From 7e9bd9ba303d4b33ecb4cf6ea1a0675c931c56e7 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 4 Oct 2026 19:04:12 +0200 Subject: [PATCH] feat: articles format option with markdown new default --- api/dependecyInjection.go | 10 +- api/handlers/article/articleHandler.go | 126 ++++-- api/pkg/apperr/messages.go | 1 + docs/api/articles.md | 97 +++++ docs/release-notes.md | 1 + docs/releases/v2.7.0-beta.md | 35 ++ features/article_body_format.feature | 229 +++++++++++ features/article_body_format.qa.md | 46 +++ go.mod | 3 +- go.sum | 6 +- internal/domain/article/bodyFormat.go | 32 ++ internal/infrastructure/markdown/blocks.go | 365 ++++++++++++++++++ internal/infrastructure/markdown/converter.go | 33 ++ .../infrastructure/markdown/jsonBlocks.go | 111 ++++++ .../infrastructure/markdown/markdownRender.go | 213 ++++++++++ .../infrastructure/useCases/CreateArticle.go | 16 +- .../infrastructure/useCases/GetArticle.go | 17 +- .../infrastructure/useCases/ListArticles.go | 21 +- .../infrastructure/useCases/UpdateArticle.go | 21 +- .../useCases/renderArticleBody.go | 24 ++ test/article_body_format_test.go | 361 +++++++++++++++++ test/article_publishing_http_test.go | 8 +- test/article_test.go | 8 +- test/article_title_rules_test.go | 10 +- test/articles_list_http_test.go | 8 +- test/body_converter_documents_test.go | 78 ++++ test/edit_article_body_http_test.go | 8 +- test/json_blocks_converter_test.go | 58 +++ test/markdown_converter_test.go | 89 +++++ test/media_env_test.go | 8 +- test/not_found_errors_test.go | 8 +- test/public_article_listing_test.go | 8 +- test/public_article_read_test.go | 8 +- test/runtime_settings_injection_http_test.go | 8 +- test/stale_token_invalidation_http_test.go | 8 +- test/use_case_baseline_test.go | 60 +-- test/verified_account_required_http_test.go | 8 +- test/write_not_found_errors_test.go | 8 +- web/bun.lock | 3 + web/package.json | 1 + web/src/components/ArticleEditor.vue | 2 + web/src/components/NewArticleModal.vue | 2 +- .../__tests__/ArticleEditor.spec.ts | 29 ++ .../__tests__/NewArticleModal.spec.ts | 4 +- .../composables/__tests__/useArticles.spec.ts | 23 ++ web/src/composables/useArticleDraft.ts | 2 +- web/src/composables/useArticles.ts | 2 +- web/src/i18n/spa.spec.ts | 4 +- web/src/lib/__tests__/articleAlign.spec.ts | 74 ++++ web/src/lib/articleAlign.ts | 8 + web/src/views/ArticleView.vue | 2 +- web/src/views/EditorView.vue | 4 +- web/src/views/IntegrationView.vue | 9 +- web/src/views/__tests__/ArticleView.spec.ts | 52 +-- .../views/__tests__/EditorImageUpload.spec.ts | 2 +- .../views/__tests__/EditorMediaPicker.spec.ts | 2 +- web/src/views/__tests__/EditorView.spec.ts | 74 ++-- .../views/__tests__/HomeUpdateBanner.spec.ts | 2 +- .../views/__tests__/HomeUpdateReady.spec.ts | 2 +- web/src/views/__tests__/HomeView.spec.ts | 42 +- .../views/__tests__/IntegrationView.spec.ts | 8 +- 61 files changed, 2269 insertions(+), 243 deletions(-) create mode 100644 docs/api/articles.md create mode 100644 docs/releases/v2.7.0-beta.md create mode 100644 features/article_body_format.feature create mode 100644 features/article_body_format.qa.md create mode 100644 internal/domain/article/bodyFormat.go create mode 100644 internal/infrastructure/markdown/blocks.go create mode 100644 internal/infrastructure/markdown/converter.go create mode 100644 internal/infrastructure/markdown/jsonBlocks.go create mode 100644 internal/infrastructure/markdown/markdownRender.go create mode 100644 internal/infrastructure/useCases/renderArticleBody.go create mode 100644 test/article_body_format_test.go create mode 100644 test/body_converter_documents_test.go create mode 100644 test/json_blocks_converter_test.go create mode 100644 test/markdown_converter_test.go create mode 100644 web/src/composables/__tests__/useArticles.spec.ts create mode 100644 web/src/lib/__tests__/articleAlign.spec.ts create mode 100644 web/src/lib/articleAlign.ts diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index d33469c..0ce6c8d 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -10,6 +10,7 @@ import ( "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" + "RenewCMS/internal/infrastructure/markdown" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/update" @@ -152,13 +153,14 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { func getUseCases(repos *Repositories, settings config.Settings) *UseCases { hasher := hashing.NewBcryptHasher() + renderer := markdown.NewConverter() return &UseCases{ Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), - CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo), - GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo), - ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo), - UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer), + GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), + ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 6d5973c..f84e361 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "strconv" + "time" "github.com/go-chi/chi/v5" ) @@ -41,6 +42,53 @@ func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleU } } +type articleResponse struct { + ID uint32 `json:"id"` + Title string `json:"title"` + Body json.RawMessage `json:"body"` + BodyFormat domain.BodyFormat `json:"body_format"` + IsOnline bool `json:"is_online"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toArticleResponse(article domain.Article, format domain.BodyFormat) (articleResponse, error) { + body := json.RawMessage(article.Body) + if format != domain.BodyFormatJSON { + encoded, err := json.Marshal(article.Body) + if err != nil { + return articleResponse{}, err + } + body = encoded + } + return articleResponse{ + ID: article.ID, + Title: article.Title, + Body: body, + BodyFormat: format, + IsOnline: article.IsOnline, + CreatedAt: article.CreatedAt, + UpdatedAt: article.UpdatedAt, + }, nil +} + +func encodeArticle(article domain.Article, format domain.BodyFormat) ([]byte, error) { + response, err := toArticleResponse(article, format) + if err != nil { + return nil, err + } + return json.Marshal(response) +} + +func parseBodyFormat(w http.ResponseWriter, r *http.Request) (domain.BodyFormat, bool) { + format, err := domain.ParseBodyFormat(r.URL.Query().Get("format")) + if err != nil { + http.Error(w, apperr.ArticleBodyFormatErrorMessage, http.StatusBadRequest) + return "", false + } + return format, true +} + func parseArticleId(r *http.Request) (uint32, error) { id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) return uint32(id), err @@ -53,11 +101,16 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { return } + format, ok := parseBodyFormat(w, r) + if !ok { + return + } + var article domain.Article if h.callerIsVerified(r) { - article, err = h.GetUseCase.GetArticle(id) + article, err = h.GetUseCase.GetArticle(id, format) } else { - article, err = h.GetUseCase.GetOnlineArticle(id) + article, err = h.GetUseCase.GetOnlineArticle(id, format) } if err != nil { if errors.Is(err, domain.ErrArticleNotFound) { @@ -68,7 +121,7 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { return } - articleJson, err := json.Marshal(article) + articleJson, err := encodeArticle(article, format) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -80,6 +133,11 @@ func (h *Handler) GetArticle(w http.ResponseWriter, r *http.Request) { } func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { + format, ok := parseBodyFormat(w, r) + if !ok { + return + } + var localArticle PostArticle if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) @@ -92,8 +150,9 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { } createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{ - Title: localArticle.Title, - Body: localArticle.Body, + Title: localArticle.Title, + Body: localArticle.Body, + Format: format, }) if err != nil { if errors.Is(err, domain.ErrInvalidTitle) { @@ -104,16 +163,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) { return } - articleJson, err := json.Marshal(createdArticle) - if err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) - if _, err := w.Write(articleJson); err != nil { - log.Printf("Failed to write response: %v", err) - } + writeArticleJsonResponse(w, http.StatusCreated, createdArticle, format) } func writeArticleErrorResponse(w http.ResponseWriter, err error) { @@ -124,13 +174,16 @@ func writeArticleErrorResponse(w http.ResponseWriter, err error) { http.Error(w, "Internal server error", http.StatusInternalServerError) } -func writeArticleJsonResponse(w http.ResponseWriter, article domain.Article) { - articleJson, err := json.Marshal(article) +func writeArticleJsonResponse(w http.ResponseWriter, status int, article domain.Article, format domain.BodyFormat) { + articleJson, err := encodeArticle(article, format) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return } + if status != http.StatusOK { + w.WriteHeader(status) + } if _, err := w.Write(articleJson); err != nil { log.Printf("Failed to write response: %v", err) } @@ -143,13 +196,18 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b return } - updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline) + format, ok := parseBodyFormat(w, r) + if !ok { + return + } + + updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format) if err != nil { writeArticleErrorResponse(w, err) return } - writeArticleJsonResponse(w, updatedArticle) + writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) } type UpdateArticleBody struct { @@ -163,6 +221,11 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { return } + format, ok := parseBodyFormat(w, r) + if !ok { + return + } + var localArticle UpdateArticleBody if err := json.NewDecoder(r.Body).Decode(&localArticle); err != nil { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) @@ -174,13 +237,13 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) { return } - updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body) + updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format) if err != nil { writeArticleErrorResponse(w, err) return } - writeArticleJsonResponse(w, updatedArticle) + writeArticleJsonResponse(w, http.StatusOK, updatedArticle, format) } func (h *Handler) PublishArticle(w http.ResponseWriter, r *http.Request) { @@ -206,19 +269,34 @@ func (h *Handler) callerIsVerified(r *http.Request) bool { } func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { + format, ok := parseBodyFormat(w, r) + if !ok { + return + } + var articles []domain.Article var err error if h.callerIsVerified(r) { - articles, err = h.ListUseCase.ListAllArticles() + articles, err = h.ListUseCase.ListAllArticles(format) } else { - articles, err = h.ListUseCase.ListOnlineArticles() + articles, err = h.ListUseCase.ListOnlineArticles(format) } if err != nil { http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) return } - articlesJson, err := json.Marshal(articles) + responses := make([]articleResponse, 0, len(articles)) + for _, article := range articles { + response, err := toArticleResponse(article, format) + if err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } + responses = append(responses, response) + } + + articlesJson, err := json.Marshal(responses) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 75d144d..18b684f 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -34,3 +34,4 @@ const UpdateAlreadyRunningErrorMessage = "An update is already running or waitin const NothingToUpdateErrorMessage = "There is nothing to update." const UpdateRefusedErrorMessage = "This update cannot be downloaded." const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again." +const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json." diff --git a/docs/api/articles.md b/docs/api/articles.md new file mode 100644 index 0000000..e80c2d3 --- /dev/null +++ b/docs/api/articles.md @@ -0,0 +1,97 @@ +# Reading articles + +Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML. + +## Routes that honour `format` + +- `GET /v1/article/` +- `GET /v1/article/{id}` +- `POST /v1/article/` +- `PATCH /v1/article/{id}` +- `PATCH /v1/article/{id}/publish` +- `PATCH /v1/article/{id}/unpublish` + +Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. + +## The `format` parameter + +| Value | `body` | `body_format` | +| --- | --- | --- | +| empty or absent | markdown string | `markdown` | +| `markdown` | markdown string | `markdown` | +| `html` | the stored HTML string | `html` | +| `json` | JSON array of blocks | `json` | + +Values are case-insensitive. When `format` is repeated, the first value is used. Any other value answers `400` with the plain-text message `The format must be one of: markdown, html, json.` before any article is read or written. An invalid id is reported first, ahead of the format. + +Breaking change: the default used to be HTML. Clients that need HTML must send `?format=html`. The web interface does so. + +## Article object + +```json +{ + "id": 1, + "title": "Backyard Renovation Update", + "body": "## Plan\n\nReplace the **old** deck.", + "body_format": "markdown", + "is_online": true, + "created_at": "2026-03-12T09:14:00Z", + "updated_at": "2026-09-10T07:00:00Z" +} +``` + +## Markdown output + +Headings, bold, italic, links, bullet and ordered lists, blockquotes, fenced code blocks, rules, images and inline code are converted. Anything else is reduced to its text. `script` and `style` content is removed, and the source HTML is never passed through; the only HTML in the output is the alignment wrapper described below. List indentation attributes are dropped. Links with a scheme other than http, https, mailto or tel are reduced to their text. Characters that markdown would read as formatting (`*`, `_`, `` ` ``, `[`, `]`, `<`, `\`) and line starts that would read as a heading, list, quote or rule (`#`, `-`, `+`, `>`, `1.`) are backslash-escaped, so plain text stays plain text. A line break becomes a backslash followed by a newline. + +### Text alignment + +Markdown has no syntax for alignment, so centered, right-aligned and justified paragraphs and headings are wrapped in a `div` with an inline style. The blank lines around the content make CommonMark parse the inside as markdown: + +```markdown +
+ +A **centered** paragraph + +
+``` + +Left alignment is the default and produces no wrapper. Your markdown renderer must allow raw HTML for the alignment to show (`html: true` in markdown-it); otherwise the wrapper tags appear as text, and a client that does not want them can request `?format=json` instead, where the alignment is a field. + +## JSON block schema + +`body` is a native JSON array. Every block has a `type`. + +| Block | Fields | +| --- | --- | +| `heading` | `level` (1 to 6), `align` (only when `center`, `right` or `justify`), `children` (text nodes) | +| `paragraph` | `align` (only when `center`, `right` or `justify`), `children` (text nodes, empty array for an empty paragraph) | +| `list` | `ordered` (boolean), `items` (array of items, each an array of blocks) | +| `blockquote` | `children` (blocks) | +| `code` | `language` (empty string when unknown), `text` | +| `hr` | none | +| `image` | `src`, `alt` (empty string when missing) | + +Text node: `{"type":"text","text":"...","marks":[...]}`. `marks` is always an array, listed outermost first. A mark is `{"type":"bold"}`, `{"type":"italic"}` or `{"type":"link","href":"..."}`. A line break becomes a `"\n"` inside the text. Adjacent text with identical marks is merged. Inline code, underline, strike-through and other formatting degrade to plain text. An image inside a paragraph becomes its own block and splits the paragraph. An empty body is `[]`. + +Example for `

Plan

Replace the old deck.

`: + +```json +[ + {"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":[]} + ]} +] +``` + +## Errors + +| Status | Body | +| --- | --- | +| `400` | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` for an invalid id | +| `400` | `The format must be one of: markdown, html, json.` for an unsupported format | +| `404` | `Article not found` | +| `500` | `Internal server error` when the body cannot be converted | diff --git a/docs/release-notes.md b/docs/release-notes.md index 34886ae..9bce210 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -29,3 +29,4 @@ Each body lives in `docs/releases/.md`. Publish with `tea releases create - | `v2.5.0-beta` | v2.5.0-beta - Media library & image picker | | `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart | | `v2.6.2-beta` | v2.6.2-beta - English and French interface | +| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies | diff --git a/docs/releases/v2.7.0-beta.md b/docs/releases/v2.7.0-beta.md new file mode 100644 index 0000000..79de0d5 --- /dev/null +++ b/docs/releases/v2.7.0-beta.md @@ -0,0 +1,35 @@ +This release lets your website read article bodies as markdown, HTML or structured blocks, and fixes text alignment in the editor. + +## New + +**Article body formats** + +- Read an article body as markdown, HTML or a typed block array with the `format` query parameter, so a headless frontend can render it with markdown-it, comark or its own components. +- Keep text alignment in every format: markdown wraps centered, right-aligned and justified blocks in a `div` with an inline style, and the block array carries an `align` field. +- Keep plain text plain: text that merely looks like markdown, such as `# not a heading` or `- not a list`, is escaped in the markdown output. +- See the Integration page snippets and [the article API reference](../api/articles.md) for the full contract. + +## Improvements and fixes + +- Fix the text alignment buttons in the article editor, which were disabled and showed a blocked cursor. Paragraphs and headings can be aligned left, center, right or justified again, and unaligned text stays free of inline styles. +- The web interface requests HTML explicitly when it loads, saves, publishes, unpublishes or creates an article, so the editor and the image counts behave exactly as before. + +## API + +- Changed: `GET /v1/article/`, `GET /v1/article/{id}`, `POST /v1/article/`, `PATCH /v1/article/{id}`, `PATCH /v1/article/{id}/publish` and `PATCH /v1/article/{id}/unpublish` now return `body` as markdown by default. Pass `?format=html` to keep the previous HTML body. +- Every article object now includes `body_format` (`markdown`, `html` or `json`), which names the format of `body`. +- `?format=json` on the same routes returns `body` as a JSON array of typed blocks instead of a string. +- An unsupported `format` answers `400` before anything is read or written. +- Storage and the request bodies of `POST` and `PATCH` stay HTML. + +## Under the hood + +- Add the article body renderer port in the domain, with a markdown and JSON block adapter, and tests for every supported element. +- Update `golang.org/x/crypto` to v0.57.0 and add `golang.org/x/net` as a direct dependency for HTML parsing. +- Add `@tiptap/extension-text-align` to the web interface. + +## Upgrading + +- Replace the binary. No database migration or new environment variable is required. +- Clients that read an article `body` as HTML must add `?format=html` to their article requests, because the default is now markdown. +- A markdown renderer needs raw HTML enabled for text alignment to show, for example `html: true` in markdown-it. diff --git a/features/article_body_format.feature b/features/article_body_format.feature new file mode 100644 index 0000000..416c60d --- /dev/null +++ b/features/article_body_format.feature @@ -0,0 +1,229 @@ +Feature: Article body content negotiation on read + + As a headless frontend developer + I want article routes to return the body as markdown (default), html or a typed json block array + So that my client can render it without handling editor HTML + + Background: + Given article bodies are stored as Tiptap HTML + And storage, the POST /v1/article body and the PATCH /v1/article/{id} body stay HTML and are unchanged + And internal/domain/article defines BodyFormat with the values "markdown", "html" and "json" + And internal/domain/article defines the port BodyRenderer and the sentinel ErrUnsupportedBodyFormat + And ParseBodyFormat matches case-insensitively, treats an empty value as "markdown", and returns ErrUnsupportedBodyFormat otherwise + And the use cases receive the BodyRenderer through their constructors, the handler only reads the "format" query value, and only api/dependecyInjection.go builds the converter + And every article object in a response has the snake_case fields id, title, body, body_format, is_online, created_at, updated_at + And body_format is "markdown", "html" or "json" and always names the format of body + And for markdown and html, body is a JSON string; for json, body is a JSON array of blocks (not an escaped string) + And the covered routes are GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish and PATCH /v1/article/{id}/unpublish + And visibility is unchanged: anonymous and unverified callers see only online articles, and an offline article reads as 404 "Article not found" + And an unsupported format answers 400 with apperr.ArticleBodyFormatErrorMessage as plain text, before any article lookup or write + And an invalid id answers apperr.IdUint32ErrorMessage first, ahead of the format check + And a repeated "format" parameter uses its first value + And a renderer failure answers 500 "Internal server error" + And article 1 is online, titled "Backyard Renovation Update", with body "

