90cc0026b1
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m51s
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Validate - Security (push) Successful in 7m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m26s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 29h47m59s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 29h56m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 30h0m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 29h59m43s
CI/CD Pipeline / PR Build API Image (push) Failing after 29h59m43s
CI/CD Pipeline / Deploy Production (push) Failing after 29h47m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 29h47m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 29h47m24s
CI/CD Pipeline / CI Gate (push) Failing after 29h47m22s
CI/CD Pipeline / Canary Release to Production (push) Failing after 29h47m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 29h59m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 29h55m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 29h55m44s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h59m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
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)
|