Complete Phase 1: policy kernel, REST service, and local smoke tooling
Implements QONTO-WP-0002 (policy-gated Qonto REST service with audit logging, rate limiting, and credential handling) and the ADHOC-2026-07-21 follow-up (fixture-backed local smoke mode, repo classification metadata). Marks QONTO-WP-0001/0002 and the ad-hoc workplan finished, and regenerates WORK-RECORDS.md and the ADHOC workplan's state_hub_workstream_id via fix-consistency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
eef408bb19
commit
ca12843013
33 changed files with 2533 additions and 30 deletions
31
tests/fixtures/qonto/organization.json
vendored
Normal file
31
tests/fixtures/qonto/organization.json
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"organization": {
|
||||
"name": "Binky Hedgehog GmbH",
|
||||
"legal_name": "Binky Hedgehog GmbH",
|
||||
"slug": "binky-hedgehog-gmbh-6923",
|
||||
"legal_country": "DE",
|
||||
"legal_registration_date": "2019-03-15",
|
||||
"bank_accounts": [
|
||||
{
|
||||
"name": "Hauptkonto",
|
||||
"slug": "main-account",
|
||||
"currency": "EUR",
|
||||
"balance": 2185.94,
|
||||
"authorized_balance": 2185.94,
|
||||
"iban": "DE02100100101234566810",
|
||||
"main": true,
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "Kickstart Business",
|
||||
"slug": "secondary-account",
|
||||
"currency": "EUR",
|
||||
"balance": 0.0,
|
||||
"authorized_balance": 0.0,
|
||||
"iban": "DE02100100101234567038",
|
||||
"main": false,
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
64
tests/fixtures/qonto/transactions.json
vendored
Normal file
64
tests/fixtures/qonto/transactions.json
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"transactions": [
|
||||
{
|
||||
"id": "tx-qonto-2026-07",
|
||||
"settled_at": "2026-07-01T08:00:00Z",
|
||||
"label": "Qonto",
|
||||
"side": "debit",
|
||||
"amount": 70.8,
|
||||
"currency": "EUR",
|
||||
"category": "subscription",
|
||||
"operation_type": "qonto_fee",
|
||||
"status": "completed",
|
||||
"iban": "DE02100100101234566810"
|
||||
},
|
||||
{
|
||||
"id": "tx-hub31-2026-06",
|
||||
"settled_at": "2026-06-02T08:00:00Z",
|
||||
"label": "HUB31",
|
||||
"side": "debit",
|
||||
"amount": 297.5,
|
||||
"currency": "EUR",
|
||||
"category": "other_expense",
|
||||
"operation_type": "transfer",
|
||||
"status": "completed",
|
||||
"iban": "DE02100100101234566810"
|
||||
},
|
||||
{
|
||||
"id": "tx-hub31-2026-05",
|
||||
"settled_at": "2026-05-02T08:00:00Z",
|
||||
"label": "HUB31",
|
||||
"side": "debit",
|
||||
"amount": 297.5,
|
||||
"currency": "EUR",
|
||||
"category": "other_expense",
|
||||
"operation_type": "transfer",
|
||||
"status": "completed",
|
||||
"iban": "DE02100100101234566810"
|
||||
},
|
||||
{
|
||||
"id": "tx-stripe-2026-06",
|
||||
"settled_at": "2026-06-29T08:00:00Z",
|
||||
"label": "Stripe Technology Europe Ltd",
|
||||
"side": "credit",
|
||||
"amount": 8.55,
|
||||
"currency": "EUR",
|
||||
"category": "other_income",
|
||||
"operation_type": "income",
|
||||
"status": "completed",
|
||||
"iban": "DE02100100101234566810"
|
||||
},
|
||||
{
|
||||
"id": "tx-old-window",
|
||||
"settled_at": "2026-02-01T08:00:00Z",
|
||||
"label": "Old Expense",
|
||||
"side": "debit",
|
||||
"amount": 12.34,
|
||||
"currency": "EUR",
|
||||
"category": "other_expense",
|
||||
"operation_type": "income",
|
||||
"status": "completed",
|
||||
"iban": "DE02100100101234566810"
|
||||
}
|
||||
]
|
||||
}
|
||||
213
tests/test_api.py
Normal file
213
tests/test_api.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.config import Settings
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.credentials import EnvironmentCredentialProvider
|
||||
from qonto_assistant.errors import PolicyDeniedError
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import QontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
service_name="qonto-assistant",
|
||||
default_tenant_id="binky",
|
||||
default_actor_lane="green",
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
policy_file=POLICY_FILE,
|
||||
qonto_base_url="https://example.test",
|
||||
qonto_fixture_dir=None,
|
||||
qonto_auth_mode="legacy_api_key",
|
||||
qonto_organization_path="/v2/organization",
|
||||
qonto_transactions_path="/v2/transactions",
|
||||
qonto_timeout_seconds=1,
|
||||
qonto_max_retries=0,
|
||||
qonto_secret_ttl_seconds=60,
|
||||
rate_limit_requests=20,
|
||||
rate_limit_window_seconds=60,
|
||||
max_concurrency=4,
|
||||
credential_source="env",
|
||||
openbao_path="tenants/binky/qonto-api",
|
||||
openbao_command="bao",
|
||||
openbao_timeout_seconds=5,
|
||||
host="127.0.0.1",
|
||||
port=8080,
|
||||
)
|
||||
|
||||
|
||||
def _claims() -> ActorClaims:
|
||||
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
|
||||
|
||||
|
||||
def _service(monkeypatch) -> tuple[CapabilityService, list[dict[str, object]]]:
|
||||
monkeypatch.setenv("API_USER", "binky-user")
|
||||
monkeypatch.setenv("API_KEY", "top-secret")
|
||||
events: list[dict[str, object]] = []
|
||||
settings = _settings()
|
||||
|
||||
organization_payload = {
|
||||
"organization": {
|
||||
"name": "Binky Hedgehog GmbH",
|
||||
"legal_name": "Binky Hedgehog GmbH",
|
||||
"slug": "binky-hedgehog-gmbh-6923",
|
||||
"legal_country": "DE",
|
||||
"legal_registration_date": "2019-03-15",
|
||||
"bank_accounts": [
|
||||
{
|
||||
"name": "Hauptkonto",
|
||||
"slug": "main-account",
|
||||
"currency": "EUR",
|
||||
"balance": 2185.94,
|
||||
"authorized_balance": 2185.94,
|
||||
"iban": "DE02100100101234566810",
|
||||
"main": True,
|
||||
"status": "active",
|
||||
},
|
||||
{
|
||||
"name": "Kickstart Business",
|
||||
"slug": "secondary-account",
|
||||
"currency": "EUR",
|
||||
"balance": 0,
|
||||
"authorized_balance": 0,
|
||||
"iban": "DE02100100101234567038",
|
||||
"main": False,
|
||||
"status": "active",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
transactions_payload = {
|
||||
"transactions": [
|
||||
{
|
||||
"id": "tx-qonto",
|
||||
"settled_at": "2026-07-01T08:00:00Z",
|
||||
"label": "Qonto",
|
||||
"side": "debit",
|
||||
"amount": 70.8,
|
||||
"currency": "EUR",
|
||||
"category": "subscription",
|
||||
"operation_type": "qonto_fee",
|
||||
"status": "completed",
|
||||
},
|
||||
{
|
||||
"id": "tx-hub31-1",
|
||||
"settled_at": "2026-06-02T08:00:00Z",
|
||||
"label": "HUB31",
|
||||
"side": "debit",
|
||||
"amount": 297.5,
|
||||
"currency": "EUR",
|
||||
"category": "other_expense",
|
||||
"operation_type": "transfer",
|
||||
"status": "completed",
|
||||
},
|
||||
{
|
||||
"id": "tx-hub31-2",
|
||||
"settled_at": "2026-05-02T08:00:00Z",
|
||||
"label": "HUB31",
|
||||
"side": "debit",
|
||||
"amount": 297.5,
|
||||
"currency": "EUR",
|
||||
"category": "other_expense",
|
||||
"operation_type": "transfer",
|
||||
"status": "completed",
|
||||
},
|
||||
{
|
||||
"id": "tx-stripe",
|
||||
"settled_at": "2026-06-29T08:00:00Z",
|
||||
"label": "Stripe",
|
||||
"side": "credit",
|
||||
"amount": 8.55,
|
||||
"currency": "EUR",
|
||||
"category": "other_income",
|
||||
"operation_type": "income",
|
||||
"status": "completed",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/v2/organization":
|
||||
return httpx.Response(200, json=organization_payload)
|
||||
if request.url.path == "/v2/transactions":
|
||||
return httpx.Response(200, json=transactions_payload)
|
||||
return httpx.Response(404, json={"error": "not_found"})
|
||||
|
||||
client = QontoClient(
|
||||
base_url=settings.qonto_base_url,
|
||||
organization_path=settings.qonto_organization_path,
|
||||
transactions_path=settings.qonto_transactions_path,
|
||||
auth_mode=settings.qonto_auth_mode,
|
||||
timeout_seconds=settings.qonto_timeout_seconds,
|
||||
max_retries=settings.qonto_max_retries,
|
||||
credential_provider=EnvironmentCredentialProvider(),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
policy = PolicyEngine.from_file(
|
||||
settings.policy_file,
|
||||
required_scope=settings.required_scope,
|
||||
enforce_scope=settings.enforce_scope,
|
||||
)
|
||||
service = CapabilityService(
|
||||
client=client,
|
||||
policy=policy,
|
||||
audit_logger=AuditLogger(sink=events.append),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
)
|
||||
return service, events
|
||||
|
||||
|
||||
def test_accounts_contract_returns_redacted_summary(monkeypatch) -> None:
|
||||
service, _ = _service(monkeypatch)
|
||||
|
||||
payload = service.get_accounts(claims=_claims(), request_id="req-accounts")
|
||||
|
||||
assert payload["organization"]["name"] == "Binky Hedgehog GmbH"
|
||||
assert payload["accounts"][0]["iban_last4"] == "6810"
|
||||
assert "iban" not in payload["accounts"][0]
|
||||
|
||||
|
||||
def test_transactions_contract_denies_oversized_page_size(monkeypatch) -> None:
|
||||
service, events = _service(monkeypatch)
|
||||
|
||||
try:
|
||||
service.list_transactions(
|
||||
claims=_claims(),
|
||||
request_id="req-deny",
|
||||
account_slug=None,
|
||||
page=1,
|
||||
page_size=101,
|
||||
window_days=31,
|
||||
status="completed",
|
||||
side=None,
|
||||
)
|
||||
except PolicyDeniedError as exc:
|
||||
assert exc.error_code == "arg_constraint"
|
||||
else:
|
||||
raise AssertionError("Expected policy denial")
|
||||
|
||||
assert events[-1]["decision"] == "deny"
|
||||
assert events[-1]["deny_reason"] == "arg_constraint"
|
||||
|
||||
|
||||
def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeypatch) -> None:
|
||||
service, events = _service(monkeypatch)
|
||||
|
||||
payload = service.get_snapshot(
|
||||
claims=_claims(),
|
||||
request_id="req-snapshot",
|
||||
window_days=90,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
assert payload["summary"]["total_balance"] == 2185.94
|
||||
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
|
||||
assert any(event["capability"] == "snapshot_bundle" for event in events)
|
||||
25
tests/test_audit.py
Normal file
25
tests/test_audit.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import logging
|
||||
|
||||
from qonto_assistant.audit import AuditLogger, REDACTED
|
||||
|
||||
|
||||
def test_audit_logger_redacts_secret_fields() -> None:
|
||||
events: list[dict[str, object]] = []
|
||||
logger = logging.getLogger("qonto_assistant.audit.test")
|
||||
logger.handlers.clear()
|
||||
audit = AuditLogger(logger=logger, sink=events.append)
|
||||
|
||||
payload = audit.emit(
|
||||
{
|
||||
"authorization": "Bearer super-secret",
|
||||
"api_key": "top-secret",
|
||||
"nested": {"token": "child-secret"},
|
||||
"capability": "org_summary",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["authorization"] == REDACTED
|
||||
assert payload["api_key"] == REDACTED
|
||||
assert payload["nested"]["token"] == REDACTED
|
||||
assert "super-secret" not in str(events[0])
|
||||
assert "top-secret" not in str(events[0])
|
||||
32
tests/test_fixture_client.py
Normal file
32
tests/test_fixture_client.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from pathlib import Path
|
||||
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
def test_fixture_client_loads_fixture_payloads() -> None:
|
||||
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
|
||||
|
||||
organization = client.get_organization()
|
||||
|
||||
assert organization["organization"]["name"] == "Binky Hedgehog GmbH"
|
||||
assert len(organization["organization"]["bank_accounts"]) == 2
|
||||
|
||||
|
||||
def test_fixture_client_filters_window_and_pagination() -> None:
|
||||
client = FixtureQontoClient(fixture_dir=FIXTURE_DIR)
|
||||
|
||||
payload = client.list_transactions(
|
||||
iban="DE02100100101234566810",
|
||||
page=1,
|
||||
page_size=2,
|
||||
window_days=31,
|
||||
status="completed",
|
||||
side=None,
|
||||
)
|
||||
|
||||
transactions = payload["transactions"]
|
||||
assert len(transactions) == 2
|
||||
assert transactions[0]["id"] == "tx-qonto-2026-07"
|
||||
assert all(item["id"] != "tx-old-window" for item in transactions)
|
||||
87
tests/test_policy.py
Normal file
87
tests/test_policy.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from pathlib import Path
|
||||
|
||||
from qonto_assistant.contracts import ActorClaims, CapabilityRequest
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
|
||||
|
||||
def _policy(*, enforce_scope: bool = False) -> PolicyEngine:
|
||||
return PolicyEngine.from_file(
|
||||
POLICY_FILE,
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=enforce_scope,
|
||||
)
|
||||
|
||||
|
||||
def _request(capability_id: str, **request_args: object) -> CapabilityRequest:
|
||||
claims = ActorClaims(
|
||||
actor_id="agent-1",
|
||||
tenant_id="binky",
|
||||
lane="green",
|
||||
scopes=frozenset({"finance.qonto.read"}),
|
||||
)
|
||||
return CapabilityRequest(
|
||||
capability_id=capability_id,
|
||||
tenant_id="binky",
|
||||
actor_claims=claims,
|
||||
resource_scope="test",
|
||||
request_args=dict(request_args),
|
||||
protocol="rest",
|
||||
)
|
||||
|
||||
|
||||
def test_policy_allows_known_read_capability() -> None:
|
||||
decision = _policy().decide(_request("org_summary"))
|
||||
assert decision.allowed is True
|
||||
assert decision.reason == "allow"
|
||||
|
||||
|
||||
def test_policy_denies_cross_tenant_requests() -> None:
|
||||
claims = ActorClaims(actor_id="agent-1", tenant_id="other", lane="green")
|
||||
request = CapabilityRequest(
|
||||
capability_id="org_summary",
|
||||
tenant_id="binky",
|
||||
actor_claims=claims,
|
||||
resource_scope="accounts",
|
||||
request_args={},
|
||||
protocol="rest",
|
||||
)
|
||||
decision = _policy().decide(request)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "tenant_scope"
|
||||
|
||||
|
||||
def test_policy_denies_excessive_page_size() -> None:
|
||||
decision = _policy().decide(_request("list_transactions", page=1, page_size=101, window_days=31))
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "arg_constraint"
|
||||
|
||||
|
||||
def test_policy_denies_volume_cost_shaped_requests() -> None:
|
||||
decision = _policy().decide(
|
||||
_request("list_transactions", page=1, page_size=50, window_days=31, operation_type="card_operation")
|
||||
)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "volume_cost"
|
||||
|
||||
|
||||
def test_policy_denies_credential_exfiltration_flags() -> None:
|
||||
decision = _policy().decide(_request("list_transactions", page=1, page_size=50, window_days=31, include_full_iban=True))
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "credential_exfil"
|
||||
|
||||
|
||||
def test_policy_enforces_scope_when_enabled() -> None:
|
||||
claims = ActorClaims(actor_id="agent-1", tenant_id="binky", lane="green", scopes=frozenset())
|
||||
request = CapabilityRequest(
|
||||
capability_id="org_summary",
|
||||
tenant_id="binky",
|
||||
actor_claims=claims,
|
||||
resource_scope="accounts",
|
||||
request_args={},
|
||||
protocol="rest",
|
||||
)
|
||||
decision = _policy(enforce_scope=True).decide(request)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason == "authz_denied"
|
||||
84
tests/test_qonto_client.py
Normal file
84
tests/test_qonto_client.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import httpx
|
||||
|
||||
from qonto_assistant.contracts import QontoCredentials
|
||||
from qonto_assistant.qonto_client import QontoClient
|
||||
|
||||
|
||||
class StubCredentialProvider:
|
||||
def __init__(self) -> None:
|
||||
self.invalidated = False
|
||||
|
||||
def get_credentials(self) -> QontoCredentials:
|
||||
return QontoCredentials(api_user="binky-user", api_key="top-secret")
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self.invalidated = True
|
||||
|
||||
|
||||
def test_client_sends_legacy_api_key_header_and_params() -> None:
|
||||
provider = StubCredentialProvider()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["auth"] = request.headers["Authorization"]
|
||||
seen["path"] = request.url.path
|
||||
seen["params"] = dict(request.url.params)
|
||||
return httpx.Response(200, json={"transactions": []})
|
||||
|
||||
client = QontoClient(
|
||||
base_url="https://example.test",
|
||||
organization_path="/v2/organization",
|
||||
transactions_path="/v2/transactions",
|
||||
auth_mode="legacy_api_key",
|
||||
timeout_seconds=1,
|
||||
max_retries=0,
|
||||
credential_provider=provider,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
client.list_transactions(
|
||||
iban="DE1234567890",
|
||||
page=2,
|
||||
page_size=25,
|
||||
window_days=14,
|
||||
status="completed",
|
||||
side="debit",
|
||||
)
|
||||
|
||||
assert seen["auth"] == "binky-user:top-secret"
|
||||
assert seen["path"] == "/v2/transactions"
|
||||
assert seen["params"] == {
|
||||
"iban": "DE1234567890",
|
||||
"current_page": "2",
|
||||
"per_page": "25",
|
||||
"window_days": "14",
|
||||
"status": "completed",
|
||||
"side": "debit",
|
||||
}
|
||||
|
||||
|
||||
def test_client_invalidates_cached_credentials_on_auth_failure() -> None:
|
||||
provider = StubCredentialProvider()
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"error": "unauthorized"})
|
||||
|
||||
client = QontoClient(
|
||||
base_url="https://example.test",
|
||||
organization_path="/v2/organization",
|
||||
transactions_path="/v2/transactions",
|
||||
auth_mode="legacy_api_key",
|
||||
timeout_seconds=1,
|
||||
max_retries=0,
|
||||
credential_provider=provider,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
try:
|
||||
client.get_organization()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
assert getattr(exc, "error_code", None) == "qonto_auth_failed"
|
||||
else:
|
||||
raise AssertionError("Expected Qonto auth failure")
|
||||
|
||||
assert provider.invalidated is True
|
||||
53
tests/test_snapshot_semantics.py
Normal file
53
tests/test_snapshot_semantics.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from pathlib import Path
|
||||
|
||||
from qonto_assistant.audit import AuditLogger
|
||||
from qonto_assistant.contracts import ActorClaims
|
||||
from qonto_assistant.policy import PolicyEngine
|
||||
from qonto_assistant.qonto_client import FixtureQontoClient
|
||||
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
|
||||
from qonto_assistant.service import CapabilityService
|
||||
|
||||
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
|
||||
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "qonto"
|
||||
|
||||
|
||||
def _claims() -> ActorClaims:
|
||||
return ActorClaims(actor_id="codex", tenant_id="binky", lane="green")
|
||||
|
||||
|
||||
def _service() -> CapabilityService:
|
||||
return CapabilityService(
|
||||
client=FixtureQontoClient(fixture_dir=FIXTURE_DIR),
|
||||
policy=PolicyEngine.from_file(
|
||||
POLICY_FILE,
|
||||
required_scope="finance.qonto.read",
|
||||
enforce_scope=False,
|
||||
),
|
||||
audit_logger=AuditLogger(sink=lambda _: None),
|
||||
rate_limiter=RateLimiter(limit=20, window_seconds=60),
|
||||
concurrency_limiter=ConcurrencyLimiter(limit=4),
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_recent_window_excludes_recurring_hint() -> None:
|
||||
payload = _service().get_snapshot(
|
||||
claims=_claims(),
|
||||
request_id="req-snapshot-31",
|
||||
window_days=31,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
assert payload["cost_run_rate_hints"]["recurring_debits"] == []
|
||||
|
||||
|
||||
def test_snapshot_90_day_window_detects_recurring_hint() -> None:
|
||||
payload = _service().get_snapshot(
|
||||
claims=_claims(),
|
||||
request_id="req-snapshot-90",
|
||||
window_days=90,
|
||||
page_size=50,
|
||||
)
|
||||
|
||||
recurring = payload["cost_run_rate_hints"]["recurring_debits"]
|
||||
assert recurring[0]["label"] == "HUB31"
|
||||
assert recurring[0]["occurrences"] == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue