feat: 添加 points_audit_ledger 及 JSON 字段 Pydantic Schema 约束
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Backend Tests Rules
|
||||
|
||||
This file governs `backend/tests/**` only.
|
||||
|
||||
## Scope & Precedence
|
||||
|
||||
- Inherits root `AGENTS.md` and `backend/AGENTS.md`.
|
||||
- If rules conflict, apply the stricter one.
|
||||
|
||||
## Test Execution
|
||||
|
||||
- Use `uv run pytest ...` for all backend test commands.
|
||||
- Unit tests should not depend on a running web process.
|
||||
- Integration tests under `backend/tests/integration` are live API tests and must run against a started backend.
|
||||
|
||||
## Integration Tests (Required Precondition)
|
||||
|
||||
- Before running integration tests, start backend and workers with:
|
||||
- `./infra/scripts/app.sh restart`
|
||||
- Verify service readiness via `/health` before sending test requests.
|
||||
- Integration tests may write test data to database, but must clean up created records after each test.
|
||||
|
||||
## Data Safety
|
||||
|
||||
- Test data must use isolated identifiers (unique email suffix, unique run_id/thread_id).
|
||||
- Cleanup must include auth users and related bonus/audit records created by the test.
|
||||
- Never hardcode production credentials or mutable shared user IDs in tests.
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from core.config.settings import config
|
||||
from core.db.session import AsyncSessionLocal
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_base_url() -> str:
|
||||
return os.environ.get("ERYAO_TEST_BASE_URL", "http://localhost:5775")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_verify_code() -> str:
|
||||
return os.environ.get("ERYAO_TEST__CODE", "123456")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unique_test_email() -> str:
|
||||
base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower()
|
||||
if "@" in base_email:
|
||||
name, domain = base_email.split("@", 1)
|
||||
else:
|
||||
name, domain = base_email, "example.com"
|
||||
return f"{name}+it{int(time.time() * 1000)}@{domain}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]:
|
||||
return {"email": unique_test_email, "code": test_verify_code}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api_client(api_base_url: str) -> AsyncIterator[httpx.AsyncClient]:
|
||||
async with httpx.AsyncClient(base_url=api_base_url, timeout=30.0) as client:
|
||||
try:
|
||||
health = await client.get("/health")
|
||||
if health.status_code != 200:
|
||||
pytest.skip(f"API not ready: /health={health.status_code}")
|
||||
except Exception as exc:
|
||||
pytest.skip(f"API unavailable: {exc}")
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_cleanup() -> AsyncIterator[list[str]]:
|
||||
emails: list[str] = []
|
||||
yield emails
|
||||
|
||||
if not emails:
|
||||
return
|
||||
|
||||
hmac_key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip()
|
||||
email_hashes = [
|
||||
hmac.new(
|
||||
hmac_key.encode("utf-8"), email.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
for email in emails
|
||||
]
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM points_audit_ledger WHERE lower(coalesce(user_email_snapshot, '')) = ANY(:emails)"
|
||||
),
|
||||
{"emails": emails},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"DELETE FROM register_bonus_claims WHERE email_hash = ANY(:email_hashes)"
|
||||
),
|
||||
{"email_hashes": email_hashes},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM auth.users WHERE lower(email) = ANY(:emails)"),
|
||||
{"emails": emails},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config.settings import config
|
||||
from core.db.session import AsyncSessionLocal
|
||||
from models.points_audit_ledger import PointsAuditLedger
|
||||
from models.points_ledger import PointsLedger
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from models.user_points import UserPoints
|
||||
|
||||
|
||||
class IdentityData(TypedDict):
|
||||
email: str
|
||||
code: str
|
||||
|
||||
|
||||
async def _create_email_session(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
) -> dict[str, object]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/email-session",
|
||||
json={"email": email, "token": code},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _wait_terminal_event(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
access_token: str,
|
||||
thread_id: str,
|
||||
run_id: str,
|
||||
timeout_s: int = 180,
|
||||
) -> str:
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {"runId": run_id, "idle_limit": 120}
|
||||
started = time.time()
|
||||
|
||||
async with client.stream(
|
||||
"GET",
|
||||
f"/api/v1/agent/runs/{thread_id}/events",
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if time.time() - started > timeout_s:
|
||||
raise TimeoutError("SSE timed out")
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
event = json.loads(line[6:])
|
||||
event_type = event.get("type")
|
||||
if event_type in {"RUN_FINISHED", "RUN_ERROR"}:
|
||||
return str(event_type)
|
||||
|
||||
raise RuntimeError("No terminal SSE event")
|
||||
|
||||
|
||||
def _build_run_payload(*, thread_id: str, run_id: str) -> dict[str, object]:
|
||||
now = int(time.time() * 1000)
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"runId": run_id,
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": f"msg_{run_id}_user_0",
|
||||
"role": "user",
|
||||
"content": "今天适合做重要决策吗?",
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"runtime_mode": "chat",
|
||||
"client_time": {
|
||||
"device_timezone": "Asia/Shanghai",
|
||||
"client_now_iso": "2026-04-10T12:00:00Z",
|
||||
"client_epoch_ms": now,
|
||||
},
|
||||
"divinationPayload": {
|
||||
"divinationMethod": "自动起卦",
|
||||
"questionType": "运势",
|
||||
"question": "今天适合做重要决策吗?",
|
||||
"divinationTimeIso": "2026-04-10T12:00:00Z",
|
||||
"yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_run_delete_reregister_keeps_bonus_single_use(
|
||||
api_client: httpx.AsyncClient,
|
||||
test_identity: IdentityData,
|
||||
db_cleanup: list[str],
|
||||
) -> None:
|
||||
email = str(test_identity["email"]).strip().lower()
|
||||
db_cleanup.append(email)
|
||||
bonus = int(config.points_policy.register_bonus_points)
|
||||
|
||||
first = await _create_email_session(
|
||||
api_client,
|
||||
email=email,
|
||||
code=str(test_identity["code"]),
|
||||
)
|
||||
user1 = first.get("user")
|
||||
assert isinstance(user1, dict)
|
||||
user1_id = str(user1["id"])
|
||||
token1 = str(first["access_token"])
|
||||
headers1 = {"Authorization": f"Bearer {token1}"}
|
||||
|
||||
before_run = await api_client.get("/api/v1/points/balance", headers=headers1)
|
||||
before_run.raise_for_status()
|
||||
before_data = before_run.json()
|
||||
assert int(before_data["balance"]) == bonus
|
||||
|
||||
thread_id = str(uuid.uuid4())
|
||||
run_id = f"run_{int(time.time() * 1000)}"
|
||||
enqueue = await api_client.post(
|
||||
"/api/v1/agent/runs",
|
||||
headers=headers1,
|
||||
json=_build_run_payload(thread_id=thread_id, run_id=run_id),
|
||||
)
|
||||
enqueue.raise_for_status()
|
||||
assert enqueue.status_code == 202
|
||||
|
||||
terminal = await _wait_terminal_event(
|
||||
api_client,
|
||||
access_token=token1,
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
assert terminal in {"RUN_FINISHED", "RUN_ERROR"}
|
||||
|
||||
after_run = await api_client.get("/api/v1/points/balance", headers=headers1)
|
||||
after_run.raise_for_status()
|
||||
after_data = after_run.json()
|
||||
assert int(after_data["balance"]) == max(bonus - int(after_data["runCost"]), 0)
|
||||
|
||||
delete_resp = await api_client.delete("/api/v1/users/me", headers=headers1)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
second = await _create_email_session(
|
||||
api_client,
|
||||
email=email,
|
||||
code=str(test_identity["code"]),
|
||||
)
|
||||
user2 = second.get("user")
|
||||
assert isinstance(user2, dict)
|
||||
user2_id = str(user2["id"])
|
||||
token2 = str(second["access_token"])
|
||||
assert user1_id != user2_id
|
||||
|
||||
headers2 = {"Authorization": f"Bearer {token2}"}
|
||||
reregister_balance = await api_client.get(
|
||||
"/api/v1/points/balance", headers=headers2
|
||||
)
|
||||
reregister_balance.raise_for_status()
|
||||
re_data = reregister_balance.json()
|
||||
assert int(re_data["balance"]) == 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
points2 = (
|
||||
await session.execute(
|
||||
select(UserPoints).where(UserPoints.user_id == uuid.UUID(user2_id))
|
||||
)
|
||||
).scalar_one()
|
||||
assert int(points2.lifetime_earned) == 0
|
||||
|
||||
run_ledger_rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(PointsLedger)
|
||||
.where(PointsLedger.user_id == uuid.UUID(user1_id))
|
||||
.order_by(PointsLedger.created_at.desc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert run_ledger_rows == []
|
||||
|
||||
run_audit_rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(PointsAuditLedger)
|
||||
.where(
|
||||
PointsAuditLedger.user_id_snapshot == uuid.UUID(user1_id),
|
||||
PointsAuditLedger.run_id == run_id,
|
||||
)
|
||||
.order_by(PointsAuditLedger.created_at.desc())
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert run_audit_rows
|
||||
assert run_audit_rows[0].run_id == run_id
|
||||
assert run_audit_rows[0].billed_to in {"user", "platform"}
|
||||
|
||||
claim_rows = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(RegisterBonusClaims).where(
|
||||
RegisterBonusClaims.user_email_snapshot == email
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
assert len(claim_rows) == 1
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config.settings import config
|
||||
from schemas.domain.points import AppendAuditLedgerCommand, ApplyPointsChangeCommand
|
||||
from schemas.domain.points import PointsChargeSnapshot
|
||||
from v1.points.service import PointsService
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAccount:
|
||||
balance: int = 100
|
||||
frozen_balance: int = 0
|
||||
lifetime_earned: int = 0
|
||||
lifetime_spent: int = 0
|
||||
version: int = 0
|
||||
|
||||
|
||||
class _FakePointsRepository:
|
||||
def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None:
|
||||
self.account = _FakeAccount()
|
||||
self.usage_snapshot = usage_snapshot
|
||||
self.appended_ledger: list[ApplyPointsChangeCommand] = []
|
||||
self.appended_audit: list[AppendAuditLedgerCommand] = []
|
||||
self.claimed: bool = False
|
||||
|
||||
async def get_or_create_user_points_for_update(
|
||||
self, *, user_id: UUID
|
||||
) -> _FakeAccount:
|
||||
del user_id
|
||||
return self.account
|
||||
|
||||
async def has_ledger_event(self, *, user_id: UUID, event_id: str) -> bool:
|
||||
del user_id, event_id
|
||||
return False
|
||||
|
||||
async def append_ledger(
|
||||
self,
|
||||
*,
|
||||
command: ApplyPointsChangeCommand,
|
||||
balance_after: int,
|
||||
) -> None:
|
||||
del balance_after
|
||||
self.appended_ledger.append(command)
|
||||
|
||||
async def append_audit_ledger(self, *, command: AppendAuditLedgerCommand) -> None:
|
||||
self.appended_audit.append(command)
|
||||
|
||||
async def has_audit_event(self, *, event_id: str) -> bool:
|
||||
del event_id
|
||||
return False
|
||||
|
||||
async def get_run_usage_snapshot(
|
||||
self,
|
||||
*,
|
||||
session_id: UUID,
|
||||
run_id: str,
|
||||
) -> PointsChargeSnapshot | None:
|
||||
del session_id, run_id
|
||||
return self.usage_snapshot
|
||||
|
||||
async def claim_register_bonus(
|
||||
self,
|
||||
*,
|
||||
email_hash: str,
|
||||
user_email_snapshot: str,
|
||||
first_user_id: UUID,
|
||||
grant_event_id: str,
|
||||
) -> bool:
|
||||
del email_hash, user_email_snapshot, first_user_id, grant_event_id
|
||||
if self.claimed:
|
||||
return False
|
||||
self.claimed = True
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None:
|
||||
usage = PointsChargeSnapshot(
|
||||
message_id=uuid4(),
|
||||
message_seq=3,
|
||||
model_code="doubao-1.5-pro",
|
||||
input_tokens=123,
|
||||
output_tokens=456,
|
||||
cost=Decimal("0.023456"),
|
||||
)
|
||||
repository = _FakePointsRepository(usage_snapshot=usage)
|
||||
service = PointsService(repository=repository) # type: ignore[arg-type]
|
||||
|
||||
user_id = uuid4()
|
||||
session_id = uuid4()
|
||||
run_id = "run_123"
|
||||
result = await service.consume_successful_run_points(
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
operator_id=user_id,
|
||||
user_email="User@Example.com",
|
||||
)
|
||||
|
||||
assert result.charged is True
|
||||
assert result.amount == 20
|
||||
assert repository.account.balance == 80
|
||||
assert len(repository.appended_ledger) == 1
|
||||
assert len(repository.appended_audit) == 1
|
||||
|
||||
audit = repository.appended_audit[0]
|
||||
assert audit.billed_to == "user"
|
||||
assert audit.input_tokens == 123
|
||||
assert audit.output_tokens == 456
|
||||
assert audit.cost == Decimal("0.023456")
|
||||
assert audit.direction == -1
|
||||
assert audit.amount == 20
|
||||
assert audit.user_email_snapshot == "user@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_failed_run_platform_cost_writes_platform_audit_only() -> None:
|
||||
usage = PointsChargeSnapshot(
|
||||
message_id=uuid4(),
|
||||
message_seq=1,
|
||||
model_code="doubao-1.5-pro",
|
||||
input_tokens=100,
|
||||
output_tokens=20,
|
||||
cost=Decimal("0.012300"),
|
||||
)
|
||||
repository = _FakePointsRepository(usage_snapshot=usage)
|
||||
service = PointsService(repository=repository) # type: ignore[arg-type]
|
||||
|
||||
result = await service.record_failed_run_platform_cost(
|
||||
user_id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
run_id="run_failed",
|
||||
operator_id=None,
|
||||
user_email="test@example.com",
|
||||
failure_kind="failed",
|
||||
)
|
||||
|
||||
assert result.audited is True
|
||||
assert result.cost == Decimal("0.012300")
|
||||
assert len(repository.appended_ledger) == 0
|
||||
assert len(repository.appended_audit) == 1
|
||||
|
||||
audit = repository.appended_audit[0]
|
||||
assert audit.billed_to == "platform"
|
||||
assert audit.direction == 0
|
||||
assert audit.amount == 0
|
||||
assert audit.cost == Decimal("0.012300")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_register_bonus_if_eligible_first_time_grants() -> None:
|
||||
repository = _FakePointsRepository(usage_snapshot=None)
|
||||
service = PointsService(repository=repository) # type: ignore[arg-type]
|
||||
|
||||
result = await service.grant_register_bonus_if_eligible(
|
||||
user_id=uuid4(),
|
||||
user_email="NewUser@Example.com",
|
||||
)
|
||||
|
||||
expected_bonus = int(config.points_policy.register_bonus_points)
|
||||
assert result.granted is True
|
||||
assert result.amount == expected_bonus
|
||||
assert repository.account.balance == 100 + expected_bonus
|
||||
assert len(repository.appended_ledger) == 1
|
||||
assert len(repository.appended_audit) == 1
|
||||
assert repository.appended_audit[0].billed_to == "user"
|
||||
assert repository.appended_audit[0].change_type.value == "register"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grant_register_bonus_if_eligible_second_time_skips() -> None:
|
||||
repository = _FakePointsRepository(usage_snapshot=None)
|
||||
repository.claimed = True
|
||||
service = PointsService(repository=repository) # type: ignore[arg-type]
|
||||
|
||||
result = await service.grant_register_bonus_if_eligible(
|
||||
user_id=uuid4(),
|
||||
user_email="dup@example.com",
|
||||
)
|
||||
|
||||
assert result.granted is False
|
||||
assert result.amount == 0
|
||||
assert len(repository.appended_ledger) == 0
|
||||
assert len(repository.appended_audit) == 0
|
||||
Reference in New Issue
Block a user