230 lines
20 KiB
Gherkin
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"> |  |
|
|
| <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
|