Plan

Replace the old deck.

" + + Scenario Outline: Single read negotiates the body format + When an anonymous client sends GET /v1/article/1 + Then the response status is 200 + And "body" is + And "body_format" is "" + + Examples: + | query | body | body_format | + | | "## Plan\n\nReplace the **old** deck." | markdown | + | ?format= | "## Plan\n\nReplace the **old** deck." | markdown | + | ?format=markdown | "## Plan\n\nReplace the **old** deck." | markdown | + | ?format=MarkDown | "## Plan\n\nReplace the **old** deck." | markdown | + | ?format=html | "

Plan

Replace the old deck.

" | html | + | ?format=HTML | "

Plan

Replace the old deck.

" | html | + | ?format=json | [{"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":[]}]}] | json | + | ?format=JSON | the same array as ?format=json | json | + + Scenario: List read negotiates every element + Given the repository also holds online article 3 with body "

Second

" + When an anonymous client sends GET /v1/article/ + Then every element has body_format "markdown" and article 3 has body "Second" + When an anonymous client sends GET /v1/article/?format=html + Then every element has body_format "html" and its stored HTML as body + When an anonymous client sends GET /v1/article/?format=json + Then every element has body_format "json" and article 3 has body [{"type":"paragraph","children":[{"type":"text","text":"Second","marks":[]}]}] + + Scenario Outline: An unsupported format is rejected + When an anonymous client sends GET + Then the response status is 400 + And the response body is apperr.ArticleBodyFormatErrorMessage followed by a newline + And no use case reads or renders an article + + Examples: + | path | + | /v1/article/1?format=pdf | + | /v1/article/?format=xml | + | /v1/article/9999?format=pdf | + | /v1/article/1?format=md | + + Scenario: An invalid id is reported before an invalid format + When an anonymous client sends GET /v1/article/abc?format=pdf + Then the response status is 400 + And the response body is apperr.IdUint32ErrorMessage followed by a newline + + Scenario: The first repeated format parameter wins + When an anonymous client sends GET /v1/article/1?format=html&format=json + Then "body_format" is "html" + + Scenario: Offline articles stay hidden whatever the format + Given article 2 is offline + When an anonymous client sends GET /v1/article/2?format=json + Then the response status is 404 and the body is "Article not found" + + Scenario Outline: Authenticated routes negotiate the response body + Given a valid jwt cookie for a verified user with id 1 + When the client sends with + Then the response status is + And "body_format" is "" + And the stored body is the HTML that was sent, never markdown or json + + Examples: + | method | path | request_body | status | body_format | + | POST | /v1/article/ | {"title":"Deck","body":"

A

"} | 201 | markdown | + | POST | /v1/article/?format=html | {"title":"Deck","body":"

A

"} | 201 | html | + | POST | /v1/article/?format=json | {"title":"Deck","body":"

A

"} | 201 | json | + | PATCH | /v1/article/1 | {"body":"

B

"} | 200 | markdown | + | PATCH | /v1/article/1?format=json | {"body":"

B

"} | 200 | json | + | PATCH | /v1/article/1/publish | | 200 | markdown | + | PATCH | /v1/article/1/unpublish?format=html | | 200 | html | + + Scenario: An unsupported format on a write route changes nothing + Given a valid jwt cookie for a verified user with id 1 + When the client sends PATCH /v1/article/1?format=pdf with {"body":"

B

"} + Then the response status is 400 + And the stored body of article 1 is unchanged + When the client sends POST /v1/article/?format=pdf with {"title":"Deck","body":"

A

"} + Then the response status is 400 and no article is created + + Scenario: The authenticated list includes offline articles in the negotiated format + Given a valid jwt cookie for a verified user and offline article 2 + When the client sends GET /v1/article/?format=json + Then the array contains articles 1 and 2 with body_format "json" + + Scenario: A converter failure answers 500 + Given the BodyRenderer fails on its next call + When an anonymous client sends GET /v1/article/1 + Then the response status is 500 and the body is "Internal server error" + + Scenario Outline: Markdown fidelity for Tiptap output + Given the stored body is + When converted with the format "markdown" + Then the body is + + Examples: + | html | markdown | + |

T

S

| # T\n\n### S | + |

b i

| **b** *i* | + |

link

| [link](https://example.com/x) | + |
  • a

  • b

| - a\n- b | + |
  1. a

  2. b

| 1. a\n2. b | + |
  • a

| - a | + |

q

| > q | + |
x := 1
| ```go\nx := 1\n``` | + |
| --- | + | A | ![A](/static/uploadedImages/a.png) | + |

ok

| ok | + |

xy

| xy | + |

use go test

| use `go test` | + + Scenario Outline: Json block shapes, one row per block and mark type + Given the stored body is + When converted with the format "json" + Then the body is + + Examples: + | html | json | + |

T

| [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] | + |
S
| [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] | + |

plain

| [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] | + |

| [{"type":"paragraph","children":[]}] | + |

b

| [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] | + |

i

| [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] | + |

l

| [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] | + |

x

| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] | + |

a
b

| [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] | + |
  • a

  • b

| [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] | + |
  1. a

| [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] | + |
  • bare
| [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] | + |
  • a

    • n

| a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" | + |
  • a

| the same output as without data-indent | + |

q

| [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] | + |
x := 1
| [{"type":"code","language":"go","text":"x := 1"}] | + |
raw
| [{"type":"code","language":"","text":"raw"}] | + |
| [{"type":"hr"}] | + | A | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] | + | | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] | + |

xIy

| [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] | + |

xy

| [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] | + |

use go test

| [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] | + |

ok

| [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] | + + Scenario Outline: Text alignment survives the conversion + Given the stored body is + When converted with the format "markdown" + Then the body is + When converted with the format "json" + Then the body is + + Examples: + | html | markdown | json | + |

a

|
+ +a + +
| [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] | + |

T

|
+ +## T + +
| [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] | + |

a

|
+ +a + +
| [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] | + |

a

| a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] | + |

a

| a | the same output as the left row | + |

| an empty string | [{"type":"paragraph","align":"center","children":[]}] | + + Scenario Outline: Plain text that looks like markdown stays plain text + Given the stored body is + When converted with the format "markdown" + Then the body is + + Examples: + | html | markdown | + |

# not a heading

| \# not a heading | + |

#tag

| #tag | + |

- not a list

| \- not a list | + |

+ not a list

| \+ not a list | + |

> not a quote

| \> not a quote | + |

1. not a list

| 1\. not a list | + |

2) not a list

| 2\) not a list | + |

---

| \--- | + |

a - b

| a - b | + |

a * b _c_

| a \* b \_c\_ | + |

a
- b

