Files
RenewCMS/features/article_body_format.feature

230 lines
20 KiB
Gherkin

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"> | ![A](/static/uploadedImages/a.png) |
| <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>&gt; 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