1- """
2- Every command the CLI registers must map to a route the public API v2 has.
3-
4- make_resource_app() registers create/update by default, which silently produced
5- commands for resources that are read-only in v2 - they shipped in --help and
6- returned 404 or 405 on every run. These tests close that off.
7-
8- The check is one-directional: each request a registered command can issue must
9- exist in v2. The reverse - v2 operations with no command - is expected and
10- tracked separately.
11-
12- The route table is a committed snapshot because the CLI is a separate
13- repository from the backend; refresh it with scripts/refresh_v2_routes.py.
14- """
1+ """Check registered commands against the routes public API v2 exposes."""
152
163from __future__ import annotations
174
2512
2613FIXTURE = Path (__file__ ).parent / "fixtures" / "public_api_v2_routes.json"
2714PREFIX = "/public/v2"
28-
29- # Command groups that manage the CLI itself rather than an API resource.
3015NON_RESOURCE_GROUPS = {"auth" , "config" }
3116
3217
3318def _v2_routes () -> dict [str , set [str ]]:
34- """Map each v2 path to its methods, with path params normalised to '{}'."""
3519 routes : dict [str , set [str ]] = {}
3620 for path , methods in json .loads (FIXTURE .read_text ()).items ():
3721 assert path .startswith (PREFIX ), f"unexpected path in fixture: { path } "
@@ -41,7 +25,6 @@ def _v2_routes() -> dict[str, set[str]]:
4125
4226
4327def _walk (typer_app , prefix : tuple [str , ...] = ()) -> tuple [list , list ]:
44- """Split sub-apps into those declaring api_operations and those skipped."""
4528 checked , skipped = [], []
4629 for group in typer_app .registered_groups :
4730 if group .name in NON_RESOURCE_GROUPS :
@@ -52,8 +35,6 @@ def _walk(typer_app, prefix: tuple[str, ...] = ()) -> tuple[list, list]:
5235 if operations is not None :
5336 checked .append ((" " .join (name ), operations ))
5437 elif sub .registered_commands :
55- # A sub-app with commands but no declared operations would pass the
56- # conformance check below simply by never being looked at.
5738 skipped .append (" " .join (name ))
5839 sub_checked , sub_skipped = _walk (sub , name )
5940 checked .extend (sub_checked )
@@ -64,14 +45,24 @@ def _walk(typer_app, prefix: tuple[str, ...] = ()) -> tuple[list, list]:
6445RESOURCES , UNCHECKED = _walk (app )
6546OPERATIONS = [(command , method , path ) for command , ops in RESOURCES for method , path in ops ]
6647
48+ STALE_COMMANDS = [
49+ ("companies" , "create" ),
50+ ("companies" , "update" ),
51+ ("budgets" , "create" ),
52+ ("budgets" , "update" ),
53+ ("depreciation-books" , "create" ),
54+ ("depreciation-books" , "update" ),
55+ ("paper-checks" , "create" ),
56+ ("paper-checks" , "update" ),
57+ ("inbox" , "get" ),
58+ ]
59+
6760
6861def test_no_resource_app_escapes_the_check ():
69- """Guard the introspection, so a sub-app cannot pass by being invisible."""
7062 assert UNCHECKED == [], f"these command groups declare no api_operations and are never checked: { UNCHECKED } "
7163
7264
7365def test_resource_apps_are_discoverable ():
74- """A walk that silently returned nothing would make every check below vacuous."""
7566 assert len (RESOURCES ) > 30
7667
7768
@@ -83,21 +74,6 @@ def test_command_maps_to_a_real_v2_route(command: str, method: str, path: str):
8374 assert method in available , f"'dualentry { command } ' calls { method } { path } , but v2 allows only { sorted (available )} "
8475
8576
86- # Named explicitly so that re-adding any one of them fails here with a direct
87- # message, not only as a route-table mismatch above.
88- STALE_COMMANDS = [
89- ("companies" , "create" ),
90- ("companies" , "update" ),
91- ("budgets" , "create" ),
92- ("budgets" , "update" ),
93- ("depreciation-books" , "create" ),
94- ("depreciation-books" , "update" ),
95- ("paper-checks" , "create" ),
96- ("paper-checks" , "update" ),
97- ("inbox" , "get" ),
98- ]
99-
100-
10177@pytest .mark .parametrize (("resource" , "command" ), STALE_COMMANDS )
10278def test_stale_command_is_not_registered (resource : str , command : str ):
10379 group = next (g for g in app .registered_groups if g .name == resource )
@@ -106,25 +82,12 @@ def test_stale_command_is_not_registered(resource: str, command: str):
10682
10783
10884def test_accounts_declaration_covers_its_commands ():
109- """
110- Accounts declares its operations by hand, so additions must update them.
111-
112- Pinning the command set is the point here: a new command has to be
113- accompanied by a matching api_operations entry, or this fails.
114- """
11585 group = next (g for g in app .registered_groups if g .name == "accounts" )
11686 registered = {c .name for c in group .typer_instance .registered_commands }
11787 assert registered == {"list" , "get" }, "update api_operations in commands/accounts.py to match"
11888
11989
12090def test_paper_checks_has_no_number_lookups ():
121- """
122- Paper checks are keyed by primary key and expose no record number.
123-
124- _resolve_by_internal_id() matches on 'internal_id' then refetches by
125- 'number'; PublicPaperCheckSchemaOut has neither, so the number-based
126- lookups could never resolve.
127- """
12891 group = next (g for g in app .registered_groups if g .name == "paper-checks" )
12992 registered = {c .name for c in group .typer_instance .registered_commands }
13093 assert "get" in registered
0 commit comments