FastAPI matches in registration order, and hierarchy was registered
~3,300 lines after the {account_id} route — every request resolved as
account_id="hierarchy" and 404'd, so the endpoint has been unreachable
since it was added. Moved above the param routes in the accounts
module, with a functional reachability test and an updated route
snapshot (literal-before-param is now asserted for both overlap
families).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
7.5 KiB
Python
147 lines
7.5 KiB
Python
"""Route-table snapshot guard for the views_api package split.
|
|
|
|
FastAPI matches routes in registration order, so a mechanical move of
|
|
endpoints between modules can silently change which endpoint answers a
|
|
path (literal vs {param} siblings). This snapshot pins the ordered
|
|
(methods, path, endpoint) table; the split must reproduce it exactly.
|
|
|
|
Regenerate after a DELIBERATE routing change with:
|
|
|
|
from libra.views_api import libra_api_router
|
|
for r in libra_api_router.routes:
|
|
print((",".join(sorted(r.methods)), r.path, r.endpoint.__name__))
|
|
"""
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
def _module(name: str):
|
|
for prefix in ("lnbits.extensions.libra", "libra"):
|
|
try:
|
|
return importlib.import_module(f"{prefix}.{name}")
|
|
except ModuleNotFoundError:
|
|
continue
|
|
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
|
|
|
|
|
|
views_api = _module("views_api")
|
|
|
|
EXPECTED_ROUTES = [
|
|
("GET", "/api/v1/currencies", "api_get_currencies"),
|
|
("GET", "/api/v1/accounts", "api_get_accounts"),
|
|
("POST", "/api/v1/accounts", "api_create_account"),
|
|
("GET", "/api/v1/accounts/hierarchy", "api_get_account_hierarchy"),
|
|
("GET", "/api/v1/accounts/{account_id}", "api_get_account"),
|
|
("GET", "/api/v1/accounts/{account_id}/balance", "api_get_account_balance"),
|
|
("GET", "/api/v1/accounts/{account_id}/transactions", "api_get_account_transactions"),
|
|
("GET", "/api/v1/entries", "api_get_journal_entries"),
|
|
("GET", "/api/v1/entries/user", "api_get_user_entries"),
|
|
("GET", "/api/v1/entries/pending", "api_get_pending_entries"),
|
|
("POST", "/api/v1/entries", "api_create_journal_entry"),
|
|
("POST", "/api/v1/entries/expense", "api_create_expense_entry"),
|
|
("POST", "/api/v1/entries/income", "api_create_income_entry"),
|
|
("POST", "/api/v1/entries/receivable", "api_create_receivable_entry"),
|
|
("POST", "/api/v1/entries/revenue", "api_create_revenue_entry"),
|
|
("POST", "/api/v1/entries/{entry_id}/approve", "api_approve_expense_entry"),
|
|
("POST", "/api/v1/entries/{entry_id}/reject", "api_reject_expense_entry"),
|
|
("GET", "/api/v1/balance", "api_get_my_balance"),
|
|
("GET", "/api/v1/balance/{user_id}", "api_get_user_balance"),
|
|
("GET", "/api/v1/balances/all", "api_get_all_balances"),
|
|
("POST", "/api/v1/generate-payment-invoice", "api_generate_payment_invoice"),
|
|
("POST", "/api/v1/record-payment", "api_record_payment"),
|
|
("POST", "/api/v1/receivables/settle", "api_settle_receivable"),
|
|
("POST", "/api/v1/payables/pay", "api_pay_user"),
|
|
("POST", "/api/v1/manual-payment-request", "api_create_manual_payment_request"),
|
|
("GET", "/api/v1/manual-payment-requests", "api_get_manual_payment_requests"),
|
|
("GET", "/api/v1/manual-payment-requests/all", "api_get_all_manual_payment_requests"),
|
|
("POST", "/api/v1/manual-payment-requests/{request_id}/approve", "api_approve_manual_payment_request"),
|
|
("POST", "/api/v1/manual-payment-requests/{request_id}/reject", "api_reject_manual_payment_request"),
|
|
("GET", "/api/v1/settings", "api_get_settings"),
|
|
("PUT", "/api/v1/settings", "api_update_settings"),
|
|
("GET", "/api/v1/user-wallet/{user_id}", "api_get_user_wallet"),
|
|
("GET", "/api/v1/users", "api_get_all_users"),
|
|
("GET", "/api/v1/admin/libra-users", "api_get_libra_users"),
|
|
("GET", "/api/v1/reports/expenses", "api_expense_report"),
|
|
("GET", "/api/v1/reports/contributions", "api_contributions_report"),
|
|
("GET", "/api/v1/users/{user_id}/unsettled-entries", "api_get_unsettled_entries"),
|
|
("GET", "/api/v1/user/wallet", "api_get_user_wallet"),
|
|
("PUT", "/api/v1/user/wallet", "api_update_user_wallet"),
|
|
("GET", "/api/v1/user/info", "api_get_user_info"),
|
|
("POST", "/api/v1/assertions", "api_create_balance_assertion"),
|
|
("GET", "/api/v1/assertions", "api_get_balance_assertions"),
|
|
("GET", "/api/v1/assertions/{assertion_id}", "api_get_balance_assertion"),
|
|
("POST", "/api/v1/assertions/{assertion_id}/check", "api_check_balance_assertion"),
|
|
("DELETE", "/api/v1/assertions/{assertion_id}", "api_delete_balance_assertion"),
|
|
("GET", "/api/v1/reconciliation/summary", "api_get_reconciliation_summary"),
|
|
("POST", "/api/v1/reconciliation/check-all", "api_check_all_assertions"),
|
|
("GET", "/api/v1/reconciliation/discrepancies", "api_get_discrepancies"),
|
|
("POST", "/api/v1/tasks/daily-reconciliation", "api_run_daily_reconciliation"),
|
|
("POST", "/api/v1/admin/equity-eligibility", "api_grant_equity_eligibility"),
|
|
("DELETE", "/api/v1/admin/equity-eligibility/{user_id}", "api_revoke_equity_eligibility"),
|
|
("GET", "/api/v1/admin/equity-eligibility", "api_list_equity_eligible_users"),
|
|
("POST", "/api/v1/admin/permissions", "api_grant_permission"),
|
|
("GET", "/api/v1/admin/permissions", "api_list_permissions"),
|
|
("DELETE", "/api/v1/admin/permissions/{permission_id}", "api_revoke_permission"),
|
|
("POST", "/api/v1/admin/permissions/bulk", "api_bulk_grant_permissions"),
|
|
("POST", "/api/v1/admin/permissions/bulk-grant", "api_bulk_grant_permission_to_users"),
|
|
("GET", "/api/v1/users/me/permissions", "api_get_user_permissions"),
|
|
("POST", "/api/v1/admin/accounts", "api_admin_add_chart_account"),
|
|
("POST", "/api/v1/admin/accounts/sync", "api_sync_all_accounts"),
|
|
("POST", "/api/v1/admin/accounts/sync/{account_name:path}", "api_sync_single_account"),
|
|
("GET", "/api/v1/admin/roles", "api_get_all_roles"),
|
|
("POST", "/api/v1/admin/roles", "api_create_role"),
|
|
("GET", "/api/v1/admin/roles/{role_id}", "api_get_role"),
|
|
("PUT", "/api/v1/admin/roles/{role_id}", "api_update_role"),
|
|
("DELETE", "/api/v1/admin/roles/{role_id}", "api_delete_role"),
|
|
("POST", "/api/v1/admin/roles/{role_id}/permissions", "api_add_role_permission"),
|
|
("DELETE", "/api/v1/admin/roles/{role_id}/permissions/{permission_id}", "api_delete_role_permission"),
|
|
("POST", "/api/v1/admin/user-roles", "api_assign_user_role"),
|
|
("GET", "/api/v1/admin/user-roles/{user_id}", "api_get_user_roles"),
|
|
("DELETE", "/api/v1/admin/user-roles/{user_role_id}", "api_revoke_user_role"),
|
|
("GET", "/api/v1/admin/users/roles", "api_get_all_user_roles"),
|
|
("GET", "/api/v1/users/me/roles", "api_get_my_roles"),
|
|
]
|
|
|
|
|
|
def test_route_table_matches_snapshot():
|
|
actual = [
|
|
(",".join(sorted(r.methods)), r.path, r.endpoint.__name__)
|
|
for r in views_api.libra_api_router.routes
|
|
]
|
|
assert actual == EXPECTED_ROUTES
|
|
|
|
|
|
def _index(path: str) -> int:
|
|
paths = [p for _, p, _ in EXPECTED_ROUTES]
|
|
return paths.index(path)
|
|
|
|
|
|
def test_overlapping_route_order_is_preserved():
|
|
"""Only relative order among OVERLAPPING patterns is behavior;
|
|
these are the two overlap families in the table. The package split
|
|
keeps each family inside one module so include order can't reorder
|
|
them."""
|
|
# The literal must precede the {param} sibling or it is unreachable.
|
|
assert _index("/api/v1/accounts/hierarchy") < _index(
|
|
"/api/v1/accounts/{account_id}"
|
|
)
|
|
assert _index("/api/v1/admin/accounts/sync") < _index(
|
|
"/api/v1/admin/accounts/sync/{account_name:path}"
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_accounts_hierarchy_is_reachable(
|
|
client, configured_user, standard_accounts,
|
|
):
|
|
"""GET /accounts/hierarchy must reach the hierarchy endpoint. It was
|
|
registered after /accounts/{account_id} since it was added, so every
|
|
request resolved as account_id="hierarchy" and 404'd."""
|
|
_, wallet = configured_user
|
|
r = await client.get(
|
|
"/libra/api/v1/accounts/hierarchy",
|
|
headers={"X-Api-Key": wallet.inkey},
|
|
)
|
|
assert r.status_code == 200, f"hierarchy shadowed again? {r.status_code} {r.text}"
|
|
assert isinstance(r.json(), list)
|