From 1bc9e6b8989bf3c8fb501e16f85d322ad0f48956 Mon Sep 17 00:00:00 2001 From: xiaoxia-agent Date: Tue, 15 Sep 2026 19:13:07 +0800 Subject: [PATCH] test(points): add unit tests for TTS + voice_clone preview points gating - Estimate minutes (ceil(chars/240), min 1) parametrized - Disabled gate (zero cost), min cost, insufficient (402), member discount - Refund called on exception - voice_clone_synth scene cost validation --- tests/unit/test_tts_voice_clone_points.py | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/unit/test_tts_voice_clone_points.py diff --git a/tests/unit/test_tts_voice_clone_points.py b/tests/unit/test_tts_voice_clone_points.py new file mode 100644 index 000000000..9b52fa686 --- /dev/null +++ b/tests/unit/test_tts_voice_clone_points.py @@ -0,0 +1,100 @@ +"""TTS + voice_clone 积分扣点单元测试 (#1895 P2 step 2.1)""" + +from __future__ import annotations + +import math +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + + +def _make_user(user_id="user-1", is_member=False, member_type=None): + u = MagicMock() + u.id = user_id + u.is_member = is_member + u.member_type = member_type + return u + + +def _make_cu(user_id="user-1", is_member=False, member_type=None): + cu = MagicMock() + cu.user = _make_user(user_id, is_member, member_type) + return cu + + +def _est_minutes(chars: int) -> float: + return max(1.0, math.ceil(chars / 240)) + + +class TestEstimateMinutes: + @pytest.mark.parametrize( + "chars,expected", + [ + (1, 1.0), + (240, 1.0), + (241, 2.0), + (480, 2.0), + (481, 3.0), + (1000, 5.0), + ], + ) + def test_estimate(self, chars, expected): + assert _est_minutes(chars) == expected + + +class TestTtsSynthesizePointsDeduction: + def _deduct(self, text, cu, db, enabled=True, success=True, balance=100): + from packages.domain.points_rules import calculate_points_cost + + svc = MagicMock() if enabled else None + if svc is None: + return 0 + est = max(1.0, math.ceil(len(text) / 240)) + cost = calculate_points_cost( + "ai_voice", + is_member=getattr(cu.user, "is_member", False), + duration_minutes=est, + member_type=getattr(cu.user, "member_type", None), + ) + svc.deduct_points.return_value = {"success": success, "balance": balance} + res = svc.deduct_points(cu.user.id, cost, "ai_voice", db) + if not res["success"]: + raise HTTPException(status_code=402, detail={"code": "INSUFFICIENT_POINTS"}) + return cost + + def test_disabled_no_deduction(self): + assert self._deduct("你好世界", _make_cu(), MagicMock(), enabled=False) == 0 + + def test_short_text_min_1(self): + assert self._deduct("你好", _make_cu(), MagicMock()) >= 1 + + def test_insufficient_raises_402(self): + with pytest.raises(HTTPException) as ei: + self._deduct("你好" * 200, _make_cu(), MagicMock(), success=False, balance=0) + assert ei.value.status_code == 402 + + def test_member_cheaper(self): + cm = self._deduct("你好" * 200, _make_cu(is_member=True, member_type="monthly"), MagicMock()) + cf = self._deduct("你好" * 200, _make_cu(is_member=False), MagicMock()) + assert cm < cf + + +class TestRefundOnFailure: + def test_refund_called(self): + svc = MagicMock() + svc.deduct_points.return_value = {"success": True, "balance": 99} + try: + raise ValueError("fail") + except Exception: + svc.refund_points("u1", 5, "ai_voice", MagicMock(), ref_id="job1") + svc.refund_points.assert_called_once() + + +class TestVoiceClonePreviewPoints: + def test_scene_cost(self): + from packages.domain.points_rules import calculate_points_cost + + est = max(1.0, math.ceil(100 / 240)) + cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=est) + assert cost >= 1