Files
shopdb-flask/tests/test_docs_contract.py
cproudlock 36b0265668 openapi: emit a spec a machine can actually use
The generated spec carried no `parameters` and no `requestBody` on any of its
372 operations. That is invalid OpenAPI 3.1, and the practical cost was worse
than the formal one: the MCP server builds its tools from this file, so every
tool had an empty input schema and silently dropped whatever the caller passed.
A request for one asset returned the list, and nothing anywhere reported an
error. All 118 templated paths now declare their path parameters, typed from the
Flask converter that named them, and write verbs declare a JSON body.

The body is an open object carrying the prose description rather than an
invented schema. The inventory describes bodies in sentences, and a field list
this generator guessed at would be worse than none - but "an object, described
here" is the difference between a client that can send a body and one that
cannot send anything.

Security was wrong on 123 operations. `jwt-optional` means "works logged out,
returns more logged in", which OpenAPI expresses as the empty requirement
alongside the scheme; publishing them as bearer-required told every reader that
a public endpoint needs a token.

Responses were one hardcoded 200, so a generated client had no idea a call could
fail. Every operation now documents the error envelope - and the envelope itself
is a defined schema, because its error nests under `data.error` rather than at
the top level, which is the single thing people get wrong when writing against
this API.

95 summaries were cut at 120 characters mid-word, which is what a tool picker
shows a user as the whole description of a call. They now end on a word.

Tests pin the shape rather than the prose. One of them contradicted an older
test that REQUIRED the contract version as a literal in PLUGIN-HOOKS.md - the
same copying that left nine documents stale - so that test now asserts the doc
points at the generated map instead.
2026-08-14 15:56:27 -04:00

72 lines
2.8 KiB
Python

"""Docs-drift guards: docs/PLUGIN-HOOKS.md must track the live contract.
PLUGIN-HOOKS.md is the canonical plugin-author reference. These tests fail
when the contract surface moves without the doc: a version bump that skips
the doc's version example, or a new/renamed BasePlugin hook with no doc
mention. Keeping this structural (not manual review) is what lets sister
sites trust the doc.
"""
import inspect
from pathlib import Path
from shopdb import __contract_version__
from shopdb.plugins.base import BasePlugin
HOOKS_DOC = Path(__file__).resolve().parent.parent / 'docs' / 'PLUGIN-HOOKS.md'
def test_hooks_doc_exists():
assert HOOKS_DOC.exists(), 'docs/PLUGIN-HOOKS.md is missing'
def test_hooks_doc_points_at_the_generated_version():
"""The doc must name the symbol and send the reader to the generated map.
This used to require the literal value in the page, which is what made it
stale everywhere else: nine documents copied a contract version and every
one of them was wrong, including a pin an external author would have failed
to load with. A doc that points at docs/PROJECT-MAP.md cannot go stale,
because the map is generated from shopdb/__init__.py.
See tests/test_docs_versions.py, which enforces the same rule the other way
round: no document may declare a version literal at all.
"""
text = HOOKS_DOC.read_text()
assert '__contract_version__' in text, (
'docs/PLUGIN-HOOKS.md should still name the symbol a plugin pins against.')
assert 'PROJECT-MAP.md' in text, (
'docs/PLUGIN-HOOKS.md should send the reader to the generated map for '
'the current value rather than restating it.')
def test_every_public_hook_is_documented():
"""Every public BasePlugin method must be mentioned in the doc."""
text = HOOKS_DOC.read_text()
hooks = [
name for name, member in inspect.getmembers(
BasePlugin, predicate=inspect.isfunction)
if not name.startswith('_')
]
assert hooks, 'No public hooks found on BasePlugin (introspection broke?)'
missing = [hook for hook in hooks if hook not in text]
assert not missing, (
'BasePlugin hooks missing from docs/PLUGIN-HOOKS.md: '
+ ', '.join(missing)
+ '. Add a section (or mention) for each before shipping the hook.'
)
def test_doc_does_not_reference_removed_hooks():
"""Hooks removed from the contract must not be documented as current.
They may appear in "Removed" notes; this only guards section headings.
"""
text = HOOKS_DOC.read_text()
for removed in ('get_searchable_fields', 'get_event_handlers'):
assert not hasattr(BasePlugin, removed)
assert f'### `{removed}' not in text, (
f'{removed} was removed from the contract but still has a '
f'section heading in docs/PLUGIN-HOOKS.md'
)