From 6902bd3f01148c403da60bccac8a3fa7417fef82 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:45:42 +0000 Subject: [PATCH] feat: make the example screen runnable and verify it in CI (#13) --- .github/workflows/custom-checks.yml | 11 +++- .gitignore | 4 ++ AGENTS.md | 17 +++++- CHANGELOG.md | 8 +++ CLAUDE.md | 45 +++++++------- README.md | 20 ++++++- my-plugin/README.md | 22 ++++++- my-plugin/plugin.json | 3 +- my-plugin/screen.html | 30 ++++++++++ requirements-dev.txt | 5 ++ tests/conftest.py | 59 ++++++++++++++++++ tests/test_manifest_screen.py | 46 ++++++++++++++ tests/test_settings_routes.py | 93 +++++++++++++++++++++++++++++ 13 files changed, 335 insertions(+), 28 deletions(-) create mode 100644 .gitignore create mode 100644 my-plugin/screen.html create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_manifest_screen.py create mode 100644 tests/test_settings_routes.py diff --git a/.github/workflows/custom-checks.yml b/.github/workflows/custom-checks.yml index 93b3d39..5d1a892 100644 --- a/.github/workflows/custom-checks.yml +++ b/.github/workflows/custom-checks.yml @@ -29,9 +29,14 @@ jobs: plugin_dir = Path('my-plugin') manifest = json.loads((plugin_dir / 'plugin.json').read_text(encoding='utf-8')) assert manifest['id'] == plugin_dir.name - for key in ('script', 'styles', 'routes'): + for key in ('script', 'screen', 'styles', 'routes'): assert (plugin_dir / manifest[key]).is_file(), f'missing {key}: {manifest[key]}' assert (plugin_dir / manifest['settings']['html']).is_file() + # The Host mounts every screen from `/api/plugins//screen.html`, + # ignoring the manifest path, and creates the container as + # `plugin-`; a mismatch leaves a dead sidebar entry. + assert manifest['screen'] == 'screen.html', manifest['screen'] + assert manifest['nav']['screen'] == f"plugin-{manifest['id']}", manifest['nav'] for section in ('settings', 'diagnostics'): for entry in (manifest.get(section, {}) or {}).get('server_files', []) or []: parts = entry.split('/') @@ -42,3 +47,7 @@ jobs: - name: Reject print calls in routes run: | ! grep -nE '(^|[^A-Za-z0-9_])(print|traceback\.print_exc)[[:space:]]*\(' my-plugin/routes.py + - name: Install test dependencies + run: pip install -r requirements-dev.txt + - name: Run plugin tests + run: pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa4de7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Python bytecode and pytest caches +__pycache__/ +*.py[cod] +.pytest_cache/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1dbf50a..deeabb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,12 +8,15 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac | File | Purpose | |---|---| -| `my-plugin/plugin.json` | Manifest — id `my-plugin`, nav entry, script/styles/settings/routes, type, icon, minHost | +| `my-plugin/plugin.json` | Manifest — id `my-plugin`, nav entry, script/screen/styles/settings/routes, type, icon, minHost | +| `my-plugin/screen.html` | Client screen markup — mounted into `plugin-my-plugin` by the Host, holds the counter and settings hooks `screen.js` queries | | `my-plugin/screen.js` | Client screen logic — finds its root by `plugin-`, wires up an example counter, loads persisted settings | | `my-plugin/settings.html` | Settings panel markup, grouped under `settings.category` | | `my-plugin/routes.py` | FastAPI `setup(app, context)` — registers GET/POST `/api/plugins/my-plugin/settings` with schema validation and persistence | | `my-plugin/assets/plugin.css` | Styling scoped to the plugin | | `my-plugin/README.md` | Per-plugin documentation for whoever copies this template | +| `tests/` | pytest suite for the manifest/screen contract and the settings routes | +| `requirements-dev.txt` | Test-only dependencies (`fastapi`, `httpx`, `pytest`) | ## Actual API shape @@ -28,12 +31,13 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac - **plugin.json id must match directory name** — `my-plugin` == `my-plugin/` - **Nav screen** uses `"nav": { "label": "My Plugin", "screen": "plugin-my-plugin" }` +- **Screen markup** is declared with a top-level `screen` key and lives in `screen.html`; the Host creates the `plugin-` container from that key and injects the markup, then runs `script`. The current Host hardcodes the fetch path (`/api/plugins//screen.html`) and ignores the manifest value, so the file must be named `screen.html`. - **screen.js finds its own root** via `document.getElementById('plugin-')` — the Host injects markup into that container - **Settings persist server-side**, not in browser storage — always go through the plugin's own routes - **routes.py exports `setup(app, context)`** — receives the FastAPI app and a context dict with `config_dir` and `log` - **Configuration validation happens once in `setup()`**, before any route is registered, so a broken config fails plugin load loudly rather than failing silently on first request - **API path** is `/api/plugins//` -- **No build step** — vanilla JS, no npm +- **No build step** — vanilla JS, no npm; the Python tests run under `pytest` (see `requirements-dev.txt`) ## Known issues / code notes @@ -43,6 +47,15 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac ## Verification checklist +Automated (the same checks `custom-checks.yml` runs): + +```bash +pip install -r requirements-dev.txt +pytest -q +``` + +Against a running Host with `my-plugin/` installed: + 1. Plugin loads without server errors (`setup()` validates config before registering routes) 2. Nav entry "My Plugin" appears in sidebar 3. Clicking the counter button increments the on-screen counter diff --git a/CHANGELOG.md b/CHANGELOG.md index f08fcf2..97eee03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `my-plugin/screen.html` and a top-level `screen` manifest key, so the example screen actually mounts — the Host only creates the `plugin-my-plugin` container when `screen` is declared. +- `tests/` and `requirements-dev.txt` — pytest coverage for the manifest/screen contract (declared files exist, the screen path the Host fetches, the nav container id) and the settings routes (GET defaults, valid-subset POST/persistence, invalid/unknown values, malformed JSON, and the 16 KiB body limit). +- `custom-checks.yml` now fails when the `screen` file or nav container reference is missing and runs `pytest`. - `nav` field in `my-plugin/plugin.json` (`{ "label": "My Plugin", "screen": "plugin-my-plugin" }`) so the plugin registers a sidebar entry. - `AGENTS.md` — reference guide for AI assistants/contributors covering the actual API shape, plugin conventions, known code notes, and a verification checklist. - `CLAUDE.md` — agent guide covering the template-vs-plugin distinction, feedBack app context, and two corrections to `AGENTS.md` (the `screen`-key container gate, and the spec-vs-Host folder/id mismatch behavior). +### Changed + +- `my-plugin/plugin.json` `type` is now `"utility"` instead of `"visualization"`, matching what the example actually is; the `visualization` type is reserved for plugins that implement the `setRenderer` note-highway contract. + + diff --git a/CLAUDE.md b/CLAUDE.md index c91a3f1..fb12887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,11 @@ ## This repo is a template, not a plugin -**Nothing in `my-plugin/` runs as-is inside feedBack.** `my-plugin` is not -a real plugin id — it's a placeholder directory meant to be copied, -renamed, and rewritten. Until that happens: +`my-plugin` is not a real plugin id — it's a placeholder directory meant to +be copied, renamed, and rewritten. As shipped it does mount as a working +demo (the counter screen, settings panel, and settings routes all function), +so you can install it and watch it run before renaming. Treat every +identifier as a placeholder regardless: - `my-plugin/plugin.json`'s `id: "my-plugin"` and the folder name `my-plugin/` are meant to be changed together. The plugin-spec (see @@ -20,12 +22,13 @@ renamed, and rewritten. Until that happens: of the mechanics (client screen ↔ server-persisted settings ↔ namespaced routes), not a feature worth keeping — replace it with whatever the real plugin actually does. -- `plugin.json` declares `"type": "visualization"`, but nothing here - implements the `setRenderer` viz-renderer contract (no - `window.feedBackViz_` factory, no `contextType`/`init`/`draw`/ - `resize`/`destroy`). If the real plugin isn't a highway visualization, - drop or change `type`; if it is one, the renderer contract has to be - built from scratch — this template doesn't demonstrate it. +- `plugin.json` declares `"type": "utility"` to match what the example + actually is (a settings screen, not a note-highway renderer). The + `visualization` type is reserved for plugins implementing the + `setRenderer` contract (a `window.feedBackViz_` factory with + `contextType`/`init`/`draw`/`resize`/`destroy`); this template doesn't + demonstrate that, so set `type` back to `visualization` only if your real + plugin builds that renderer from scratch. If you're asked to "fix" or "improve" something in `my-plugin/` in the abstract, without a rename already having happened, the most useful thing @@ -77,19 +80,17 @@ Two corrections to it, both load-bearing enough to call out here rather than silently fix in place: - **The container-mount convention** ("screen.js finds its own root via - `document.getElementById('plugin-')`") is incomplete: the Host only - creates that container when the manifest declares a top-level `screen` - key (its `has_screen` gate — `plugins/__init__.py`'s - `"has_screen": bool(manifest.get("screen"))`, consumed by - `static/js/plugin-loader.js`'s container-creation site). `script` is - injected independently of `has_screen`, so a script-only manifest still - runs `screen.js` against a DOM where that container was never created. - `my-plugin/plugin.json` in this template has **no** `screen` key (and - no `screen.html` file), so `plugin-my-plugin` is never created as - shipped, `screen.js`'s root resolves to `null`, and the checklist's - "clicking the counter button" step doesn't apply until a real plugin - adds both `"screen": "screen.html"` to its manifest **and** the file - itself. + `document.getElementById('plugin-')`") is gated on the manifest + declaring a top-level `screen` key (the Host's `has_screen` gate — + `plugins/__init__.py`'s `"has_screen": bool(manifest.get("screen"))`, + consumed by `static/js/plugin-loader.js`'s container-creation site). + `script` is injected independently of `has_screen`, so a script-only + manifest runs `screen.js` against a DOM where that container was never + created. This template now declares `"screen": "screen.html"` and ships + the file, so `plugin-my-plugin` is created and the example mounts. Note + the current Host hardcodes the fetch path — it always requests + `/api/plugins//screen.html` and ignores the manifest value — so the + markup file has to be named `screen.html`. - **The folder/id mismatch consequence** ("a mismatch is a silent skip at plugin discovery") is spec language, not what the shipped `get-flashbacks/feedBack` Host actually does — see the note in "This diff --git a/README.md b/README.md index fbf9d98..5572af8 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ A reference template for creating [feedBack plugins](https://github.com/got-feed 2. **Update the manifest:** Edit `your-plugin/plugin.json`: - Change `"id"` to match your folder name (e.g., `"your-plugin"`) - Update `name`, `version`, `description`, `type`, `icon` + - Update the hard-coded `PLUGIN_ID` in `screen.js`, `settings.html`, and `routes.py` (including the `nav.screen` value in `plugin.json`) so the screen lookup and `/api/plugins//...` routes follow the new id 3. **Customize the plugin:** Edit the files in your plugin directory: + - `screen.html` — Client screen markup (the counter and settings display) - `screen.js` — Client-side screen logic - `settings.html` — Settings panel markup and behavior - `routes.py` — Server-side logic and persistence @@ -38,13 +40,29 @@ A reference template for creating [feedBack plugins](https://github.com/got-feed 5. **Reload plugins:** Restart feedBack or use the plugin manager to reload. Your plugin should appear in the plugin list. +## Verify the example runs + +The automated checks (the same ones `custom-checks.yml` runs in CI): + +```bash +pip install -r requirements-dev.txt # test-only deps: fastapi, httpx, pytest +pytest -q +``` + +The suite covers the manifest/screen mount contract and the settings routes (GET defaults, POST/persistence, invalid and unknown values, malformed JSON, and the 16 KiB body limit). + +To see the example live, install `my-plugin/` into feedBack (step 4 above), reload, and check the "My Plugin" sidebar entry: + +1. Click **Increment** — the counter climbs. +2. Open the plugin's settings panel, change the color, click **Save Settings**, then reopen the screen — the "Active color" display reflects the saved value, served from `/api/plugins/my-plugin/settings`. + ## What's included ### `my-plugin/` A complete, working plugin that demonstrates: -- A **client screen** (`screen.js`) with lifecycle management +- A **client screen** (`screen.html` markup + `screen.js` logic) with lifecycle management - **Settings persistence** (HTML form + server routes) - **Server routes** (`routes.py`) for reading/writing settings - **Styling** (`assets/plugin.css`) scoped to the plugin diff --git a/my-plugin/README.md b/my-plugin/README.md index e022b7a..c06ad4a 100644 --- a/my-plugin/README.md +++ b/my-plugin/README.md @@ -10,6 +10,7 @@ This template demonstrates a complete, working feedBack plugin that includes: | Surface | File | Manifest key | |---|---|---| +| Client screen markup | `screen.html` | `screen` | | Client screen | `screen.js` | `script` | | Styles | `assets/plugin.css` | `styles` | | Settings panel | `settings.html` | `settings` | @@ -25,6 +26,11 @@ Before anything else, choose a unique `id` for your plugin. The folder name **mu - **Rename** the `my-plugin/` folder to your chosen name (e.g., `tuner`, `drum-highway-3d`). - **Update** `plugin.json` to set `"id": ""` to match the folder name. +- **Update the hard-coded id everywhere it appears** — the example hard-codes `my-plugin` in several places, and every one has to move to the new id or the screen container lookup and the `/api/plugins//...` routes will point at the old name: + - `plugin.json` → `"id"` (plus `name`, `description`, `icon` in the same file) + - `screen.js` → the `PLUGIN_ID` constant + - `settings.html` → the `PLUGIN_ID` constant + - `routes.py` → the `PLUGIN_ID` constant (it also derives the route namespace) - Use **lowercase alphanumeric with `-` or `_` separators** (e.g., `^[a-z0-9][a-z0-9_-]*$`). **Important:** The `id` is permanent. Changing it orphans users' saved settings and breaks derived names across the app. Choose it carefully and don't rename later. @@ -41,7 +47,10 @@ Open `plugin.json` and customize: ### 3. Edit the screen -Update `screen.js` to implement your plugin's functionality. The script: +The screen has two halves: + +- **`screen.html`** — the markup. The Host creates a container `
` and sets its innerHTML from this file, so it must exist and be named `screen.html` (the current Host hardcodes that fetch path and ignores the manifest `screen` value). The example markup exposes `data-role="increment-button"`, `data-role="counter"`, and `data-role="color-display"` hooks that `screen.js` wires up. +- **`screen.js`** — the behavior. Implement your plugin's functionality here. The script: - **Runs on load:** The Host executes it once when the screen is first displayed. - **Is self-executing:** You don't export a function for the Host to call. Set up everything you need when the script runs. @@ -89,6 +98,17 @@ Customize `assets/plugin.css` for your plugin's visual appearance. Remember: - **Don't mutate the app shell.** Use the Host's contribution registries to add to shared surfaces (spec §6.3). - The Host applies these styles only while your screen is active, then unloads them. +## Testing the example + +The template ships a pytest suite for the manifest/screen contract and the settings routes: + +```bash +pip install -r requirements-dev.txt # from the template repo root +pytest -q +``` + +The tests discover the plugin by scanning for `plugin.json`, so they keep working after you rename the folder (keep them, they're yours). + ## How settings work 1. **Client loads settings:** Call `GET /api/plugins//settings` when the settings panel or screen mount. diff --git a/my-plugin/plugin.json b/my-plugin/plugin.json index 0542437..1dde521 100644 --- a/my-plugin/plugin.json +++ b/my-plugin/plugin.json @@ -3,10 +3,11 @@ "name": "My Plugin", "version": "0.1.0", "description": "A feedBack plugin template with screen, settings, and server routes.", - "type": "visualization", + "type": "utility", "nav": { "label": "My Plugin", "screen": "plugin-my-plugin" }, "script": "screen.js", "scriptType": "classic", + "screen": "screen.html", "styles": "assets/plugin.css", "settings": { "html": "settings.html", diff --git a/my-plugin/screen.html b/my-plugin/screen.html new file mode 100644 index 0000000..86b7bcb --- /dev/null +++ b/my-plugin/screen.html @@ -0,0 +1,30 @@ + +
+

My Plugin

+ +
+

Counter

+

+ + 0 +

+
+ +
+

Persisted settings

+

+ Active color: indigo +

+
+
diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..89729b3 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Test-only dependencies. The Host provides FastAPI at runtime; these are for +# running `pytest` against the example's routes locally and in CI. +fastapi>=0.110 +httpx>=0.27 +pytest>=8 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e501c7b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures for the template's plugin-contract tests. + +The suite discovers the plugin directory by scanning for a ``plugin.json`` +instead of hard-coding ``my-plugin``, so a copy that has already been renamed +still runs. Everything else — the manifest, the routes path, the route prefix — +is derived from the manifest. +""" + +import importlib.util +import json +import logging +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _plugin_dirs() -> list[Path]: + return sorted(p.parent for p in REPO_ROOT.glob("*/plugin.json")) + + +@pytest.fixture(scope="session") +def plugin_dir() -> Path: + dirs = _plugin_dirs() + assert len(dirs) == 1, f"expected exactly one plugin directory, found {dirs}" + return dirs[0] + + +@pytest.fixture(scope="session") +def manifest(plugin_dir: Path) -> dict: + return json.loads((plugin_dir / "plugin.json").read_text(encoding="utf-8")) + + +@pytest.fixture() +def routes_module(plugin_dir: Path, manifest: dict): + spec = importlib.util.spec_from_file_location( + "plugin_under_test_routes", plugin_dir / manifest["routes"] + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture() +def client(plugin_dir: Path, manifest: dict, routes_module, tmp_path: Path) -> TestClient: + app = FastAPI() + routes_module.setup( + app, {"config_dir": tmp_path, "log": logging.getLogger("plugin-tests")} + ) + return TestClient(app) + + +@pytest.fixture() +def settings_url(manifest: dict) -> str: + return f"/api/plugins/{manifest['id']}/settings" diff --git a/tests/test_manifest_screen.py b/tests/test_manifest_screen.py new file mode 100644 index 0000000..45fe7e2 --- /dev/null +++ b/tests/test_manifest_screen.py @@ -0,0 +1,46 @@ +"""Manifest and screen-contract checks. + +These fail when the example stops being runnable through the Host's screen +contract: a declared surface whose file is missing, a nav entry pointing at a +container the Host never creates, or screen markup that lacks the hooks +``screen.js`` queries. +""" + +from pathlib import Path + +HOST_SCREEN_PATH = "screen.html" + + +def test_every_declared_file_exists(plugin_dir: Path, manifest: dict) -> None: + for key in ("script", "screen", "styles", "routes"): + assert key in manifest, f"manifest must declare '{key}'" + assert (plugin_dir / manifest[key]).is_file(), ( + f"'{key}' points at a missing file: {manifest[key]}" + ) + assert (plugin_dir / manifest["settings"]["html"]).is_file(), ( + "settings.html points at a missing file" + ) + + +def test_screen_file_is_the_path_the_host_fetches( + plugin_dir: Path, manifest: dict +) -> None: + # The shipped Host hardcodes `/api/plugins//screen.html` when mounting a + # screen and ignores the manifest screen value, so the declared file has to + # be named `screen.html` for the example to actually render. + assert manifest["screen"] == HOST_SCREEN_PATH + assert (plugin_dir / HOST_SCREEN_PATH).is_file() + + +def test_nav_entry_points_at_the_screen_container(manifest: dict) -> None: + # The Host creates the container as `plugin-`; a nav entry that points + # anywhere else is a dead sidebar link. + assert manifest["nav"]["screen"] == f"plugin-{manifest['id']}" + + +def test_screen_markup_exposes_the_hooks_screen_js_queries( + plugin_dir: Path, manifest: dict +) -> None: + html = (plugin_dir / manifest["screen"]).read_text(encoding="utf-8") + for role in ("increment-button", "counter", "color-display"): + assert f'data-role="{role}"' in html, f"screen markup is missing data-role={role}" diff --git a/tests/test_settings_routes.py b/tests/test_settings_routes.py new file mode 100644 index 0000000..ebeed65 --- /dev/null +++ b/tests/test_settings_routes.py @@ -0,0 +1,93 @@ +"""Behavioral tests for the example's settings routes. + +Covers the advertised contract: GET returns defaults, a valid subset POSTs and +persists, and invalid, unknown, malformed, and oversized bodies are rejected +without mutating stored state. +""" + +import json +from pathlib import Path + +import pytest + +DEFAULTS = {"color": "indigo", "intensity": 5, "enable_animations": True} +MAX_BODY_BYTES = 16 * 1024 + + +def test_get_returns_defaults_when_nothing_persisted(client, settings_url) -> None: + res = client.get(settings_url) + assert res.status_code == 200 + assert res.json() == DEFAULTS + + +def test_post_valid_subset_persists_and_merges( + client, settings_url, manifest: dict, tmp_path: Path +) -> None: + res = client.post(settings_url, json={"color": "crimson"}) + assert res.status_code == 200 + assert res.json() == {**DEFAULTS, "color": "crimson"} + + assert client.get(settings_url).json() == {**DEFAULTS, "color": "crimson"} + + persisted = json.loads( + (tmp_path / f"{manifest['id']}.json").read_text(encoding="utf-8") + ) + assert persisted["color"] == "crimson" + + +def test_post_full_valid_body_persists(client, settings_url) -> None: + body = {"color": "amber", "intensity": 0, "enable_animations": False} + assert client.post(settings_url, json=body).status_code == 200 + assert client.get(settings_url).json() == body + + +@pytest.mark.parametrize( + "body", + [ + {"color": "mauve"}, + {"intensity": 11}, + {"intensity": -1}, + {"intensity": "5"}, + {"intensity": True}, + {"enable_animations": "yes"}, + {"enable_animations": 1}, + ], +) +def test_post_invalid_value_is_rejected(client, settings_url, body: dict) -> None: + assert client.post(settings_url, json=body).status_code == 400 + assert client.get(settings_url).json() == DEFAULTS + + +def test_post_unknown_key_is_rejected(client, settings_url) -> None: + assert client.post(settings_url, json={"bogus": 1}).status_code == 400 + assert client.get(settings_url).json() == DEFAULTS + + +def test_post_non_object_body_is_rejected(client, settings_url) -> None: + res = client.post( + settings_url, content=b"[1, 2, 3]", headers={"content-type": "application/json"} + ) + assert res.status_code == 400 + + +def test_post_malformed_json_is_rejected(client, settings_url) -> None: + res = client.post( + settings_url, content=b"{not json", headers={"content-type": "application/json"} + ) + assert res.status_code == 400 + + +def test_post_oversized_body_is_rejected(client, settings_url) -> None: + res = client.post(settings_url, content=b"x" * (MAX_BODY_BYTES + 1)) + assert res.status_code == 413 + assert client.get(settings_url).json() == DEFAULTS + + +def test_post_oversized_streamed_body_is_rejected(client, settings_url) -> None: + # A generator body makes httpx use chunked transfer encoding, exercising the + # streaming size cap rather than the Content-Length fast path. + def chunks(): + for _ in range(20): + yield b"x" * 1024 + + assert client.post(settings_url, content=chunks()).status_code == 413