| a\n\- b | + + Scenario: The editor lets the author align text + Given the article editor registers the TextAlign extension for paragraphs and headings + Then the left, center, right and justify toolbar buttons are enabled + And clicking center writes style="text-align: center;" on the current paragraph or heading + And paragraphs and headings that were never aligned carry no style attribute + + Scenario: The SPA asks for html explicitly + Given no visual change is expected, so docs/design is not consulted + When EditorView loads an article, or ArticleView or HomeView load the list + Then each request is /v1/article/{id}?format=html or /v1/article/?format=html + And the draft save PATCH, the publish and unpublish PATCH and the create POST also carry ?format=html, so a converter failure cannot fail a save + And the editor and the image counts receive HTML exactly as before + + Scenario: The documentation reflects the contract + Then docs/api has an article read page covering format, the markdown default, body_format, the json block schema, the 400 message and the breaking default change + And web/src/views/IntegrationView.vue snippets show ?format + And docs/releases has a note stating the default flip and that the SPA sends format=html diff --git a/features/article_body_format.qa.md b/features/article_body_format.qa.md new file mode 100644 index 0000000..599e15f --- /dev/null +++ b/features/article_body_format.qa.md @@ -0,0 +1,46 @@ +# QA procedure: article_body_format + +Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP. + +## Setup +1. Call `validator.InitValidator()` once. +2. Reuse `mockArticleRepo` and `mockUserRepo`. Add a fake `fakeBodyRenderer` with a unique type name. It records each call's format and the html it received. For markdown it returns "MD:"+html. For html it returns the html unchanged. For json it returns `[{"type":"paragraph","children":[{"type":"text","text":"FAKE","marks":[]}]}]`. It has a failure switch. +3. Wire the real use cases over the fakes, passing the renderer. Update every existing test that builds these use cases for the new constructor signatures. Build `article.NewHandler(...)`. +4. Mount a fresh `chi.NewRouter()` mirroring `api/router.go` for `/v1/article`: `jwtauth.Verifier` on the group, GET `/` and `/{id}` public, and `Authenticator` + `RequireCurrentToken` + `RequireVerifiedUser` for POST, DELETE and PATCH. +5. Mint a jwt cookie for user_id 1 with `token.Encode(map[string]any{"user_id": uint32(1)})`. Seed a verified user. Seed articles 1 (online) and 2 (offline). + +## HTTP checks (fake renderer) +6. Single read matrix: for each of none, `?format=`, markdown, MarkDown, html, HTML, json, JSON, assert status 200, `body_format`, the renderer's recorded format and the stored HTML passed in. For json, decode `body` and assert it is a JSON array (not a string). +7. List with none, `?format=html` and `?format=json`: every element has the matching `body_format`. +8. `format=pdf`, `xml` and `md` on single, list and unknown-id paths return 400 with `apperr.ArticleBodyFormatErrorMessage + "\n"`. The renderer and the repo are not called. +9. `GET /v1/article/abc?format=pdf` returns 400 with `apperr.IdUint32ErrorMessage + "\n"`. +10. `?format=html&format=json` returns html. +11. `GET /v1/article/2?format=json` anonymous returns 404 `"Article not found\n"`. +12. Write-route matrix with a cookie (POST, PATCH, publish, unpublish, each with none, html and json where listed): assert status, `body_format`, and that the repo holds the HTML that was sent. +13. `PATCH /v1/article/1?format=pdf` and `POST /v1/article/?format=pdf` with a cookie return 400. The repo is unchanged and no article is created. +14. Authenticated `GET /v1/article/?format=json` includes articles 1 and 2. +15. Renderer failure switch, then `GET /v1/article/1` returns 500 `"Internal server error\n"`. + +## Domain +16. `ParseBodyFormat`: "", markdown, MARKDOWN, html, Html, json, JSON are accepted (empty returns markdown). pdf, md and " html" return `errors.Is(err, ErrUnsupportedBodyFormat)`. + +## Converter tests (real library, real Tiptap HTML samples) +17. `test/markdown_converter_test.go`: every row of the markdown fidelity table, plus a combined document (heading, paragraph with marks, list, image, code block, quote, hr). Assert exact output. For format html, assert the input comes back byte-identical. +17b. Alignment and escaping: every row of the alignment and plain-text tables, for markdown and json, in `test/markdown_converter_test.go` and `test/json_blocks_converter_test.go`. `test/body_converter_documents_test.go` covers the combined hostile document (no raw script, style, iframe or span leaks in markdown or json), its block types, and code block text extraction. +18. `test/json_blocks_converter_test.go`: every row of the json shapes table. Assert by unmarshalling both sides and comparing with deep equality. This also asserts `marks` is `[]` rather than null or absent, `ordered` is a bool, and code `language` is "" when absent. Also include a combined document with nested list, blockquote and an image inside a paragraph. Assert that script and style content never appears, and that no raw HTML survives in any output for any format except html. +19. `test/json_blocks_converter_test.go` also asserts that html "" produces `[]`, and that the output is a non-null JSON array. + +## SPA (Vitest, `task test:web`) +20. Mock `/v1/article/12?format=html` and assert `EditorView` requests that URL and loads the HTML body into the draft. +21. Assert `useArticles.listArticles` requests `/v1/article/?format=html`. +22. Assert the `ArticleView` image count is computed from the HTML. +23. Assert `useArticleDraft.save` sends PATCH `/v1/article/{id}?format=html` with the JSON body. +24. Reported as NOT RUN if there is no runner. No visual check applies, because no visual change is expected. + +## Docs +25. Check that a docs/api article read page, the IntegrationView snippets and a docs/releases note exist as described. + +## Manual verification +- In the embedded SPA, edit and save an article against a real SQLite file and confirm the stored body is still HTML. +- `curl` the public list and a single article with no format, `?format=html` and `?format=json` against a built binary. +- No SMTP involvement. diff --git a/go.mod b/go.mod index 65ba77a..7020b8e 100644 --- a/go.mod +++ b/go.mod @@ -11,8 +11,9 @@ require ( github.com/go-playground/validator/v10 v10.28.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - golang.org/x/crypto v0.44.0 + golang.org/x/crypto v0.57.0 golang.org/x/image v0.46.0 + golang.org/x/net v0.59.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/gorm v1.31.1 ) diff --git a/go.sum b/go.sum index de28d55..95093f9 100644 --- a/go.sum +++ b/go.sum @@ -78,14 +78,16 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/domain/article/bodyFormat.go b/internal/domain/article/bodyFormat.go new file mode 100644 index 0000000..5170c7f --- /dev/null +++ b/internal/domain/article/bodyFormat.go @@ -0,0 +1,32 @@ +package article + +import ( + "errors" + "strings" +) + +var ErrUnsupportedBodyFormat = errors.New("unsupported body format") + +type BodyFormat string + +const ( + BodyFormatMarkdown BodyFormat = "markdown" + BodyFormatHTML BodyFormat = "html" + BodyFormatJSON BodyFormat = "json" +) + +func ParseBodyFormat(value string) (BodyFormat, error) { + switch strings.ToLower(value) { + case "", string(BodyFormatMarkdown): + return BodyFormatMarkdown, nil + case string(BodyFormatHTML): + return BodyFormatHTML, nil + case string(BodyFormatJSON): + return BodyFormatJSON, nil + } + return "", ErrUnsupportedBodyFormat +} + +type BodyRenderer interface { + Render(html string, format BodyFormat) (string, error) +} diff --git a/internal/infrastructure/markdown/blocks.go b/internal/infrastructure/markdown/blocks.go new file mode 100644 index 0000000..477de51 --- /dev/null +++ b/internal/infrastructure/markdown/blocks.go @@ -0,0 +1,365 @@ +package markdown + +import ( + "net/url" + "strconv" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +const ( + markBold = "bold" + markItalic = "italic" + markLink = "link" + markCode = "code" + + codeLanguagePrefix = "language-" +) + +type mark struct { + kind string + href string +} + +type inline struct { + text string + marks []mark +} + +type blockKind int + +const ( + kindHeading blockKind = iota + kindParagraph + kindList + kindBlockquote + kindCode + kindRule + kindImage +) + +type block struct { + kind blockKind + level int + inlines []inline + ordered bool + items [][]block + children []block + language string + text string + src string + alt string + align string +} + +var transparentBlocks = map[string]bool{ + "div": true, "section": true, "article": true, "header": true, "footer": true, + "main": true, "aside": true, "nav": true, "figure": true, "figcaption": true, + "table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true, + "td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true, + "summary": true, "li": true, +} + +var skippedElements = map[string]bool{ + "script": true, "style": true, "template": true, "head": true, + "noscript": true, "iframe": true, "object": true, "embed": true, +} + +var alignments = map[string]bool{"center": true, "right": true, "justify": true} + +var allowedLinkSchemes = map[string]bool{ + "": true, "http": true, "https": true, "mailto": true, "tel": true, +} + +func parseBlocks(source string) ([]block, error) { + context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"} + nodes, err := html.ParseFragment(strings.NewReader(source), context) + if err != nil { + return nil, err + } + collector := newCollector(false, paragraphBlock) + collector.walkAll(nodes, nil) + return collector.finish(false), nil +} + +func paragraphBlock(inlines []inline) block { + return block{kind: kindParagraph, inlines: inlines} +} + +func headingBlock(level int) func([]inline) block { + return func(inlines []inline) block { + return block{kind: kindHeading, level: level, inlines: inlines} + } +} + +type collector struct { + out []block + current []inline + keepBlank bool + build func([]inline) block +} + +func newCollector(keepBlank bool, build func([]inline) block) *collector { + return &collector{keepBlank: keepBlank, build: build} +} + +func (c *collector) walkAll(nodes []*html.Node, marks []mark) { + for _, node := range nodes { + c.walk(node, marks) + } +} + +func (c *collector) walkChildren(parent *html.Node, marks []mark) { + for child := parent.FirstChild; child != nil; child = child.NextSibling { + c.walk(child, marks) + } +} + +func (c *collector) addText(text string, marks []mark) { + if text == "" { + return + } + c.current = appendInline(c.current, inline{text: text, marks: marks}) +} + +func (c *collector) flush() { + if len(c.current) == 0 { + return + } + if c.keepBlank || hasVisibleText(c.current) { + c.out = append(c.out, c.build(c.current)) + } + c.current = nil +} + +func (c *collector) finish(emitEmpty bool) []block { + hadBlocks := len(c.out) > 0 + c.flush() + if emitEmpty && !hadBlocks && len(c.out) == 0 { + c.out = append(c.out, c.build(nil)) + } + return c.out +} + +func (c *collector) walk(node *html.Node, marks []mark) { + switch node.Type { + case html.TextNode: + c.addText(node.Data, marks) + return + case html.ElementNode: + default: + return + } + + tag := node.Data + if skippedElements[tag] { + return + } + + switch tag { + case "br": + c.addText("\n", marks) + case "strong", "b": + c.walkChildren(node, withMark(marks, mark{kind: markBold})) + case "em", "i": + c.walkChildren(node, withMark(marks, mark{kind: markItalic})) + case "code": + c.walkChildren(node, withMark(marks, mark{kind: markCode})) + case "a": + href := safeHref(attr(node, "href")) + if href == "" { + c.walkChildren(node, marks) + return + } + c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href})) + case "img": + c.flush() + if src := attr(node, "src"); src != "" { + c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")}) + } + case "hr": + c.flush() + c.out = append(c.out, block{kind: kindRule}) + case "p": + c.flush() + c.out = append(c.out, parseTextBlock(node, paragraphBlock)...) + case "h1", "h2", "h3", "h4", "h5", "h6": + c.flush() + level, _ := strconv.Atoi(tag[1:]) + c.out = append(c.out, parseTextBlock(node, headingBlock(level))...) + case "ul", "ol": + c.flush() + c.out = append(c.out, parseList(node)) + case "blockquote": + c.flush() + c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)}) + case "pre": + c.flush() + c.out = append(c.out, parseCodeBlock(node)) + default: + if transparentBlocks[tag] { + c.flush() + c.walkChildren(node, marks) + c.flush() + return + } + c.walkChildren(node, marks) + } +} + +func parseTextBlock(node *html.Node, build func([]inline) block) []block { + inner := newCollector(true, build) + inner.walkChildren(node, nil) + blocks := inner.finish(true) + if align := alignFromStyle(attr(node, "style")); align != "" { + for index := range blocks { + if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading { + blocks[index].align = align + } + } + } + return blocks +} + +func alignFromStyle(style string) string { + for _, declaration := range strings.Split(style, ";") { + name, value, found := strings.Cut(declaration, ":") + if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" { + continue + } + if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] { + return align + } + } + return "" +} + +func parseContainer(node *html.Node) []block { + inner := newCollector(false, paragraphBlock) + inner.walkChildren(node, nil) + return inner.finish(false) +} + +func parseList(node *html.Node) block { + list := block{kind: kindList, ordered: node.Data == "ol"} + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "li" { + list.items = append(list.items, parseContainer(child)) + } + } + return list +} + +func parseCodeBlock(node *html.Node) block { + code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")} + for child := node.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "code" { + code.language = languageFromClass(attr(child, "class")) + break + } + } + return code +} + +func languageFromClass(class string) string { + for _, name := range strings.Fields(class) { + if strings.HasPrefix(name, codeLanguagePrefix) { + return strings.TrimPrefix(name, codeLanguagePrefix) + } + } + return "" +} + +func textContent(node *html.Node) string { + var builder strings.Builder + var visit func(*html.Node) + visit = func(n *html.Node) { + switch { + case n.Type == html.TextNode: + builder.WriteString(n.Data) + case n.Type == html.ElementNode && skippedElements[n.Data]: + return + case n.Type == html.ElementNode && n.Data == "br": + builder.WriteString("\n") + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + visit(child) + } + } + visit(node) + return builder.String() +} + +func attr(node *html.Node, name string) string { + for _, a := range node.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +func safeHref(href string) string { + trimmed := strings.TrimSpace(href) + parsed, err := url.Parse(trimmed) + if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] { + return "" + } + return trimmed +} + +func withMark(marks []mark, added mark) []mark { + for _, existing := range marks { + if existing.kind == added.kind { + return marks + } + } + next := make([]mark, 0, len(marks)+1) + next = append(next, marks...) + return append(next, added) +} + +func hasVisibleText(inlines []inline) bool { + for _, item := range inlines { + if strings.TrimSpace(item.text) != "" { + return true + } + } + return false +} + +func sameMarks(a, b []mark) bool { + if len(a) != len(b) { + return false + } + for index := range a { + if a[index] != b[index] { + return false + } + } + return true +} + +func appendInline(inlines []inline, next inline) []inline { + if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) { + inlines[last].text += next.text + return inlines + } + return append(inlines, next) +} + +func withoutCodeMarks(inlines []inline) []inline { + merged := make([]inline, 0, len(inlines)) + for _, item := range inlines { + kept := make([]mark, 0, len(item.marks)) + for _, m := range item.marks { + if m.kind != markCode { + kept = append(kept, m) + } + } + merged = appendInline(merged, inline{text: item.text, marks: kept}) + } + return merged +} diff --git a/internal/infrastructure/markdown/converter.go b/internal/infrastructure/markdown/converter.go new file mode 100644 index 0000000..166fac2 --- /dev/null +++ b/internal/infrastructure/markdown/converter.go @@ -0,0 +1,33 @@ +package markdown + +import ( + domain "RenewCMS/internal/domain/article" +) + +type Converter struct{} + +var _ domain.BodyRenderer = &Converter{} + +func NewConverter() *Converter { + return &Converter{} +} + +func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) { + switch format { + case domain.BodyFormatHTML: + return html, nil + case domain.BodyFormatMarkdown: + blocks, err := parseBlocks(html) + if err != nil { + return "", err + } + return renderMarkdown(blocks), nil + case domain.BodyFormatJSON: + blocks, err := parseBlocks(html) + if err != nil { + return "", err + } + return renderJSON(blocks) + } + return "", domain.ErrUnsupportedBodyFormat +} diff --git a/internal/infrastructure/markdown/jsonBlocks.go b/internal/infrastructure/markdown/jsonBlocks.go new file mode 100644 index 0000000..2bbed51 --- /dev/null +++ b/internal/infrastructure/markdown/jsonBlocks.go @@ -0,0 +1,111 @@ +package markdown + +import ( + "bytes" + "encoding/json" + "strings" +) + +type jsonMark struct { + Type string `json:"type"` + Href string `json:"href,omitempty"` +} + +type jsonText struct { + Type string `json:"type"` + Text string `json:"text"` + Marks []jsonMark `json:"marks"` +} + +type jsonHeading struct { + Type string `json:"type"` + Level int `json:"level"` + Align string `json:"align,omitempty"` + Children []jsonText `json:"children"` +} + +type jsonParagraph struct { + Type string `json:"type"` + Align string `json:"align,omitempty"` + Children []jsonText `json:"children"` +} + +type jsonList struct { + Type string `json:"type"` + Ordered bool `json:"ordered"` + Items [][]any `json:"items"` +} + +type jsonBlockquote struct { + Type string `json:"type"` + Children []any `json:"children"` +} + +type jsonCode struct { + Type string `json:"type"` + Language string `json:"language"` + Text string `json:"text"` +} + +type jsonRule struct { + Type string `json:"type"` +} + +type jsonImage struct { + Type string `json:"type"` + Src string `json:"src"` + Alt string `json:"alt"` +} + +func renderJSON(blocks []block) (string, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(toJSONBlocks(blocks)); err != nil { + return "", err + } + return strings.TrimSuffix(buffer.String(), "\n"), nil +} + +func toJSONBlocks(blocks []block) []any { + result := make([]any, 0, len(blocks)) + for _, b := range blocks { + result = append(result, toJSONBlock(b)) + } + return result +} + +func toJSONBlock(b block) any { + switch b.kind { + case kindHeading: + return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)} + case kindParagraph: + return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)} + case kindList: + items := make([][]any, 0, len(b.items)) + for _, item := range b.items { + items = append(items, toJSONBlocks(item)) + } + return jsonList{Type: "list", Ordered: b.ordered, Items: items} + case kindBlockquote: + return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)} + case kindCode: + return jsonCode{Type: "code", Language: b.language, Text: b.text} + case kindImage: + return jsonImage{Type: "image", Src: b.src, Alt: b.alt} + } + return jsonRule{Type: "hr"} +} + +func toJSONTexts(inlines []inline) []jsonText { + plain := withoutCodeMarks(inlines) + texts := make([]jsonText, 0, len(plain)) + for _, item := range plain { + marks := make([]jsonMark, 0, len(item.marks)) + for _, m := range item.marks { + marks = append(marks, jsonMark{Type: m.kind, Href: m.href}) + } + texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks}) + } + return texts +} diff --git a/internal/infrastructure/markdown/markdownRender.go b/internal/infrastructure/markdown/markdownRender.go new file mode 100644 index 0000000..2eb59d9 --- /dev/null +++ b/internal/infrastructure/markdown/markdownRender.go @@ -0,0 +1,213 @@ +package markdown + +import ( + "regexp" + "strconv" + "strings" +) + +const ( + blockSeparator = "\n\n" + hardBreak = "\\\n" + bulletMarker = "- " + codeFence = "```" + alignOpen = `
` + alignClose = "
" +) + +var textEscaper = strings.NewReplacer( + "\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<", +) + +var ( + numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`) + markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`) +) + +var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E") + +func renderMarkdown(blocks []block) string { + return renderBlocks(blocks, blockSeparator) +} + +func renderBlocks(blocks []block, separator string) string { + parts := make([]string, 0, len(blocks)) + for _, b := range blocks { + if rendered := renderBlock(b); rendered != "" { + parts = append(parts, rendered) + } + } + return strings.Join(parts, separator) +} + +func renderBlock(b block) string { + rendered := renderPlainBlock(b) + if b.align == "" || rendered == "" { + return rendered + } + return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose +} + +func renderPlainBlock(b block) string { + switch b.kind { + case kindHeading: + return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines) + case kindParagraph: + return escapeLineStarts(renderInlines(b.inlines)) + case kindList: + return renderList(b) + case kindBlockquote: + return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">") + case kindCode: + return renderCode(b) + case kindImage: + return "![" + escapeAlt(b.alt) + "](" + hrefEscaper.Replace(b.src) + ")" + } + return "---" +} + +func escapeLineStarts(text string) string { + lines := strings.Split(text, "\n") + for index, line := range lines { + line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`) + if markerLineStart.MatchString(line) { + line = `\` + line + } + lines[index] = line + } + return strings.Join(lines, "\n") +} + +func renderCode(b block) string { + fence := codeFence + for strings.Contains(b.text, fence) { + fence += "`" + } + return fence + b.language + "\n" + b.text + "\n" + fence +} + +func escapeAlt(alt string) string { + return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt) +} + +func renderList(b block) string { + lines := make([]string, 0, len(b.items)) + for index, item := range b.items { + marker := bulletMarker + if b.ordered { + marker = strconv.Itoa(index+1) + ". " + } + content := renderBlocks(item, itemSeparator(item)) + lines = append(lines, marker+indentContinuation(content, len(marker))) + } + return strings.Join(lines, "\n") +} + +func itemSeparator(item []block) string { + textBlocks := 0 + for _, b := range item { + if b.kind != kindList { + textBlocks++ + } + } + if textBlocks > 1 { + return blockSeparator + } + return "\n" +} + +func indentContinuation(content string, width int) string { + indent := strings.Repeat(" ", width) + lines := strings.Split(content, "\n") + for index := 1; index < len(lines); index++ { + if lines[index] != "" { + lines[index] = indent + lines[index] + } + } + return strings.Join(lines, "\n") +} + +func prefixLines(content, prefix, emptyPrefix string) string { + lines := strings.Split(content, "\n") + for index, line := range lines { + if line == "" { + lines[index] = emptyPrefix + continue + } + lines[index] = prefix + line + } + return strings.Join(lines, "\n") +} + +func renderInlines(inlines []inline) string { + var builder strings.Builder + for _, item := range inlines { + builder.WriteString(renderInline(item)) + } + return builder.String() +} + +func renderInline(item inline) string { + isCode := hasMarkKind(item.marks, markCode) + lines := strings.Split(item.text, "\n") + for index, line := range lines { + lines[index] = wrapMarks(item.marks, line, isCode) + } + return strings.Join(lines, hardBreak) +} + +func hasMarkKind(marks []mark, kind string) bool { + for _, m := range marks { + if m.kind == kind { + return true + } + } + return false +} + +func wrapMarks(marks []mark, text string, isCode bool) string { + core := strings.TrimSpace(text) + if core == "" { + return text + } + start := strings.Index(text, core) + leading, trailing := text[:start], text[start+len(core):] + var wrapped string + if isCode { + wrapped = inlineCode(core) + } else { + wrapped = escapeText(core) + } + for index := len(marks) - 1; index >= 0; index-- { + wrapped = applyMark(marks[index], wrapped) + } + return leading + wrapped + trailing +} + +func inlineCode(text string) string { + fence := "`" + for strings.Contains(text, fence) { + fence += "`" + } + if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") { + return fence + " " + text + " " + fence + } + return fence + text + fence +} + +func applyMark(m mark, text string) string { + switch m.kind { + case markBold: + return "**" + text + "**" + case markItalic: + return "*" + text + "*" + case markLink: + return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")" + } + return text +} + +func escapeText(text string) string { + return textEscaper.Replace(text) +} diff --git a/internal/infrastructure/useCases/CreateArticle.go b/internal/infrastructure/useCases/CreateArticle.go index 35c7ab4..3272ee1 100644 --- a/internal/infrastructure/useCases/CreateArticle.go +++ b/internal/infrastructure/useCases/CreateArticle.go @@ -6,15 +6,17 @@ import ( type CreateArticleUseCase struct { articleRepository article.Repository + bodyRenderer article.BodyRenderer } type CreateArticleCommand struct { - Title string - Body string + Title string + Body string + Format article.BodyFormat } -func NewCreateArticleUseCase(articleRepository article.Repository) *CreateArticleUseCase { - return &CreateArticleUseCase{articleRepository} +func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *CreateArticleUseCase { + return &CreateArticleUseCase{articleRepository, bodyRenderer} } func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) (article.Article, error) { @@ -22,9 +24,13 @@ func (g *CreateArticleUseCase) CreateArticle(createArticle CreateArticleCommand) return article.Article{}, err } - return g.articleRepository.Create(article.Article{ + created, err := g.articleRepository.Create(article.Article{ Title: createArticle.Title, Body: createArticle.Body, IsOnline: false, }) + if err != nil { + return article.Article{}, err + } + return renderArticle(g.bodyRenderer, created, createArticle.Format) } diff --git a/internal/infrastructure/useCases/GetArticle.go b/internal/infrastructure/useCases/GetArticle.go index 72b150b..e4b05f2 100644 --- a/internal/infrastructure/useCases/GetArticle.go +++ b/internal/infrastructure/useCases/GetArticle.go @@ -4,13 +4,14 @@ import "RenewCMS/internal/domain/article" type GetArticleUseCase struct { articleRepository article.Repository + bodyRenderer article.BodyRenderer } -func NewGetArticleUseCase(articleRepository article.Repository) *GetArticleUseCase { - return &GetArticleUseCase{articleRepository} +func NewGetArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *GetArticleUseCase { + return &GetArticleUseCase{articleRepository, bodyRenderer} } -func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) { +func (u *GetArticleUseCase) GetOnlineArticle(id uint32, format article.BodyFormat) (article.Article, error) { art, err := u.articleRepository.Get(id) if err != nil { return article.Article{}, err @@ -20,11 +21,15 @@ func (u *GetArticleUseCase) GetOnlineArticle(id uint32) (article.Article, error) return article.Article{}, article.ErrArticleNotFound } - return art, nil + return renderArticle(u.bodyRenderer, art, format) } -func (u *GetArticleUseCase) GetArticle(id uint32) (article.Article, error) { - return u.articleRepository.Get(id) +func (u *GetArticleUseCase) GetArticle(id uint32, format article.BodyFormat) (article.Article, error) { + art, err := u.articleRepository.Get(id) + if err != nil { + return article.Article{}, err + } + return renderArticle(u.bodyRenderer, art, format) } func (g *GetArticleUseCase) GetArticleByName(name string) (article.Article, error) { diff --git a/internal/infrastructure/useCases/ListArticles.go b/internal/infrastructure/useCases/ListArticles.go index 9bba621..a3e63e8 100644 --- a/internal/infrastructure/useCases/ListArticles.go +++ b/internal/infrastructure/useCases/ListArticles.go @@ -4,19 +4,28 @@ import "RenewCMS/internal/domain/article" type ListArticlesUseCase struct { articleRepository article.Repository + bodyRenderer article.BodyRenderer } -func NewListArticlesUseCase(articleRepository article.Repository) *ListArticlesUseCase { - return &ListArticlesUseCase{articleRepository} +func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *ListArticlesUseCase { + return &ListArticlesUseCase{articleRepository, bodyRenderer} } -func (u *ListArticlesUseCase) ListOnlineArticles() ([]article.Article, error) { +func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) { online := true - return u.articleRepository.FindByFilters(article.Filters{ + articles, err := u.articleRepository.FindByFilters(article.Filters{ IsOnline: &online, }) + if err != nil { + return nil, err + } + return renderArticles(u.bodyRenderer, articles, format) } -func (u *ListArticlesUseCase) ListAllArticles() ([]article.Article, error) { - return u.articleRepository.FindByFilters(article.Filters{}) +func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) { + articles, err := u.articleRepository.FindByFilters(article.Filters{}) + if err != nil { + return nil, err + } + return renderArticles(u.bodyRenderer, articles, format) } diff --git a/internal/infrastructure/useCases/UpdateArticle.go b/internal/infrastructure/useCases/UpdateArticle.go index db3dec7..629c1e8 100644 --- a/internal/infrastructure/useCases/UpdateArticle.go +++ b/internal/infrastructure/useCases/UpdateArticle.go @@ -4,16 +4,25 @@ import "RenewCMS/internal/domain/article" type UpdateArticleUseCase struct { articleRepository article.Repository + bodyRenderer article.BodyRenderer } -func NewUpdateArticleUseCase(articleRepository article.Repository) *UpdateArticleUseCase { - return &UpdateArticleUseCase{articleRepository} +func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer) *UpdateArticleUseCase { + return &UpdateArticleUseCase{articleRepository, bodyRenderer} } -func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string) (article.Article, error) { - return g.articleRepository.UpdateBody(id, body) +func (g *UpdateArticleUseCase) UpdateBody(id uint32, body string, format article.BodyFormat) (article.Article, error) { + updated, err := g.articleRepository.UpdateBody(id, body) + if err != nil { + return article.Article{}, err + } + return renderArticle(g.bodyRenderer, updated, format) } -func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) { - return g.articleRepository.UpdateIsOnline(id, isOnline) +func (g *UpdateArticleUseCase) UpdateIsOnline(id uint32, isOnline bool, format article.BodyFormat) (article.Article, error) { + updated, err := g.articleRepository.UpdateIsOnline(id, isOnline) + if err != nil { + return article.Article{}, err + } + return renderArticle(g.bodyRenderer, updated, format) } diff --git a/internal/infrastructure/useCases/renderArticleBody.go b/internal/infrastructure/useCases/renderArticleBody.go new file mode 100644 index 0000000..f2b3f27 --- /dev/null +++ b/internal/infrastructure/useCases/renderArticleBody.go @@ -0,0 +1,24 @@ +package useCases + +import "RenewCMS/internal/domain/article" + +func renderArticle(renderer article.BodyRenderer, art article.Article, format article.BodyFormat) (article.Article, error) { + body, err := renderer.Render(art.Body, format) + if err != nil { + return article.Article{}, err + } + art.Body = body + return art, nil +} + +func renderArticles(renderer article.BodyRenderer, articles []article.Article, format article.BodyFormat) ([]article.Article, error) { + rendered := make([]article.Article, 0, len(articles)) + for _, art := range articles { + renderedArticle, err := renderArticle(renderer, art, format) + if err != nil { + return nil, err + } + rendered = append(rendered, renderedArticle) + } + return rendered, nil +} diff --git a/test/article_body_format_test.go b/test/article_body_format_test.go new file mode 100644 index 0000000..41e2dc7 --- /dev/null +++ b/test/article_body_format_test.go @@ -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 = "

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

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(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(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) + } + } +} diff --git a/test/article_publishing_http_test.go b/test/article_publishing_http_test.go index 458424b..9ea534d 100644 --- a/test/article_publishing_http_test.go +++ b/test/article_publishing_http_test.go @@ -23,11 +23,11 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/article_test.go b/test/article_test.go index 532cd0c..cb0bb28 100644 --- a/test/article_test.go +++ b/test/article_test.go @@ -148,7 +148,7 @@ var _ article.Repository = &mockArticleRepo{} func TestCreateArticle(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo) + uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}) t.Run("Valid Title", func(t *testing.T) { cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"} @@ -174,17 +174,17 @@ func TestGetOnlineArticle(t *testing.T) { repo := newMockArticleRepo() repo.articles[1] = article.Article{ID: 1, IsOnline: true} repo.articles[2] = article.Article{ID: 2, IsOnline: false} - uc := useCases.NewGetArticleUseCase(repo) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) t.Run("Article Online", func(t *testing.T) { - _, err := uc.GetOnlineArticle(1) + _, err := uc.GetOnlineArticle(1, article.BodyFormatHTML) if err != nil { t.Errorf("Expected article to be found") } }) t.Run("Article Offline", func(t *testing.T) { - _, err := uc.GetOnlineArticle(2) + _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("Offline article should return not found") } diff --git a/test/article_title_rules_test.go b/test/article_title_rules_test.go index 534ab53..fa2f03e 100644 --- a/test/article_title_rules_test.go +++ b/test/article_title_rules_test.go @@ -26,11 +26,11 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) * getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(repo), - useCases.NewGetArticleUseCase(repo), - useCases.NewListArticlesUseCase(repo), + useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(repo), - useCases.NewUpdateArticleUseCase(repo), + useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}), getUserUseCase, ) @@ -87,7 +87,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) { for _, tc := range cases { t.Run(fmt.Sprintf("rune_count_%d", tc.runeCount), func(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo) + uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}) title := strings.Repeat("\U0001F600", tc.runeCount) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go index 5c579db..00c7f49 100644 --- a/test/articles_list_http_test.go +++ b/test/articles_list_http_test.go @@ -20,11 +20,11 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/body_converter_documents_test.go b/test/body_converter_documents_test.go new file mode 100644 index 0000000..f2babd9 --- /dev/null +++ b/test/body_converter_documents_test.go @@ -0,0 +1,78 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + "RenewCMS/internal/infrastructure/markdown" + "encoding/json" + "reflect" + "strings" + "testing" +) + +const hostileBody = `

Title

Intro bold

  • one

    • deep

kept
I
a
` + +func TestConverterNeverLeaksRawHtml(t *testing.T) { + converter := markdown.NewConverter() + for _, format := range []domainArticle.BodyFormat{domainArticle.BodyFormatMarkdown, domainArticle.BodyFormatJSON} { + got, err := converter.Render(hostileBody, format) + if err != nil { + t.Fatalf("%s: unexpected error: %v", format, err) + } + for _, forbidden := range []string{"x"} { + got, err := converter.Render(source, domainArticle.BodyFormatJSON) + if err != nil || got != "[]" { + t.Errorf("%q: expected [] got %q, %v", source, got, err) + } + } +} + +func TestMarkdownConverterCodeBlockText(t *testing.T) { + converter := markdown.NewConverter() + examples := []struct{ name, html, markdown string }{ + {"text node kept", "
x
", "```\nx\n```"}, + {"skipped element dropped", "
ab
", "```\nab\n```"}, + {"style dropped", "
a
", "```\na\n```"}, + {"break becomes newline", "
a
b
", "```\na\nb\n```"}, + {"nested element text kept", "
abc
", "```\nabc\n```"}, + {"trailing newline trimmed", "
a\n
", "```\na\n```"}, + {"fence grows around backticks", "
```\nx
", "````\n```\nx\n````"}, + } + for _, example := range examples { + t.Run(example.name, func(t *testing.T) { + got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown) + if err != nil || got != example.markdown { + t.Errorf("expected %q, got %q, %v", example.markdown, got, err) + } + }) + } +} diff --git a/test/edit_article_body_http_test.go b/test/edit_article_body_http_test.go index e4af86b..ea0851b 100644 --- a/test/edit_article_body_http_test.go +++ b/test/edit_article_body_http_test.go @@ -24,11 +24,11 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/json_blocks_converter_test.go b/test/json_blocks_converter_test.go new file mode 100644 index 0000000..386f6ad --- /dev/null +++ b/test/json_blocks_converter_test.go @@ -0,0 +1,58 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + "RenewCMS/internal/infrastructure/markdown" + "testing" +) + +func TestJsonBlocksConverter(t *testing.T) { + converter := markdown.NewConverter() + paragraphA := `{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}` + listOfA := `[{"type":"list","ordered":false,"items":[[` + paragraphA + `]]}]` + examples := []struct { + name, html, json string + }{ + {"h1", "

T

", `[{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}]`}, + {"h6", "
S
", `[{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}]`}, + {"paragraph", "

plain

", `[{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}]`}, + {"empty paragraph", "

", `[{"type":"paragraph","children":[]}]`}, + {"bold", "

b

", `[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}]`}, + {"italic", "

i

", `[{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}]`}, + {"link", `

l

`, `[{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}]`}, + {"nested marks outermost first", `

x

`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}]`}, + {"line break", "

a
b

", `[{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}]`}, + {"bullet list", "
  • a

  • b

", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}]`}, + {"ordered list", "
  1. a

", `[{"type":"list","ordered":true,"items":[[` + paragraphA + `]]}]`}, + {"bare list item", "
  • a
", listOfA}, + {"nested list", "
  • a

    • n

", `[{"type":"list","ordered":false,"items":[[` + paragraphA + `,{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"n","marks":[]}]}]]}]]}]`}, + {"data-indent ignored", `
  • a

`, listOfA}, + {"blockquote", "

q

", `[{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}]`}, + {"code with language", `
x := 1
`, `[{"type":"code","language":"go","text":"x := 1"}]`}, + {"code without language", "
raw
", `[{"type":"code","language":"","text":"raw"}]`}, + {"rule", "
", `[{"type":"hr"}]`}, + {"image with alt", `A`, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}]`}, + {"image without alt", ``, `[{"type":"image","src":"/static/uploadedImages/a.png","alt":""}]`}, + {"image splits paragraph", `

xIy

`, `[{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}]`}, + {"span stripped", `

xy

`, `[{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}]`}, + {"inline code degrades", "

use go test

", `[{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}]`}, + {"script and style removed", "

ok

", `[{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}]`}, + {"empty html", "", `[]`}, + {"centered paragraph", `

a

`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}]`}, + {"right aligned heading", `

T

`, `[{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}]`}, + {"justified paragraph", `

a

`, `[{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}]`}, + {"left alignment omitted", `

a

`, `[` + paragraphA + `]`}, + {"alignment skips the image inside a paragraph", `

xIy

`, `[{"type":"paragraph","align":"center","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","align":"center","children":[{"type":"text","text":"y","marks":[]}]}]`}, + } + for _, example := range examples { + t.Run(example.name, func(t *testing.T) { + got, err := converter.Render(example.html, domainArticle.BodyFormatJSON) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != example.json { + t.Errorf("expected %s, got %s", example.json, got) + } + }) + } +} diff --git a/test/markdown_converter_test.go b/test/markdown_converter_test.go new file mode 100644 index 0000000..63ca097 --- /dev/null +++ b/test/markdown_converter_test.go @@ -0,0 +1,89 @@ +package test + +import ( + domainArticle "RenewCMS/internal/domain/article" + "RenewCMS/internal/infrastructure/markdown" + "errors" + "testing" +) + +func TestMarkdownConverterFidelity(t *testing.T) { + converter := markdown.NewConverter() + examples := []struct { + name, html, markdown string + }{ + {"headings", "

T

S

", "# T\n\n### S"}, + {"bold and italic", "

b i

", "**b** *i*"}, + {"link", `

link

`, "[link](https://example.com/x)"}, + {"bullet list", "
  • a

  • b

", "- a\n- b"}, + {"ordered list", "
  1. a

  2. b

", "1. a\n2. b"}, + {"data-indent dropped", `
  • a

`, "- a"}, + {"nested list", "
  • a

    • n

", "- a\n - n"}, + {"blockquote", "

q

", "> q"}, + {"code block", `
x := 1
`, "```go\nx := 1\n```"}, + {"rule", "
", "---"}, + {"image", `A`, "![A](/static/uploadedImages/a.png)"}, + {"script and style removed", "

ok

", "ok"}, + {"unsupported span stripped", `

xy

`, "xy"}, + {"inline code", "

use go test

", "use `go test`"}, + {"empty body", "", ""}, + {"line break", "

a
b

", "a\\\nb"}, + {"literal angle bracket is escaped", "

<script>

", "\\", "```\na\nb\n```"}, + {"adjacent same marks merge", "

ab

", "**ab**"}, + {"adjacent different marks stay apart", "

ab

", "**a***b*"}, + {"two paragraphs", "

a

b

", "a\n\nb"}, + {"centered paragraph", `

hi b

`, "
\n\nhi **b**\n\n
"}, + {"right aligned heading", `

T

`, "
\n\n## T\n\n
"}, + {"justified among other styles", `

a

`, "
\n\na\n\n
"}, + {"tiptap style with trailing semicolon", `

a

`, "
\n\na\n\n
"}, + {"tiptap explicit left", `

a

`, "a"}, + {"left alignment is the default", `

a

`, "a"}, + {"unknown alignment ignored", `

a

`, "a"}, + {"style without value ignored", `

a

`, "a"}, + {"empty aligned paragraph", `

`, ""}, + {"aligned paragraph in list item", `
  • a

`, "-
\n\n a\n\n
"}, + {"aligned paragraph in blockquote", `

q

`, ">
\n>\n> q\n>\n>
"}, + {"alignment skips the image inside a paragraph", `

xIy

`, "
\n\nx\n\n
\n\n![I](/i.png)\n\n
\n\ny\n\n
"}, + {"text characters are escaped", "

a * b _c_ `d` [e]

", "a \\* b \\_c\\_ \\`d\\` \\[e\\]"}, + {"leading hash is escaped", "

# not a heading

", "\\# not a heading"}, + {"hash without space is kept", "

#tag

", "#tag"}, + {"leading dash is escaped", "

- not a list

", "\\- not a list"}, + {"leading plus is escaped", "

+ not a list

", "\\+ not a list"}, + {"leading quote marker is escaped", "

> not a quote

", "\\> not a quote"}, + {"leading number is escaped", "

1. not a list

", "1\\. not a list"}, + {"leading number with parenthesis is escaped", "

2) not a list

", "2\\) not a list"}, + {"leading rule is escaped", "

---

", "\\---"}, + {"leading equals is escaped", "

==

", "\\=="}, + {"inner dash is kept", "

a - b

", "a - b"}, + {"marker after a line break is escaped", "

a
- b

", "a\\\n\\- b"}, + {"combined document", `

Title

Intro bold and it

  • one

  • two

A
x := 1

quote


`, "# Title\n\n
\n\nIntro **bold** and *it*\n\n
\n\n- one\n- two\n\n![A](/static/uploadedImages/a.png)\n\n```go\nx := 1\n```\n\n> quote\n\n---"}, + } + for _, example := range examples { + t.Run(example.name, func(t *testing.T) { + got, err := converter.Render(example.html, domainArticle.BodyFormatMarkdown) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != example.markdown { + t.Errorf("expected %q, got %q", example.markdown, got) + } + }) + } +} + +func TestMarkdownConverterHtmlAndUnknownFormat(t *testing.T) { + converter := markdown.NewConverter() + html := "

a

" + got, err := converter.Render(html, domainArticle.BodyFormatHTML) + if err != nil || got != html { + t.Errorf("html must pass through unchanged, got %q, %v", got, err) + } + if _, err := converter.Render(html, domainArticle.BodyFormat("pdf")); !errors.Is(err, domainArticle.ErrUnsupportedBodyFormat) { + t.Errorf("expected ErrUnsupportedBodyFormat, got %v", err) + } +} diff --git a/test/media_env_test.go b/test/media_env_test.go index df34bf3..77e6cfa 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -52,10 +52,10 @@ func newMediaEnv(t *testing.T) *mediaEnv { container := &api.UseCases{ Token: env.token, - CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles), - GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles), - ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles), - UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}), + GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}), + ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles), GetUserUseCase: useCases.NewGetUserUseCase(env.users), CreateImageUseCase: useCases.NewCreateImageUseCase(env.images), diff --git a/test/not_found_errors_test.go b/test/not_found_errors_test.go index 30800aa..b8dd25c 100644 --- a/test/not_found_errors_test.go +++ b/test/not_found_errors_test.go @@ -126,11 +126,11 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) hasher := hashing.NewBcryptHasher() diff --git a/test/public_article_listing_test.go b/test/public_article_listing_test.go index 91a408f..071b825 100644 --- a/test/public_article_listing_test.go +++ b/test/public_article_listing_test.go @@ -42,11 +42,11 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth. getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/public_article_read_test.go b/test/public_article_read_test.go index f461910..36996f7 100644 --- a/test/public_article_read_test.go +++ b/test/public_article_read_test.go @@ -24,11 +24,11 @@ import ( func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index d7a31df..f8d4677 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -132,10 +132,10 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { return &api.UseCases{ Token: token, - CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo), - GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo), - ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo), - UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo), + CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), + UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo), GetUserUseCase: useCases.NewGetUserUseCase(userRepo), CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com"), diff --git a/test/stale_token_invalidation_http_test.go b/test/stale_token_invalidation_http_test.go index 6559fbd..e9f1379 100644 --- a/test/stale_token_invalidation_http_test.go +++ b/test/stale_token_invalidation_http_test.go @@ -23,11 +23,11 @@ import ( func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) loginHandler := auth.NewLoginHandler( diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 7286799..09ff2da 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -29,7 +29,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) { for _, tc := range cases { t.Run(fmt.Sprintf("length_%d", tc.length), func(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewCreateArticleUseCase(repo) + uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}) title := strings.Repeat("a", tc.length) result, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: title, Body: "Some body text"}) @@ -62,7 +62,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) { func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) { repo := newMockArticleRepo() repo.fail.failNext("Create") - uc := useCases.NewCreateArticleUseCase(repo) + uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}) _, err := uc.CreateArticle(useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"}) @@ -84,8 +84,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_1", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - result, err := uc.GetArticle(1) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.GetArticle(1, article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -96,8 +96,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_2", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - result, err := uc.GetArticle(2) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.GetArticle(2, article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -108,8 +108,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetArticle_9", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - _, err := uc.GetArticle(9) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + _, err := uc.GetArticle(9, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -117,8 +117,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_1", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - result, err := uc.GetOnlineArticle(1) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -129,8 +129,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_2", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - _, err := uc.GetOnlineArticle(2) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + _, err := uc.GetOnlineArticle(2, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -138,8 +138,8 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) { t.Run("GetOnlineArticle_9", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) - _, err := uc.GetOnlineArticle(9) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) + _, err := uc.GetOnlineArticle(9, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -155,7 +155,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) { t.Run("Backyard_Renovation_Update", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) result, err := uc.GetArticleByName("Backyard Renovation Update") if err != nil { t.Fatalf("expected no error, got %v", err) @@ -167,7 +167,7 @@ func TestGetArticleByNameDelegatesVerbatim(t *testing.T) { t.Run("Unknown_Title", func(t *testing.T) { repo := newRepo() - uc := useCases.NewGetArticleUseCase(repo) + uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) _, err := uc.GetArticleByName("Unknown Title") if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) @@ -198,8 +198,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) { t.Run("ListOnlineArticles", func(t *testing.T) { repo := newRepo() - uc := useCases.NewListArticlesUseCase(repo) - result, err := uc.ListOnlineArticles() + uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.ListOnlineArticles(article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -208,8 +208,8 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) { t.Run("ListAllArticles", func(t *testing.T) { repo := newRepo() - uc := useCases.NewListArticlesUseCase(repo) - result, err := uc.ListAllArticles() + uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.ListAllArticles(article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -219,9 +219,9 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) { func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) { repo := newMockArticleRepo() - uc := useCases.NewListArticlesUseCase(repo) + uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) - result, err := uc.ListOnlineArticles() + result, err := uc.ListOnlineArticles(article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -242,8 +242,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateBody_id1", func(t *testing.T) { repo := newRepo() - uc := useCases.NewUpdateArticleUseCase(repo) - result, err := uc.UpdateBody(1, "New body") + uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.UpdateBody(1, "New body", article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -254,8 +254,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateBody_id9", func(t *testing.T) { repo := newRepo() - uc := useCases.NewUpdateArticleUseCase(repo) - _, err := uc.UpdateBody(9, "New body") + uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}) + _, err := uc.UpdateBody(9, "New body", article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } @@ -263,8 +263,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateIsOnline_id1", func(t *testing.T) { repo := newRepo() - uc := useCases.NewUpdateArticleUseCase(repo) - result, err := uc.UpdateIsOnline(1, true) + uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}) + result, err := uc.UpdateIsOnline(1, true, article.BodyFormatHTML) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -275,8 +275,8 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) { t.Run("UpdateIsOnline_id9", func(t *testing.T) { repo := newRepo() - uc := useCases.NewUpdateArticleUseCase(repo) - _, err := uc.UpdateIsOnline(9, true) + uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}) + _, err := uc.UpdateIsOnline(9, true, article.BodyFormatHTML) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected article.ErrArticleNotFound, got %v", err) } diff --git a/test/verified_account_required_http_test.go b/test/verified_account_required_http_test.go index fa7a437..c24acf6 100644 --- a/test/verified_account_required_http_test.go +++ b/test/verified_account_required_http_test.go @@ -21,11 +21,11 @@ import ( func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index 6364d57..b32198b 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -213,11 +213,11 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth getUserUseCase := useCases.NewGetUserUseCase(userRepo) articleHandler := article.NewHandler( - useCases.NewCreateArticleUseCase(articleRepo), - useCases.NewGetArticleUseCase(articleRepo), - useCases.NewListArticlesUseCase(articleRepo), + useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}), + useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}), useCases.NewDeleteArticleUseCase(articleRepo), - useCases.NewUpdateArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}), getUserUseCase, ) diff --git a/web/bun.lock b/web/bun.lock index ac5601d..ff1b2c8 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -10,6 +10,7 @@ "@tiptap/core": "3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-image": "3.31.3", + "@tiptap/extension-text-align": "3.31.3", "@tiptap/pm": "3.31.3", "@tiptap/vue-3": "3.31.3", "@vueuse/core": "14.4.0", @@ -425,6 +426,8 @@ "@tiptap/extension-text": ["@tiptap/extension-text@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg=="], + "@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA=="], + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3" } }, "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw=="], "@tiptap/extensions": ["@tiptap/extensions@3.31.3", "", { "peerDependencies": { "@tiptap/core": "3.31.3", "@tiptap/pm": "3.31.3" } }, "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA=="], diff --git a/web/package.json b/web/package.json index 29cbc8f..8d54e3d 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@tiptap/core": "3.31.3", "@tiptap/extension-drag-handle-vue-3": "^3.31.3", "@tiptap/extension-image": "3.31.3", + "@tiptap/extension-text-align": "3.31.3", "@tiptap/pm": "3.31.3", "@tiptap/vue-3": "3.31.3", "@vueuse/core": "14.4.0", diff --git a/web/src/components/ArticleEditor.vue b/web/src/components/ArticleEditor.vue index 4536a67..7771a28 100644 --- a/web/src/components/ArticleEditor.vue +++ b/web/src/components/ArticleEditor.vue @@ -5,6 +5,7 @@ import { ref } from 'vue' import type { EditorToolbarItem } from '@nuxt/ui' import type { Editor } from '@tiptap/core' import { ArticleIndent } from '@/lib/articleIndent' +import { ArticleTextAlign } from '@/lib/articleAlign' import { ArticleImage, findArticleImagePos } from '@/lib/articleImage' const props = withDefaults( @@ -27,6 +28,7 @@ const emit = defineEmits<{ const editorInstance = ref(null) const extensions = [ ArticleIndent, + ArticleTextAlign, ArticleImage.configure({ onRequestRemove: (imageId: number) => emit('request-remove-image', imageId), }), diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue index 74fdfec..b319bc3 100644 --- a/web/src/components/NewArticleModal.vue +++ b/web/src/components/NewArticleModal.vue @@ -120,7 +120,7 @@ async function onSubmit() { formAlert.value = '' submitting.value = true try { - const article = await api.request
('/v1/article/', { + const article = await api.request
('/v1/article/?format=html', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title.value, body: body.value }), diff --git a/web/src/components/__tests__/ArticleEditor.spec.ts b/web/src/components/__tests__/ArticleEditor.spec.ts index 83635c2..60ef929 100644 --- a/web/src/components/__tests__/ArticleEditor.spec.ts +++ b/web/src/components/__tests__/ArticleEditor.spec.ts @@ -56,6 +56,35 @@ afterEach(async () => { await router.push('/login') }) +describe('the alignment toolbar buttons align the current block', () => { + const ALIGNMENTS = ['left', 'center', 'right', 'justify'] + const FIRST_ALIGN_BUTTON = 7 + + function alignButton(name: string) { + const buttons = document.body.querySelectorAll('.article-editor button') + return buttons[FIRST_ALIGN_BUTTON + ALIGNMENTS.indexOf(name)] ?? null + } + + it.each(ALIGNMENTS)('keeps the %s button enabled', async (name) => { + await mountArticleEditor() + + const button = alignButton(name) + + expect(button).not.toBeNull() + expect(button?.disabled).toBe(false) + }) + + it('writes the alignment into the body when a button is clicked', async () => { + const wrapper = await mountArticleEditor() + const editor = wrapper.findComponent(ArticleEditor) + + alignButton('center')?.click() + await flushPromises() + + expect(editor.props('modelValue')).toContain('text-align: center') + }) +}) + describe('insertImage stamps the inserted image with its numeric id', () => { it('renders data-image-id on the img and emits the updated body', async () => { const wrapper = await mountArticleEditor() diff --git a/web/src/components/__tests__/NewArticleModal.spec.ts b/web/src/components/__tests__/NewArticleModal.spec.ts index ebe5749..0016061 100644 --- a/web/src/components/__tests__/NewArticleModal.spec.ts +++ b/web/src/components/__tests__/NewArticleModal.spec.ts @@ -206,7 +206,7 @@ describe('Submitting disables the form and cannot be dismissed', () => { await clickButton('Create draft') expect(requestMock).toHaveBeenCalledWith( - '/v1/article/', + '/v1/article/?format=html', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: TITLE, body: BODY }), @@ -336,7 +336,7 @@ describe('Retrying after an Error succeeds', () => { expect(requestMock).toHaveBeenCalledTimes(2) expect(requestMock).toHaveBeenNthCalledWith( 2, - '/v1/article/', + '/v1/article/?format=html', expect.objectContaining({ body: JSON.stringify({ title: TITLE, body: BODY }), }), diff --git a/web/src/composables/__tests__/useArticles.spec.ts b/web/src/composables/__tests__/useArticles.spec.ts new file mode 100644 index 0000000..023d846 --- /dev/null +++ b/web/src/composables/__tests__/useArticles.spec.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const request = vi.fn() + +vi.mock('@/composables/useApi', () => ({ + useApi: () => ({ request }), +})) + +import { useArticles } from '@/composables/useArticles' + +describe('useArticles', () => { + beforeEach(() => { + request.mockReset() + }) + + it('asks the API for html bodies when listing articles', async () => { + request.mockResolvedValue([]) + + await useArticles().listArticles() + + expect(request).toHaveBeenCalledWith('/v1/article/?format=html') + }) +}) diff --git a/web/src/composables/useArticleDraft.ts b/web/src/composables/useArticleDraft.ts index 8d7cdaa..63f03bb 100644 --- a/web/src/composables/useArticleDraft.ts +++ b/web/src/composables/useArticleDraft.ts @@ -79,7 +79,7 @@ export function useArticleDraft(articleId: number) { async function save(): Promise { businessState.value = 'saving' try { - await request
(`/v1/article/${articleId}`, { + await request
(`/v1/article/${articleId}?format=html`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: body.value }), diff --git a/web/src/composables/useArticles.ts b/web/src/composables/useArticles.ts index 906ba3a..cff0cc9 100644 --- a/web/src/composables/useArticles.ts +++ b/web/src/composables/useArticles.ts @@ -13,7 +13,7 @@ export function useArticles() { const { request } = useApi() function listArticles(): Promise { - return request('/v1/article/') + return request('/v1/article/?format=html') } return { listArticles } diff --git a/web/src/i18n/spa.spec.ts b/web/src/i18n/spa.spec.ts index d255f98..0c9b1b5 100644 --- a/web/src/i18n/spa.spec.ts +++ b/web/src/i18n/spa.spec.ts @@ -393,7 +393,7 @@ describe('Hardener localization outcomes', () => { ]).toEqual([window.location.origin, true, true, true]) await vi.waitFor(() => expect(document.body.textContent).toContain( - `const res = await fetch('${window.location.origin}/v1/article/')`, + `const res = await fetch('${window.location.origin}/v1/article/?format=html')`, ), ) vi.unstubAllGlobals() @@ -411,7 +411,7 @@ describe('Hardener localization outcomes', () => { updated_at: '2025-02-14T12:00:00Z', } request.mockImplementation(async (url: string) => - url === '/v1/article/' ? [article] : article, + url === '/v1/article/?format=html' ? [article] : article, ) await mountRoute(path) if (path === '/article') { diff --git a/web/src/lib/__tests__/articleAlign.spec.ts b/web/src/lib/__tests__/articleAlign.spec.ts new file mode 100644 index 0000000..14705fc --- /dev/null +++ b/web/src/lib/__tests__/articleAlign.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import { ArticleIndent } from '../articleIndent' +import { ArticleTextAlign } from '../articleAlign' + +function createEditor(content: string) { + return new Editor({ + extensions: [StarterKit, ArticleIndent, ArticleTextAlign], + content, + }) +} + +describe('Aligning text writes text-align on paragraphs and headings', () => { + it.each(['center', 'right', 'justify'])('can align a paragraph to the %s', (align) => { + const editor = createEditor('

Hello

') + editor.commands.setTextSelection(2) + + expect(editor.can().setTextAlign(align)).toBe(true) + editor.commands.setTextAlign(align) + + expect(editor.getHTML()).toContain(`

Hello

`) + expect(editor.isActive({ textAlign: align })).toBe(true) + editor.destroy() + }) + + it('leaves unaligned paragraphs and headings without a style', () => { + const editor = createEditor('

Title

Hello

') + + expect(editor.getHTML()).toBe('

Title

Hello

') + editor.destroy() + }) + + it('aligns a heading', () => { + const editor = createEditor('

Title

') + editor.commands.setTextSelection(2) + + editor.commands.setTextAlign('right') + + expect(editor.getHTML()).toContain('

Title

') + editor.destroy() + }) + + it('returns to left alignment', () => { + const editor = createEditor('

Hello

') + editor.commands.setTextSelection(2) + + editor.commands.setTextAlign('left') + + expect(editor.isActive({ textAlign: 'center' })).toBe(false) + expect(editor.getHTML()).not.toContain('center') + editor.destroy() + }) + + it('loads stored alignment back from HTML', () => { + const editor = createEditor('

Hello

') + editor.commands.setTextSelection(2) + + expect(editor.isActive({ textAlign: 'justify' })).toBe(true) + editor.destroy() + }) + + it('keeps alignment and indentation together', () => { + const editor = createEditor('

Hello

') + editor.commands.setTextSelection(2) + + editor.commands.indent() + editor.commands.setTextAlign('center') + + expect(editor.getHTML()).toContain('data-indent="1"') + expect(editor.getHTML()).toContain('text-align: center') + editor.destroy() + }) +}) diff --git a/web/src/lib/articleAlign.ts b/web/src/lib/articleAlign.ts new file mode 100644 index 0000000..a380658 --- /dev/null +++ b/web/src/lib/articleAlign.ts @@ -0,0 +1,8 @@ +import TextAlign from '@tiptap/extension-text-align' + +export const ALIGN_TYPES = ['heading', 'paragraph'] + +export const ArticleTextAlign = TextAlign.configure({ + types: ALIGN_TYPES, + alignments: ['left', 'center', 'right', 'justify'], +}) diff --git a/web/src/views/ArticleView.vue b/web/src/views/ArticleView.vue index 15702a0..e274cf3 100644 --- a/web/src/views/ArticleView.vue +++ b/web/src/views/ArticleView.vue @@ -188,7 +188,7 @@ async function setOnline(article: Article, makeOnline: boolean) { setBusy(article.id, true) try { const updated = await request
( - `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`, + `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`, { method: 'PATCH' }, ) articles.value = articles.value.map((existing) => diff --git a/web/src/views/EditorView.vue b/web/src/views/EditorView.vue index 422a95a..7de5160 100644 --- a/web/src/views/EditorView.vue +++ b/web/src/views/EditorView.vue @@ -65,7 +65,7 @@ async function loadArticle() { }, SKELETON_DELAY_MS) try { - const result = await request
(`/v1/article/${articleId}`) + const result = await request
(`/v1/article/${articleId}?format=html`) article.value = result draft.load(result.body, new Date(result.updated_at)) loadState.value = 'loaded' @@ -128,7 +128,7 @@ async function setOnline(makeOnline: boolean) { publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…') try { const updated = await request
( - `/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}`, + `/v1/article/${articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`, { method: 'PATCH' }, ) article.value = updated diff --git a/web/src/views/IntegrationView.vue b/web/src/views/IntegrationView.vue index d2faa30..54b8089 100644 --- a/web/src/views/IntegrationView.vue +++ b/web/src/views/IntegrationView.vue @@ -32,6 +32,7 @@ const sampleArticle = { updated_at: '2026-03-12T09:14:00Z', }, ], + body_format: 'html', is_online: true, created_at: '2026-03-12T09:14:00Z', updated_at: '2026-09-10T07:00:00Z', @@ -47,8 +48,8 @@ const endpoints = computed(() => [ return t('Lists every online article.') }, examples: { - fetch: `const res = await fetch('${origin.value}/v1/article/')\nconst articles = await res.json()`, - curl: `curl ${origin.value}/v1/article/`, + fetch: `const res = await fetch('${origin.value}/v1/article/?format=html')\nconst articles = await res.json()`, + curl: `curl ${origin.value}/v1/article/?format=html`, response: JSON.stringify(sampleArticleList, null, 2), }, }, @@ -61,8 +62,8 @@ const endpoints = computed(() => [ ) }, examples: { - fetch: `const res = await fetch('${origin.value}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json()`, - curl: `curl ${origin.value}/v1/article/12`, + fetch: `const res = await fetch('${origin.value}/v1/article/12?format=html')\nif (res.status === 404) return notFound()\nconst article = await res.json()`, + curl: `curl ${origin.value}/v1/article/12?format=html`, response: JSON.stringify(sampleArticle, null, 2), }, }, diff --git a/web/src/views/__tests__/ArticleView.spec.ts b/web/src/views/__tests__/ArticleView.spec.ts index ee31090..393f92a 100644 --- a/web/src/views/__tests__/ArticleView.spec.ts +++ b/web/src/views/__tests__/ArticleView.spec.ts @@ -192,7 +192,7 @@ afterEach(async () => { describe('Default — the list loads sorted by last updated, newest first', () => { it('shows the breadcrumb, tab counts and every row in fixture order', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) expect(wrapper.text()).toContain('All 6') @@ -218,7 +218,7 @@ describe('Loading — skeleton rows while the first request is in flight', () => it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => { let resolveList: (() => void) | null = null const wrapper = await mountArticles({ - 'GET /v1/article/': () => + 'GET /v1/article/?format=html': () => new Promise((resolve) => { resolveList = () => resolve(mockResponse(200, sixArticleFixture())) }), @@ -239,7 +239,7 @@ describe('Loading — skeleton rows while the first request is in flight', () => describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => { it('opens a menu named "Actions for Winter checklist for timber windows"', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -260,7 +260,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', ( }) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -287,7 +287,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () => }) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -305,7 +305,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () => describe('Empty — zero articles', () => { it('shows the no-articles empty state and opens NewArticleModal', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, []), + 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(wrapper.text()).toContain('All 0') @@ -326,7 +326,7 @@ describe('Empty — zero articles', () => { describe('Initial load failure — an error must not render the no-articles empty state', () => { it('does not show "No articles yet" when GET /v1/article/ fails', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(500), + 'GET /v1/article/?format=html': () => mockResponse(500), }) expect(wrapper.text()).not.toContain('No articles yet') @@ -336,7 +336,7 @@ describe('Initial load failure — an error must not render the no-articles empt describe('No results — a search matches nothing, but the tab counts still show the full list', () => { it('shows the no-results empty state, then clears the search', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) const searchInput = wrapper.find('input[type="search"]') @@ -367,7 +367,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count .slice(0, 4) .map((a) => ({ ...a, is_online: true })) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, onlineOnly), + 'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly), }) await clickTab('Drafts 0') @@ -384,7 +384,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count .slice(0, 4) .map((a) => ({ ...a, is_online: false })) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, draftsOnly), + 'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly), }) await clickTab('Online 0') @@ -400,8 +400,8 @@ describe('An empty tab shows its own EmptyState, while the other tab still count describe('Action in progress — publishing a row disables only that row', () => { it('sends the PATCH and shows the busy label for that row alone', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/4/publish': () => new Promise(() => {}), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -420,8 +420,8 @@ describe('Published — a 200 updates the row in place and raises a toast', () = updated_at: iso(0), } const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/4/publish': () => mockResponse(200, updated), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -448,8 +448,8 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc ])('shows Retry for $name and re-sends the same request', async ({ handler }) => { const patchCall = vi.fn(handler) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/4/publish': patchCall, + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/4/publish?format=html': patchCall, }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -487,8 +487,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u ])('shows Retry for $name and re-sends the same request', async ({ handler }) => { const patchCall = vi.fn(handler) const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/3/unpublish': patchCall, + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/3/unpublish?format=html': patchCall, }) await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen') @@ -517,8 +517,8 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => { it('removes the row on a 404 publish response', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/2/publish': () => mockResponse(404), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/2/publish?format=html': () => mockResponse(404), }) await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls') @@ -540,7 +540,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row it('removes the row when the delete confirmation resolves 404', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), 'DELETE /v1/article/2': () => mockResponse(404), }) @@ -565,8 +565,8 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => { it('removes the row and updates the tab counts', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), - 'PATCH /v1/article/3/unpublish': () => mockResponse(404), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), + 'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404), }) await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen') @@ -590,7 +590,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh describe('Delete confirm — opens the dialog with the row title and image count', () => { it('opens ConfirmDialog with the title and 3 images', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), }) await openRowMenu(wrapper, 'Winter checklist for timber windows') @@ -611,7 +611,7 @@ describe('Delete confirm — opens the dialog with the row title and image count describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => { it('removes the row, recomputes tabs and raises the success toast', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), 'DELETE /v1/article/4': () => mockResponse(204), }) @@ -640,7 +640,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => { it('throws the inline error message and leaves the article in the list', async () => { const wrapper = await mountArticles({ - 'GET /v1/article/': () => mockResponse(200, sixArticleFixture()), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), 'DELETE /v1/article/4': () => mockResponse(500), }) diff --git a/web/src/views/__tests__/EditorImageUpload.spec.ts b/web/src/views/__tests__/EditorImageUpload.spec.ts index 266f185..e9933a3 100644 --- a/web/src/views/__tests__/EditorImageUpload.spec.ts +++ b/web/src/views/__tests__/EditorImageUpload.spec.ts @@ -191,7 +191,7 @@ const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: function articleHandlers(extra: Record Response | Promise> = {}) { return { - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), 'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY), ...extra, } diff --git a/web/src/views/__tests__/EditorMediaPicker.spec.ts b/web/src/views/__tests__/EditorMediaPicker.spec.ts index c076a32..2d0b3d4 100644 --- a/web/src/views/__tests__/EditorMediaPicker.spec.ts +++ b/web/src/views/__tests__/EditorMediaPicker.spec.ts @@ -57,7 +57,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) { session.session = { verified: true, username: 'marta' } session.status = 'ready' global.fetch = vi.fn(async (url: string) => { - if (url === '/v1/article/12') { + if (url === '/v1/article/12?format=html') { return mockResponse(200, articleFixture(body)) } if (url === '/v1/media/?limit=24') { diff --git a/web/src/views/__tests__/EditorView.spec.ts b/web/src/views/__tests__/EditorView.spec.ts index bdef44f..480506c 100644 --- a/web/src/views/__tests__/EditorView.spec.ts +++ b/web/src/views/__tests__/EditorView.spec.ts @@ -147,7 +147,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () => it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => { let resolveGet: (() => void) | null = null const wrapper = await mountEditor({ - 'GET /v1/article/12': () => + 'GET /v1/article/12?format=html': () => new Promise((resolve) => { resolveGet = () => resolve(mockResponse(200, articleFixture())) }), @@ -171,7 +171,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () => it('never shows a skeleton when the response resolves in under 150 ms', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => Promise.resolve(mockResponse(200, articleFixture())), + 'GET /v1/article/12?format=html': () => Promise.resolve(mockResponse(200, articleFixture())), }) await vi.advanceTimersByTimeAsync(0) @@ -190,7 +190,7 @@ describe('Saved — a freshly loaded article, draft or online', () => { 'reads the Saved state for is_online $is_online', async ({ is_online, badge, publishLabel }) => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture({ is_online })), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ is_online })), }) expect(wrapper.text()).toContain('Articles') @@ -207,7 +207,7 @@ describe('Saved — a freshly loaded article, draft or online', () => { describe('Unsaved changes — any edit marks the article dirty', () => { it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) @@ -222,12 +222,12 @@ describe('Saving — Save sends the PATCH and disables itself while in flight', it('sends the edited body and shows Saving…', async () => { let patchBody: string | null = null const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) global.fetch = vi.fn((url: string, init?: RequestInit) => { - if (routeFor(url, init) === 'PATCH /v1/article/12') { + if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') { patchBody = init?.body as string return new Promise(() => {}) } @@ -248,13 +248,13 @@ describe('Saving — Save sends the PATCH and disables itself while in flight', describe('Ctrl/⌘ S saves the same way as clicking Save', () => { it('sends the same PATCH on Ctrl+S', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) let patchBody: string | null = null global.fetch = vi.fn((url: string, init?: RequestInit) => { - if (routeFor(url, init) === 'PATCH /v1/article/12') { + if (routeFor(url, init) === 'PATCH /v1/article/12?format=html') { patchBody = init?.body as string return new Promise(() => {}) } @@ -276,8 +276,8 @@ describe('A successful save returns to the Saved state with the new time', () => vi.setSystemTime(new Date('2026-09-26T11:30:00Z')) let resolvePatch: (() => void) | null = null const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - 'PATCH /v1/article/12': () => + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), + 'PATCH /v1/article/12?format=html': () => new Promise((resolve) => { resolvePatch = () => resolve( @@ -305,7 +305,7 @@ describe('A successful save returns to the Saved state with the new time', () => describe('SaveStatus switches to a relative time after an hour', () => { it('reads "Saved n h ago" once more than an hour has passed', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) expect(wrapper.text()).toContain('Saved at 11:02') @@ -327,8 +327,8 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer }, ])('shows Save failed with Retry for $name', async ({ handler }) => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - 'PATCH /v1/article/12': handler, + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), + 'PATCH /v1/article/12?format=html': handler, }) await typeBody(wrapper, paragraph('Revised opening paragraph.')) await saveButton(wrapper).trigger('click') @@ -354,8 +354,8 @@ describe('Retry resends the same body and can recover', () => { ), ) const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - 'PATCH /v1/article/12': patchCall, + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), + 'PATCH /v1/article/12?format=html': patchCall, }) await typeBody(wrapper, paragraph('Revised opening paragraph.')) await saveButton(wrapper).trigger('click') @@ -373,8 +373,8 @@ describe('Retry resends the same body and can recover', () => { describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => { it('shows the toast and never reads Save failed', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - 'PATCH /v1/article/12': () => mockResponse(404), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), + 'PATCH /v1/article/12?format=html': () => mockResponse(404), }) await typeBody(wrapper, paragraphOfLength(4320)) await saveButton(wrapper).trigger('click') @@ -392,7 +392,7 @@ describe('A save against an article deleted elsewhere shows a toast, not the err describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => { it('shows the warning icon and keeps Save enabled', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(9240)) @@ -405,7 +405,7 @@ describe('Near the limit — the counter warns at 9000 characters without blocki describe('Too long — over 10000 characters disables Save and Publish until shortened', () => { it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(10214)) @@ -429,7 +429,7 @@ describe('Too long — over 10000 characters disables Save and Publish until sho describe('Preview shows the rendered title and body without the editing surface', () => { it('replaces the editor with a read-only rendering, then restores it unchanged', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ body: '

Some body

' })), }) @@ -454,7 +454,7 @@ describe('Preview shows the rendered title and body without the editing surface' describe('Offline blocks Save and Publish, and recovers when the connection returns', () => { it('shows the offline SaveStatus and re-enables Save once back online', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) @@ -478,7 +478,7 @@ describe('Offline blocks Save and Publish, and recovers when the connection retu describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => { it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) @@ -507,7 +507,7 @@ describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => { it('keeps the user on the editor with the unsaved body intact', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) @@ -528,7 +528,7 @@ describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => { it('keeps the user on /article/12/edit with the unsaved body intact', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) await typeBody(wrapper, paragraphOfLength(4320)) @@ -549,7 +549,7 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () describe('Leaving a clean article never prompts', () => { it('navigates directly with no ConfirmDialog', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), }) const articlesLink = wrapper.findAll('a').find((a) => a.attributes('href') === '/article') @@ -564,8 +564,8 @@ describe('Leaving a clean article never prompts', () => { describe('Session expired mid-save keeps the draft and restores it after logging back in', () => { it('mirrors the body to sessionStorage and restores it on the next mount', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleFixture()), - 'PATCH /v1/article/12': () => mockResponse(401), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), + 'PATCH /v1/article/12?format=html': () => mockResponse(401), }) const body = paragraph('Paragraph added just before the session expired.') await typeBody(wrapper, body) @@ -581,7 +581,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging session.session = { verified: true, username: 'marta' } session.status = 'ready' global.fetch = fetchRouter({ - 'GET /v1/article/12': () => + 'GET /v1/article/12?format=html': () => mockResponse( 200, articleFixture({ @@ -624,7 +624,7 @@ function requestCalls(wrapper: Awaited>) { describe('Requesting removal of an inline image opens the neutral confirm dialog', () => { it('opens ConfirmDialog with the exact copy and sends nothing', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) @@ -637,13 +637,13 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog expect(props.confirmLabel).toBe('Remove image') expect(props.cancelLabel).toBe('Cancel') expect(props.tone).toBe('neutral') - expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) + expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html']) expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') }) it('previews the image the session has seen with its filename and size', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()), }) useSeenImagesStore().remember([{ id: 10, filename: 'before.png', size: 4089446 }]) @@ -659,7 +659,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog it('previews only the thumbnail for an image the session has not seen', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) @@ -676,7 +676,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog describe('Cancelling the confirm changes nothing', () => { it('keeps the image and the draft state when the dialog resolves to cancel', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) @@ -686,14 +686,14 @@ describe('Cancelling the confirm changes nothing', () => { expect(articleEditorBody(wrapper)).toContain('data-image-id="10"') expect(wrapper.text()).not.toContain('Unsaved changes') expect(toastAdd).not.toHaveBeenCalled() - expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) + expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html']) }) }) describe('Confirming removes the image from the body only, marks the draft dirty and toasts', () => { it('removes the image from the content, reads Unsaved changes and sends no request', async () => { const wrapper = await mountEditor({ - 'GET /v1/article/12': () => mockResponse(200, articleWithImageFixture()), + 'GET /v1/article/12?format=html': () => mockResponse(200, articleWithImageFixture()), }) await requestRemoveImage(wrapper) @@ -710,7 +710,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty title: 'Image removed', description: "It's still in your library.", }) - expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12']) + expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html']) }) }) @@ -718,7 +718,7 @@ describe('An unknown or deleted article id replaces the editor with an error sta it('shows the not-found error state and navigates to /article', async () => { const wrapper = await mountEditor( { - 'GET /v1/article/9999': () => mockResponse(404), + 'GET /v1/article/9999?format=html': () => mockResponse(404), }, '/article/9999/edit', ) diff --git a/web/src/views/__tests__/HomeUpdateBanner.spec.ts b/web/src/views/__tests__/HomeUpdateBanner.spec.ts index d227417..83a1734 100644 --- a/web/src/views/__tests__/HomeUpdateBanner.spec.ts +++ b/web/src/views/__tests__/HomeUpdateBanner.spec.ts @@ -35,7 +35,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions) session.session = { verified: true, username: 'marta', role } session.status = 'ready' global.fetch = vi.fn((url: string) => { - if (url === '/v1/article/') { + if (url === '/v1/article/?format=html') { return Promise.resolve(mockResponse(200, [])) } if (url === '/v1/system/update') { diff --git a/web/src/views/__tests__/HomeUpdateReady.spec.ts b/web/src/views/__tests__/HomeUpdateReady.spec.ts index 3e7b46b..6000964 100644 --- a/web/src/views/__tests__/HomeUpdateReady.spec.ts +++ b/web/src/views/__tests__/HomeUpdateReady.spec.ts @@ -35,7 +35,7 @@ async function mountHome(role: UserRole, status: UpdateStatus) { session.session = { verified: true, username: 'marta', role } session.status = 'ready' global.fetch = vi.fn((url: string) => { - if (url === '/v1/article/') { + if (url === '/v1/article/?format=html') { return Promise.resolve(mockResponse(200, [])) } if (url === '/v1/system/update') { diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts index 7e07c72..8d1de0c 100644 --- a/web/src/views/__tests__/HomeView.spec.ts +++ b/web/src/views/__tests__/HomeView.spec.ts @@ -136,7 +136,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () => vi.useFakeTimers() let resolveList: (() => void) | null = null const wrapper = await mountHome({ - 'GET /v1/article/': () => + 'GET /v1/article/?format=html': () => new Promise((resolve) => { resolveList = () => resolve(mockResponse(200, sixArticles)) }), @@ -160,7 +160,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () => it('never shows a skeleton when the response resolves in under 150 ms', async () => { vi.useFakeTimers() const wrapper = await mountHome({ - 'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)), + 'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)), }) await vi.advanceTimersByTimeAsync(0) @@ -174,7 +174,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () => describe('Default — the list loaded with a mix of online and draft articles', () => { it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { const wrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(wrapper.text()).toContain("Here's where things stand on your site.") @@ -217,8 +217,8 @@ describe('A successful create hands off to the editor with a toast', () => { updated_at: '2026-01-01T00:00:00Z', } const wrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), - 'POST /v1/article/': () => mockResponse(201, created), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), + 'POST /v1/article/?format=html': () => mockResponse(201, created), }) await findButton(wrapper, 'New article')?.trigger('click') @@ -258,7 +258,7 @@ describe('A successful create hands off to the editor with a toast', () => { describe('Empty, first run — zero articles', () => { it('shows the empty state and opens NewArticleModal', async () => { const wrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, []), + 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(wrapper.text()).toContain('Welcome to RenewCMS.') @@ -284,7 +284,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', () }, ])('shows the alert with identical copy for $name', async ({ handler }) => { const wrapper = await mountHome({ - 'GET /v1/article/': handler, + 'GET /v1/article/?format=html': handler, }) expect(wrapper.text()).toContain("Here's where things stand on your site.") @@ -304,7 +304,7 @@ describe('Retry re-issues the request and recovers into the Default state', () = .mockResolvedValueOnce(mockResponse(500)) .mockResolvedValueOnce(mockResponse(200, sixArticles)) const wrapper = await mountHome({ - 'GET /v1/article/': listCall, + 'GET /v1/article/?format=html': listCall, }) expect(wrapper.find('[role="alert"]').exists()).toBe(true) @@ -323,7 +323,7 @@ describe('The list is fetched exactly once per navigation', () => { it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { const listCall = vi.fn(() => mockResponse(200, sixArticles)) const wrapper = await mountHome({ - 'GET /v1/article/': listCall, + 'GET /v1/article/?format=html': listCall, }) expect(listCall).toHaveBeenCalledTimes(1) @@ -344,7 +344,7 @@ describe('The greeting matches the hour of day', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) const wrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(wrapper.text()).toContain(`${greeting}, marta`) @@ -357,23 +357,23 @@ describe('The greeting is identical across every load state at a fixed hour', () vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) const loadingWrapper = await mountHome({ - 'GET /v1/article/': () => new Promise(() => {}), + 'GET /v1/article/?format=html': () => new Promise(() => {}), }) await vi.advanceTimersByTimeAsync(150) expect(loadingWrapper.text()).toContain('Good morning, marta') const defaultWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(defaultWrapper.text()).toContain('Good morning, marta') const emptyWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, []), + 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(emptyWrapper.text()).toContain('Good morning, marta') const errorWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(500), + 'GET /v1/article/?format=html': () => mockResponse(500), }) expect(errorWrapper.text()).toContain('Good morning, marta') }) @@ -394,7 +394,7 @@ describe('The greeting names the person by display name, else username', () => { session.session = { verified: true, username: 'marta', displayName } session.status = 'ready' global.fetch = fetchRouter({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { @@ -414,7 +414,7 @@ describe('The greeting names the person by display name, else username', () => { session.session = { verified: true, username: 'marta', displayName: '' } session.status = 'ready' global.fetch = fetchRouter({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { @@ -440,7 +440,7 @@ describe('The greeting handles a missing username', () => { session.session = { verified: true } session.status = 'ready' global.fetch = fetchRouter({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) as unknown as typeof fetch await router.push('/home') const wrapper = mount(HomeView, { @@ -461,7 +461,7 @@ describe('The "Connect your website" card renders in every state', () => { it('renders the same card while loading, in Default, Empty and Error', async () => { vi.useFakeTimers() const wrapper = await mountHome({ - 'GET /v1/article/': () => new Promise(() => {}), + 'GET /v1/article/?format=html': () => new Promise(() => {}), }) await vi.advanceTimersByTimeAsync(150) expect(wrapper.text()).toContain('Connect your website') @@ -471,17 +471,17 @@ describe('The "Connect your website" card renders in every state', () => { vi.useRealTimers() const defaultWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, sixArticles), + 'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), }) expect(defaultWrapper.text()).toContain('Connect your website') const emptyWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(200, []), + 'GET /v1/article/?format=html': () => mockResponse(200, []), }) expect(emptyWrapper.text()).toContain('Connect your website') const errorWrapper = await mountHome({ - 'GET /v1/article/': () => mockResponse(500), + 'GET /v1/article/?format=html': () => mockResponse(500), }) expect(errorWrapper.text()).toContain('Connect your website') }) diff --git a/web/src/views/__tests__/IntegrationView.spec.ts b/web/src/views/__tests__/IntegrationView.spec.ts index 85fc905..09d886e 100644 --- a/web/src/views/__tests__/IntegrationView.spec.ts +++ b/web/src/views/__tests__/IntegrationView.spec.ts @@ -71,12 +71,12 @@ describe("Default — the page loads with the app's own origin", () => { expect(cardTexts).toHaveLength(2) expect(cardTexts[0]).toContain('/v1/article/') expect(cardTexts[0]).toContain('Lists every online article.') - expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/`) + expect(cardTexts[0]).toContain(`${STUB_ORIGIN}/v1/article/?format=html`) expect(cardTexts[1]).toContain('/v1/article/{id}') expect(cardTexts[1]).toContain( 'Returns one online article. Drafts and unknown ids both return 404.', ) - expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12`) + expect(cardTexts[1]).toContain(`${STUB_ORIGIN}/v1/article/12?format=html`) const fetchTabs = wrapper.findAll('[role="tab"]').filter((tab) => tab.text() === 'fetch') expect(fetchTabs.length).toBeGreaterThan(0) @@ -138,7 +138,7 @@ describe("Each EndpointCard's own Copy button is independent of the Base URL's", await flushPromises() const expectedCode = - `const res = await fetch('${STUB_ORIGIN}/v1/article/')\n` + + `const res = await fetch('${STUB_ORIGIN}/v1/article/?format=html')\n` + 'const articles = await res.json()' expect(writeText).toHaveBeenCalledWith(expectedCode) @@ -178,7 +178,7 @@ describe('curl and Response tabs render, unchecked visually', () => { const firstCard = wrapper.findAllComponents(EndpointCard)[0] await activateTab(firstCard.element as HTMLElement, 'curl') - expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/`) + expect(firstCard.text()).toContain(`curl ${STUB_ORIGIN}/v1/article/?format=html`) await activateTab(firstCard.element as HTMLElement, 'Response')