test_billing_statements.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import asyncio
  2. from uuid import uuid4
  3. import pytest
  4. from sqlalchemy import func, select
  5. from test_after_sales import scenario # noqa: F401
  6. from test_api_documentation import validate_response
  7. from test_money_budget import PREFIX, PRICE, approve, budget, report, reserve_sample, setup
  8. from test_token_budget import sample
  9. from zhifu.modules.agent_runtime.budget import change_entry
  10. from zhifu.modules.billing.models import BillingStatement
  11. from zhifu.modules.identity.access_models import Principal
  12. from zhifu.operations.billing_access import change as access
  13. pytestmark = pytest.mark.integration
  14. async def prepared(context):
  15. await setup(context)
  16. await approve(context, PRICE)
  17. await budget(context, 100_000)
  18. identifier = uuid4()
  19. await reserve_sample(context, identifier.bytes)
  20. await change_entry(
  21. context["app"].state.sessions,
  22. context["tenant"].bytes,
  23. identifier.bytes,
  24. usage=sample()["usage"],
  25. )
  26. usage = sample()["usage"]
  27. # 用户授权的测试账单用于真实事务验收,费用字段来自现有验收价格的计算结果。
  28. return {
  29. "provider": PRICE["provider"],
  30. "reference": "integration-" + str(uuid4()),
  31. "evidence": "自动生成的事务测试账单,不代表供应商账单",
  32. "currency": "CNY",
  33. "lines": [
  34. {
  35. "record_id": "sample-1",
  36. "entry_id": str(identifier),
  37. "model": PRICE["model"],
  38. "amount": 912,
  39. "input_tokens": usage["input_tokens"],
  40. "output_tokens": usage["output_tokens"],
  41. "cached_tokens": usage["input_token_details"]["cache_read"],
  42. }
  43. ],
  44. }
  45. async def test_import_comparison_idempotency_and_atomicity(scenario): # noqa: F811
  46. body = await prepared(scenario)
  47. call = scenario["call"]
  48. path = PREFIX + "/statements/" + str(uuid4())
  49. before = await report(scenario)
  50. results = await asyncio.gather(*(call("PUT", path, "agent", body) for _ in range(4)))
  51. for response in results:
  52. validate_response(
  53. scenario["app"].openapi(), PREFIX + "/statements/{statement_id}", "put", response, 200
  54. )
  55. assert response.json()["items"][0]["status"] == "MATCHED"
  56. assert await report(scenario) == before
  57. assert (await call("PUT", path, "agent", {**body, "evidence": "不同证据"})).status_code == 409
  58. assert (await call("PUT", PREFIX + "/statements/" + str(uuid4()), "agent", body)).json()[
  59. "code"
  60. ] == "BILLING_SOURCE_EXISTS"
  61. invalid = {
  62. **body,
  63. "reference": "invalid",
  64. "lines": [
  65. *body["lines"],
  66. {**body["lines"][0], "record_id": "bad", "entry_id": str(uuid4())},
  67. ],
  68. }
  69. assert (
  70. await call("PUT", PREFIX + "/statements/" + str(uuid4()), "agent", invalid)
  71. ).status_code == 404
  72. async with scenario["app"].state.sessions() as session:
  73. assert (
  74. await session.scalar(
  75. select(func.count())
  76. .select_from(BillingStatement)
  77. .where(BillingStatement.tenant_id == scenario["tenant"].bytes)
  78. )
  79. == 1
  80. )
  81. response = await call("GET", PREFIX + "/statements?limit=1", "agent")
  82. validate_response(scenario["app"].openapi(), PREFIX + "/statements", "get", response, 200)
  83. assert response.json()["next_cursor"] is None
  84. async def test_difference_approval_and_current_comparison(scenario): # noqa: F811
  85. body = await prepared(scenario)
  86. body["lines"][0]["amount"] = 900
  87. identifier = uuid4()
  88. path = PREFIX + "/statements/" + str(identifier)
  89. response = await scenario["call"]("PUT", path, "agent", body)
  90. item = response.json()["items"][0]
  91. assert item["status"] == "DIFFERENT" and item["differences"] == ["amount"]
  92. payload = {
  93. "kind": "RECONCILE",
  94. "expected_version": item["entry_version"],
  95. "entry_id": body["lines"][0]["entry_id"],
  96. "amount": 900,
  97. "reason": "核实测试账单",
  98. "statement_id": str(identifier),
  99. "record_id": "sample-1",
  100. }
  101. wrong = await scenario["call"](
  102. "PUT", PREFIX + "/requests/" + str(uuid4()), "agent", {**payload, "amount": 901}
  103. )
  104. assert wrong.json()["code"] == "BILLING_EVIDENCE_CONFLICT"
  105. request_path = PREFIX + "/requests/" + str(uuid4())
  106. assert (await scenario["call"]("PUT", request_path, "agent", payload)).status_code == 200
  107. decision = {"expected_version": 1, "approve": True, "reason": "独立核对测试账单"}
  108. assert (
  109. await scenario["call"]("POST", request_path + "/decisions", "agent", decision)
  110. ).status_code == 403
  111. response = await scenario["call"]("POST", request_path + "/decisions", "reviewer", decision)
  112. assert response.status_code == 200, response.text
  113. assert (await report(scenario))["consumed"] == 900
  114. response = await scenario["call"]("GET", path, "agent")
  115. assert response.json()["items"][0]["status"] == "MATCHED"
  116. # 重复导入只刷新比较结果,不再次修改账本。
  117. assert (await scenario["call"]("PUT", path, "agent", body)).status_code == 200
  118. assert (await report(scenario))["consumed"] == 900
  119. response = await scenario["call"]("PUT", PREFIX + "/requests/" + str(uuid4()), "agent", payload)
  120. response = await scenario["call"](
  121. "POST", PREFIX + "/requests/" + response.json()["id"] + "/decisions", "reviewer", decision
  122. )
  123. assert response.json()["code"] == "BILLING_VERSION_CONFLICT"
  124. async def test_permissions_validation_and_unmatched_records(scenario): # noqa: F811
  125. body = await prepared(scenario)
  126. call = scenario["call"]
  127. path = PREFIX + "/statements/" + str(uuid4())
  128. assert (await call("PUT", path, "customer", body)).status_code == 403
  129. assert (await scenario["client"].get(PREFIX + "/statements")).status_code == 401
  130. for invalid in (
  131. {**body, "currency": "USD"},
  132. {**body, "lines": body["lines"] * 2},
  133. {**body, "lines": [{**body["lines"][0], "amount": 0.5}]},
  134. {**body, "lines": [{**body["lines"][0], "cached_tokens": 10**12}]},
  135. ):
  136. assert (await call("PUT", path, "agent", invalid)).status_code == 422
  137. body["lines"][0]["cached_tokens"] = None
  138. response = await call("PUT", path, "agent", body)
  139. assert response.json()["items"][0]["status"] == "UNKNOWN"
  140. unlinked = {
  141. **body,
  142. "reference": "unlinked",
  143. "lines": [{**body["lines"][0], "entry_id": None}],
  144. }
  145. response = await call("PUT", PREFIX + "/statements/" + str(uuid4()), "agent", unlinked)
  146. assert response.json()["items"][0]["status"] == "UNLINKED"
  147. mismatch = {**body, "reference": "route-mismatch", "provider": "https://example.org"}
  148. response = await call("PUT", PREFIX + "/statements/" + str(uuid4()), "agent", mismatch)
  149. assert response.json()["items"][0]["status"] == "ROUTE_MISMATCH"
  150. first = (await call("GET", PREFIX + "/statements?limit=1", "agent")).json()
  151. second = (
  152. await call("GET", PREFIX + "/statements?limit=1&after=" + first["next_cursor"], "agent")
  153. ).json()
  154. assert first["items"][0]["id"] != second["items"][0]["id"]
  155. async with scenario["app"].state.sessions.begin() as session:
  156. foreign = await session.get(Principal, scenario["accounts"]["foreign"].bytes)
  157. foreign.kind = "STAFF"
  158. tenant = foreign.tenant_id
  159. for permission in ("billing:read", "billing:request"):
  160. await access(
  161. scenario["app"].state.sessions,
  162. tenant,
  163. scenario["accounts"]["foreign"].bytes,
  164. permission,
  165. True,
  166. "integration",
  167. "租户隔离验证",
  168. )
  169. assert (await call("GET", path, "foreign")).status_code == 404
  170. assert (await call("PUT", path, "foreign", body)).status_code == 404
  171. assert (
  172. await call("PUT", PREFIX + "/statements/" + str(uuid4()), "foreign", body)
  173. ).status_code == 404
  174. assert (await call("GET", PREFIX + "/statements", "foreign")).json()["items"] == []