c3e1ab62ed
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m51s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m18s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 30h47m23s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 30h47m29s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 30h47m31s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 30h46m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 30h46m57s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 30h46m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 30h46m54s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 30h46m54s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 30h46m54s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 30h46m55s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 30h46m55s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 30h46m49s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 30h46m56s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 30h46m57s
- Add migration 008_membership_points.sql: users table new fields + 4 new tables - Add domain objects: PointsAccount, PointsTransaction, PointsOrder, DailyUsageRecord - Add repository ports & SQLAlchemy adapter implementations - Add PointsService with balance/charge/refund/recharge/daily-usage logic - Add points_rules configuration (9 scenes, packages, membership pricing) - Add points_gate middleware decorator for AI feature entry points - Add API routes: points balance/transactions/rules/packages/check/deduct/refund/recharge - Add API routes: membership status, daily usage - Add unit tests for points_rules (20 tests) and points_service (20 tests)
188 lines
7.2 KiB
Python
188 lines
7.2 KiB
Python
"""PointsService 单元测试 (#1895) — 使用 SQLite 内存数据库"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
from sqlalchemy import create_engine, event
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
from packages.domain.points_service import PointsService
|
||
|
||
|
||
@pytest.fixture()
|
||
def db_session():
|
||
"""创建 SQLite 内存数据库 session,包含所有积分相关表。"""
|
||
from packages.adapters.sqlalchemy_impl.models import Base
|
||
|
||
engine = create_engine("sqlite://", echo=False)
|
||
|
||
# SQLite 不支持 WITH FOR UPDATE,mock 掉
|
||
@event.listens_for(engine, "connect")
|
||
def _disable_for_update(dbapi_conn, connection_record):
|
||
pass
|
||
|
||
Base.metadata.create_all(engine)
|
||
SessionLocal = sessionmaker(bind=engine)
|
||
session = SessionLocal()
|
||
|
||
yield session
|
||
|
||
session.close()
|
||
|
||
|
||
@pytest.fixture()
|
||
def service():
|
||
return PointsService()
|
||
|
||
|
||
@pytest.fixture()
|
||
def user_id():
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
class TestGetOrCreateAccount:
|
||
def test_creates_new_account(self, service, db_session, user_id):
|
||
data = service.get_or_create_account(user_id, db_session)
|
||
assert data["user_id"] == user_id
|
||
assert data["balance"] == 0
|
||
assert data["total_earned"] == 0
|
||
assert data["total_spent"] == 0
|
||
|
||
def test_returns_existing_account(self, service, db_session, user_id):
|
||
service.get_or_create_account(user_id, db_session)
|
||
data = service.get_or_create_account(user_id, db_session)
|
||
assert data["user_id"] == user_id
|
||
assert data["balance"] == 0
|
||
|
||
|
||
class TestCheckBalance:
|
||
def test_sufficient_when_zero(self, service, db_session, user_id):
|
||
result = service.check_balance(user_id, 0, db_session)
|
||
assert result["sufficient"] is True
|
||
|
||
def test_insufficient_when_new_account(self, service, db_session, user_id):
|
||
result = service.check_balance(user_id, 10, db_session)
|
||
assert result["sufficient"] is False
|
||
assert result["remaining_after"] == -10
|
||
|
||
|
||
class TestDeductPoints:
|
||
def test_deduct_fails_insufficient_balance(self, service, db_session, user_id):
|
||
result = service.deduct_points(user_id, 100, "ai_voice", db_session)
|
||
assert result["success"] is False
|
||
assert result["transaction_id"] is None
|
||
|
||
def test_deduct_after_recharge(self, service, db_session, user_id):
|
||
# 先充值
|
||
service.add_points(user_id, 50, "recharge", db_session)
|
||
# 再扣减
|
||
result = service.deduct_points(user_id, 20, "ai_voice", db_session)
|
||
assert result["success"] is True
|
||
assert result["balance"] == 30
|
||
|
||
def test_deduct_creates_transaction(self, service, db_session, user_id):
|
||
service.add_points(user_id, 100, "recharge", db_session)
|
||
result = service.deduct_points(user_id, 30, "ai_voice", db_session)
|
||
assert result["success"] is True
|
||
|
||
txns = service.get_transactions(user_id, db_session)
|
||
assert txns["total"] == 2 # 1 add + 1 deduct
|
||
deduct_txn = [t for t in txns["items"] if t["type"] == "deduct"][0]
|
||
assert deduct_txn["amount"] == 30
|
||
assert deduct_txn["balance_after"] == 70
|
||
|
||
|
||
class TestAddPoints:
|
||
def test_add_new_account(self, service, db_session, user_id):
|
||
result = service.add_points(user_id, 100, "recharge:starter_pack", db_session)
|
||
assert result["success"] is True
|
||
assert result["balance"] == 100
|
||
|
||
def test_add_accumulates(self, service, db_session, user_id):
|
||
service.add_points(user_id, 50, "recharge", db_session)
|
||
result = service.add_points(user_id, 30, "bonus", db_session)
|
||
assert result["balance"] == 80
|
||
|
||
|
||
class TestRefundPoints:
|
||
def test_refund_adds_back(self, service, db_session, user_id):
|
||
service.add_points(user_id, 100, "recharge", db_session)
|
||
service.deduct_points(user_id, 20, "ai_voice", db_session)
|
||
result = service.refund_points(user_id, 20, "ai_voice", db_session)
|
||
assert result["success"] is True
|
||
assert result["balance"] == 100
|
||
|
||
def test_refund_creates_refund_transaction(self, service, db_session, user_id):
|
||
service.add_points(user_id, 100, "recharge", db_session)
|
||
service.refund_points(user_id, 10, "ai_rewrite", db_session)
|
||
|
||
txns = service.get_transactions(user_id, db_session)
|
||
refund_txns = [t for t in txns["items"] if t["type"] == "add" and "refund" in t["source"]]
|
||
assert len(refund_txns) == 1
|
||
assert "refund:" in refund_txns[0]["source"]
|
||
|
||
|
||
class TestGetTransactions:
|
||
def test_empty_for_new_user(self, service, db_session, user_id):
|
||
result = service.get_transactions(user_id, db_session)
|
||
assert result["total"] == 0
|
||
assert result["items"] == []
|
||
|
||
def test_pagination(self, service, db_session, user_id):
|
||
for i in range(5):
|
||
service.add_points(user_id, 10, f"batch_{i}", db_session)
|
||
|
||
result = service.get_transactions(user_id, db_session, page=1, page_size=3)
|
||
assert result["total"] == 5
|
||
assert len(result["items"]) == 3
|
||
|
||
result2 = service.get_transactions(user_id, db_session, page=2, page_size=3)
|
||
assert len(result2["items"]) == 2
|
||
|
||
|
||
class TestGetDailyUsage:
|
||
def test_zero_usage(self, service, db_session, user_id):
|
||
with patch("packages.domain.points_service._get_redis_client", return_value=None):
|
||
result = service.get_daily_usage(user_id, db_session)
|
||
assert result["free_clips_used"] == 0
|
||
assert result["free_clips_limit"] == 2
|
||
assert result["free_clips_remaining"] == 2
|
||
assert "reset_at" in result
|
||
|
||
def test_after_recording(self, service, db_session, user_id):
|
||
with patch("packages.domain.points_service._get_redis_client", return_value=None):
|
||
service.record_daily_free_clip(user_id, db_session)
|
||
result = service.get_daily_usage(user_id, db_session)
|
||
assert result["free_clips_used"] == 1
|
||
assert result["free_clips_remaining"] == 1
|
||
|
||
|
||
class TestCreateOrder:
|
||
def test_points_order(self, service, db_session, user_id):
|
||
result = service.create_order(user_id, "points", "starter_pack", db_session)
|
||
assert result["order_type"] == "points"
|
||
assert result["product_code"] == "starter_pack"
|
||
assert result["amount_cents"] == 990
|
||
assert result["status"] == "pending"
|
||
|
||
def test_membership_order(self, service, db_session, user_id):
|
||
result = service.create_order(user_id, "membership", "monthly", db_session)
|
||
assert result["order_type"] == "membership"
|
||
assert result["amount_cents"] == 1990
|
||
|
||
def test_unknown_package_raises(self, service, db_session, user_id):
|
||
with pytest.raises(ValueError, match="Unknown points package"):
|
||
service.create_order(user_id, "points", "nonexistent", db_session)
|
||
|
||
def test_unknown_membership_raises(self, service, db_session, user_id):
|
||
with pytest.raises(ValueError, match="Unknown membership type"):
|
||
service.create_order(user_id, "membership", "lifetime", db_session)
|
||
|
||
def test_unknown_order_type_raises(self, service, db_session, user_id):
|
||
with pytest.raises(ValueError, match="Unknown order type"):
|
||
service.create_order(user_id, "insurance", "basic", db_session)
|