feat: articles format option with markdown new default
This commit is contained in:
1 parent
68401d66e0
commit
7e9bd9ba30
61 files changed
+2269
-243
No files matched your search
@@ -0,0 +1,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 "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>"
|
||||
|
||||
Scenario Outline: Single read negotiates the body format
|
||||
When an anonymous client sends GET /v1/article/1<query>
|
||||
Then the response status is 200
|
||||
And "body" is <body>
|
||||
And "body_format" is "<body_format>"
|
||||
|
||||
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 | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | html |
|
||||
| ?format=HTML | "<h2>Plan</h2><p>Replace the <strong>old</strong> deck.</p>" | 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 "<p>Second</p>"
|
||||
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 <path>
|
||||
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 <method> <path> with <request_body>
|
||||
Then the response status is <status>
|
||||
And "body_format" is "<body_format>"
|
||||
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":"<p>A</p>"} | 201 | markdown |
|
||||
| POST | /v1/article/?format=html | {"title":"Deck","body":"<p>A</p>"} | 201 | html |
|
||||
| POST | /v1/article/?format=json | {"title":"Deck","body":"<p>A</p>"} | 201 | json |
|
||||
| PATCH | /v1/article/1 | {"body":"<p>B</p>"} | 200 | markdown |
|
||||
| PATCH | /v1/article/1?format=json | {"body":"<p>B</p>"} | 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":"<p>B</p>"}
|
||||
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":"<p>A</p>"}
|
||||
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 <html>
|
||||
When converted with the format "markdown"
|
||||
Then the body is <markdown>
|
||||
|
||||
Examples:
|
||||
| html | markdown |
|
||||
| <h1>T</h1><h3>S</h3> | # T\n\n### S |
|
||||
| <p><strong>b</strong> <em>i</em></p> | **b** *i* |
|
||||
| <p><a href="https://example.com/x">link</a></p> | [link](https://example.com/x) |
|
||||
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | - a\n- b |
|
||||
| <ol><li><p>a</p></li><li><p>b</p></li></ol> | 1. a\n2. b |
|
||||
| <ul><li data-indent="2"><p>a</p></li></ul> | - a |
|
||||
| <blockquote><p>q</p></blockquote> | > q |
|
||||
| <pre><code class="language-go">x := 1</code></pre> | ```go\nx := 1\n``` |
|
||||
| <hr> | --- |
|
||||
| <img src="/static/uploadedImages/a.png" alt="A"> |  |
|
||||
| <p>ok</p><script>alert(1)</script><style>p{}</style> | ok |
|
||||
| <p>x<span style="color:red">y</span></p> | xy |
|
||||
| <p>use <code>go test</code></p> | use `go test` |
|
||||
|
||||
Scenario Outline: Json block shapes, one row per block and mark type
|
||||
Given the stored body is <html>
|
||||
When converted with the format "json"
|
||||
Then the body is <json>
|
||||
|
||||
Examples:
|
||||
| html | json |
|
||||
| <h1>T</h1> | [{"type":"heading","level":1,"children":[{"type":"text","text":"T","marks":[]}]}] |
|
||||
| <h6>S</h6> | [{"type":"heading","level":6,"children":[{"type":"text","text":"S","marks":[]}]}] |
|
||||
| <p>plain</p> | [{"type":"paragraph","children":[{"type":"text","text":"plain","marks":[]}]}] |
|
||||
| <p></p> | [{"type":"paragraph","children":[]}] |
|
||||
| <p><strong>b</strong></p> | [{"type":"paragraph","children":[{"type":"text","text":"b","marks":[{"type":"bold"}]}]}] |
|
||||
| <p><em>i</em></p> | [{"type":"paragraph","children":[{"type":"text","text":"i","marks":[{"type":"italic"}]}]}] |
|
||||
| <p><a href="https://example.com/x">l</a></p> | [{"type":"paragraph","children":[{"type":"text","text":"l","marks":[{"type":"link","href":"https://example.com/x"}]}]}] |
|
||||
| <p><a href="/a"><strong><em>x</em></strong></a></p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[{"type":"link","href":"/a"},{"type":"bold"},{"type":"italic"}]}]}] |
|
||||
| <p>a<br>b</p> | [{"type":"paragraph","children":[{"type":"text","text":"a\nb","marks":[]}]}] |
|
||||
| <ul><li><p>a</p></li><li><p>b</p></li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}],[{"type":"paragraph","children":[{"type":"text","text":"b","marks":[]}]}]]}] |
|
||||
| <ol><li><p>a</p></li></ol> | [{"type":"list","ordered":true,"items":[[{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}]]}] |
|
||||
| <ul><li>bare</li></ul> | [{"type":"list","ordered":false,"items":[[{"type":"paragraph","children":[{"type":"text","text":"bare","marks":[]}]}]]}] |
|
||||
| <ul><li><p>a</p><ul><li><p>n</p></li></ul></li></ul> | a list item whose blocks are the paragraph "a" followed by a nested list block with ordered false and the item "n" |
|
||||
| <ul><li data-indent="2"><p>a</p></li></ul> | the same output as without data-indent |
|
||||
| <blockquote><p>q</p></blockquote> | [{"type":"blockquote","children":[{"type":"paragraph","children":[{"type":"text","text":"q","marks":[]}]}]}] |
|
||||
| <pre><code class="language-go">x := 1</code></pre> | [{"type":"code","language":"go","text":"x := 1"}] |
|
||||
| <pre><code>raw</code></pre> | [{"type":"code","language":"","text":"raw"}] |
|
||||
| <hr> | [{"type":"hr"}] |
|
||||
| <img src="/static/uploadedImages/a.png" alt="A"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":"A"}] |
|
||||
| <img src="/static/uploadedImages/a.png"> | [{"type":"image","src":"/static/uploadedImages/a.png","alt":""}] |
|
||||
| <p>x<img src="/i.png" alt="I">y</p> | [{"type":"paragraph","children":[{"type":"text","text":"x","marks":[]}]},{"type":"image","src":"/i.png","alt":"I"},{"type":"paragraph","children":[{"type":"text","text":"y","marks":[]}]}] |
|
||||
| <p>x<span style="color:red">y</span></p> | [{"type":"paragraph","children":[{"type":"text","text":"xy","marks":[]}]}] |
|
||||
| <p>use <code>go test</code></p> | [{"type":"paragraph","children":[{"type":"text","text":"use go test","marks":[]}]}] |
|
||||
| <p>ok</p><script>alert(1)</script><style>p{}</style> | [{"type":"paragraph","children":[{"type":"text","text":"ok","marks":[]}]}] |
|
||||
|
||||
Scenario Outline: Text alignment survives the conversion
|
||||
Given the stored body is <html>
|
||||
When converted with the format "markdown"
|
||||
Then the body is <markdown>
|
||||
When converted with the format "json"
|
||||
Then the body is <json>
|
||||
|
||||
Examples:
|
||||
| html | markdown | json |
|
||||
| <p style="text-align: center">a</p> | <div style="text-align: center">
|
||||
|
||||
a
|
||||
|
||||
</div> | [{"type":"paragraph","align":"center","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||
| <h2 style="text-align:right">T</h2> | <div style="text-align: right">
|
||||
|
||||
## T
|
||||
|
||||
</div> | [{"type":"heading","level":2,"align":"right","children":[{"type":"text","text":"T","marks":[]}]}] |
|
||||
| <p style="text-align: JUSTIFY">a</p> | <div style="text-align: justify">
|
||||
|
||||
a
|
||||
|
||||
</div> | [{"type":"paragraph","align":"justify","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||
| <p style="text-align: left">a</p> | a | [{"type":"paragraph","children":[{"type":"text","text":"a","marks":[]}]}] |
|
||||
| <p style="text-align: start">a</p> | a | the same output as the left row |
|
||||
| <p style="text-align: center"></p> | an empty string | [{"type":"paragraph","align":"center","children":[]}] |
|
||||
|
||||
Scenario Outline: Plain text that looks like markdown stays plain text
|
||||
Given the stored body is <html>
|
||||
When converted with the format "markdown"
|
||||
Then the body is <markdown>
|
||||
|
||||
Examples:
|
||||
| html | markdown |
|
||||
| <p># not a heading</p> | \# not a heading |
|
||||
| <p>#tag</p> | #tag |
|
||||
| <p>- not a list</p> | \- not a list |
|
||||
| <p>+ not a list</p> | \+ not a list |
|
||||
| <p>> not a quote</p> | \> not a quote |
|
||||
| <p>1. not a list</p> | 1\. not a list |
|
||||
| <p>2) not a list</p> | 2\) not a list |
|
||||
| <p>---</p> | \--- |
|
||||
| <p>a - b</p> | a - b |
|
||||
| <p>a * b _c_</p> | a \* b \_c\_ |
|
||||
| <p>a<br>- b</p> | 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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user