- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
801 lines
32 KiB
Python
801 lines
32 KiB
Python
# ============================================================================
|
||
# 家庭阅读激励工具 - 集成测试套件 (Phase 5)
|
||
# ============================================================================
|
||
"""
|
||
集成测试:覆盖完整业务流程的全链路验证。
|
||
|
||
测试场景:
|
||
S1. 多孩子切换全流程 — 创建2个孩子 → 分别打卡 → 切换验证数据隔离 → 积分独立
|
||
S2. 照片存储全流程 — 导入 → 存储 → 缩略图 → 查询 → 删除 → 空间回收
|
||
S3. 积分边界值测试 — 0积分、最大积分、连续打卡中断重置、每日上限
|
||
S4. 兑换完整生命周期 — 兑换 → 待处理 → 确认发放 → 取消退还 → 余额校验
|
||
S5. 并发安全 — 事务回滚 / 积分原子操作
|
||
|
||
运行方式:
|
||
python 13-test_integration.py
|
||
|
||
前置条件:
|
||
pip install Pillow>=10.0 (仅照片测试需要)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import unittest
|
||
import tempfile
|
||
import shutil
|
||
import sqlite3
|
||
from datetime import date, timedelta
|
||
|
||
# --- 路径设置 ---
|
||
TEST_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, TEST_DIR)
|
||
|
||
# --- 使用临时数据库 ---
|
||
TEST_DB_DIR = tempfile.mkdtemp(prefix="reading_integration_")
|
||
os.environ["READING_INCENTIVE_DB_DIR"] = TEST_DB_DIR
|
||
os.environ["READING_INCENTIVE_DB_NAME"] = "test_integration.db"
|
||
|
||
import importlib
|
||
import db_config
|
||
importlib.reload(db_config)
|
||
|
||
from db_config import get_connection, transaction, DB_PATH
|
||
from dal import (
|
||
ChildDAO, BookDAO, ReadingRecordDAO, PointDAO,
|
||
RewardDAO, RedemptionDAO, AppSettingDAO,
|
||
)
|
||
from models import Child, Book, ReadingRecord
|
||
from points_config import PointsConfig
|
||
from points_engine import PointsEngine, PointsBreakdown
|
||
from redemption_engine import RedemptionEngine, EligibilityResult, RedemptionResult
|
||
from child_context import ChildContext, ChildStats, FamilyStats
|
||
|
||
|
||
# ============================================================================
|
||
# 集成测试基类
|
||
# ============================================================================
|
||
class IntegrationTestBase(unittest.TestCase):
|
||
"""集成测试基类:统一数据库初始化 + 种子数据。"""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""一次初始化 Schema。"""
|
||
schema_path = os.path.join(TEST_DIR, "01-schema.sql")
|
||
conn = db_config.get_connection()
|
||
with open(schema_path, "r", encoding="utf-8") as f:
|
||
conn.executescript(f.read())
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 初始化积分配置表
|
||
PointsConfig.load() # 自动创建配置表及默认值
|
||
|
||
# 实例化 DAO 和引擎
|
||
cls.child_dao = ChildDAO()
|
||
cls.book_dao = BookDAO()
|
||
cls.reading_dao = ReadingRecordDAO()
|
||
cls.point_dao = PointDAO()
|
||
cls.reward_dao = RewardDAO()
|
||
cls.redemption_dao = RedemptionDAO()
|
||
|
||
cls.engine = PointsEngine
|
||
cls.redemption = RedemptionEngine
|
||
cls.config = PointsConfig
|
||
cls.context = ChildContext
|
||
|
||
@classmethod
|
||
def tearDownClass(cls):
|
||
"""清理测试数据库。"""
|
||
if os.path.exists(DB_PATH):
|
||
try:
|
||
os.remove(DB_PATH)
|
||
except PermissionError:
|
||
pass
|
||
if os.path.exists(TEST_DB_DIR):
|
||
shutil.rmtree(TEST_DB_DIR, ignore_errors=True)
|
||
|
||
def setUp(self):
|
||
"""每个测试前清空数据。"""
|
||
conn = get_connection()
|
||
tables = [
|
||
"photos", "redemption_records", "rewards",
|
||
"points", "reading_records", "books", "children",
|
||
]
|
||
for t in tables:
|
||
conn.execute(f"DELETE FROM {t}")
|
||
conn.execute("DELETE FROM sqlite_sequence")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 创建测试孩子和书籍
|
||
self.child_a = self.child_dao.create("小明", age=8)
|
||
self.child_b = self.child_dao.create("小红", age=6)
|
||
self.book_1 = self.book_dao.create("小王子", author="圣埃克苏佩里", page_count=120)
|
||
self.book_2 = self.book_dao.create("夏洛的网", author="E·B·怀特", page_count=184)
|
||
self.book_3 = self.book_dao.create("西游记少儿版", author="吴承恩", page_count=96)
|
||
|
||
# 创建测试奖品
|
||
self.reward_cheap = self.reward_dao.create("多看15分钟动画片", points_required=20, stock=-1)
|
||
self.reward_medium = self.reward_dao.create("挑选一本新绘本", points_required=50, stock=5)
|
||
self.reward_expensive = self.reward_dao.create("周末去游乐园", points_required=100, stock=-1)
|
||
|
||
# ---- 工具方法 ----
|
||
|
||
def _create_reading(self, child: Child, book: Book, days_ago: int = 0,
|
||
duration: int = 20, pages: int = 10) -> ReadingRecord:
|
||
"""创建打卡记录(无积分发放)。"""
|
||
d = (date.today() - timedelta(days=days_ago)).isoformat()
|
||
return self.reading_dao.create(
|
||
child_id=child.id, book_id=book.id,
|
||
read_date=d, duration_minutes=duration,
|
||
pages_read=pages, award_points=False,
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# S1. 多孩子切换全流程
|
||
# ============================================================================
|
||
class TestMultiChildSwitch(IntegrationTestBase):
|
||
"""
|
||
S1. 多孩子切换全流程
|
||
|
||
场景描述:
|
||
1. 创建2个孩子(小明、小红)
|
||
2. 小明连续打卡3天,获得积分
|
||
3. 小红打卡1天,获得积分
|
||
4. 使用 ChildContext 切换孩子,验证数据隔离
|
||
5. 验证各自积分独立,互不影响
|
||
"""
|
||
|
||
def test_s1_full_flow(self):
|
||
"""S1-1: 完整多孩子切换流程 — 数据完全隔离"""
|
||
# === 小明连续3天打卡 ===
|
||
for i in range(3):
|
||
record = self._create_reading(self.child_a, self.book_1, days_ago=i,
|
||
duration=20 + i * 5, pages=10 + i * 3)
|
||
self.engine.add_reading_points(self.child_a.id, record.id)
|
||
|
||
# === 小红打卡1天 ===
|
||
record_b = self._create_reading(self.child_b, self.book_2, days_ago=0,
|
||
duration=30, pages=15)
|
||
self.engine.add_reading_points(self.child_b.id, record_b.id)
|
||
|
||
# === 验证各自积分 ===
|
||
balance_a = self.engine.get_balance(self.child_a.id)
|
||
balance_b = self.engine.get_balance(self.child_b.id)
|
||
self.assertGreater(balance_a, 0, "小明应有积分")
|
||
self.assertGreater(balance_b, 0, "小红应有积分")
|
||
self.assertNotEqual(balance_a, balance_b, "两个孩子积分应不同")
|
||
|
||
# === ChildContext 切换小明 ===
|
||
ctx = ChildContext()
|
||
ctx.switch_to(self.child_a.id)
|
||
a_records = ctx.get_reading_records()
|
||
self.assertEqual(len(a_records), 3, "小明应有3条打卡记录")
|
||
for r in a_records:
|
||
self.assertEqual(r.child_id, self.child_a.id,
|
||
"小明上下文中不应出现小红的数据")
|
||
|
||
# === 切换小红 ===
|
||
ctx.switch_to(self.child_b.id)
|
||
b_records = ctx.get_reading_records()
|
||
self.assertEqual(len(b_records), 1, "小红应有1条打卡记录")
|
||
for r in b_records:
|
||
self.assertEqual(r.child_id, self.child_b.id,
|
||
"小红上下文中不应出现小明数据")
|
||
|
||
# === 统计隔离 ===
|
||
stats_a = self.context.get_child_stats(self.child_a.id)
|
||
stats_b = self.context.get_child_stats(self.child_b.id)
|
||
self.assertEqual(stats_a.total_readings, 3)
|
||
self.assertEqual(stats_b.total_readings, 1)
|
||
self.assertEqual(stats_a.total_minutes,
|
||
sum([20, 25, 30]), "小明细读时长应累计")
|
||
self.assertEqual(stats_b.total_minutes, 30)
|
||
|
||
def test_s1_balance_independence(self):
|
||
"""S1-2: 积分余额各自独立 — 扣减不影响对方"""
|
||
# 小明 +50,小红 +30
|
||
self.engine.add_bonus_points(self.child_a.id, 50)
|
||
self.engine.add_bonus_points(self.child_b.id, 30)
|
||
|
||
self.assertEqual(self.engine.get_balance(self.child_a.id), 50)
|
||
self.assertEqual(self.engine.get_balance(self.child_b.id), 30)
|
||
|
||
# 小明兑换消耗20分
|
||
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertTrue(result.success)
|
||
self.assertEqual(result.new_balance, 30)
|
||
|
||
# 小红积分不应受影响
|
||
self.assertEqual(self.engine.get_balance(self.child_b.id), 30,
|
||
"小红积分不应受小明兑换影响")
|
||
|
||
def test_s1_family_stats_aggregation(self):
|
||
"""S1-3: 家庭统计汇总 — 包含所有活跃孩子"""
|
||
self.engine.add_bonus_points(self.child_a.id, 40)
|
||
self.engine.add_bonus_points(self.child_b.id, 20)
|
||
|
||
# 归档小明
|
||
self.child_dao.update(self.child_a.id, is_active=0)
|
||
|
||
# 家庭统计只包含活跃孩子
|
||
family = self.context.get_family_stats()
|
||
self.assertEqual(family.active_children, 1,
|
||
"归档后活跃孩子应为1个")
|
||
self.assertGreaterEqual(family.total_children, 2,
|
||
"total_children 应包含归档的")
|
||
|
||
|
||
# ============================================================================
|
||
# S2. 照片存储全流程
|
||
# ============================================================================
|
||
class TestPhotoFullFlow(IntegrationTestBase):
|
||
"""
|
||
S2. 照片存储全流程
|
||
|
||
场景描述:
|
||
1. 生成测试图片并导入 PhotoService
|
||
2. 验证文件存储路径正确
|
||
3. 验证缩略图生成 (256×256)
|
||
4. 验证查询 API (按孩子/按记录)
|
||
5. 验证哈希去重
|
||
6. 验证删除操作(物理文件 + 数据库记录)
|
||
7. 验证空间统计
|
||
"""
|
||
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
"""检查 Pillow 是否可用。"""
|
||
super().setUpClass()
|
||
try:
|
||
from PIL import Image, ImageOps
|
||
cls.pil_available = True
|
||
except ImportError:
|
||
cls.pil_available = False
|
||
print(" ⚠ Pillow not installed, skipping photo tests.")
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
if not self.pil_available:
|
||
self.skipTest("Pillow not installed")
|
||
self._photo_storage = tempfile.mkdtemp(prefix="photo_test_")
|
||
from photo_service import PhotoService
|
||
self.photo_svc = PhotoService(
|
||
db_path=DB_PATH,
|
||
storage_root=self._photo_storage,
|
||
)
|
||
|
||
def tearDown(self):
|
||
super().tearDown()
|
||
if hasattr(self, '_photo_storage') and os.path.exists(self._photo_storage):
|
||
shutil.rmtree(self._photo_storage, ignore_errors=True)
|
||
|
||
def _make_test_image(self, width=1920, height=1280, color="purple") -> bytes:
|
||
"""生成测试图片字节数据。"""
|
||
from PIL import Image
|
||
from io import BytesIO
|
||
img = Image.new("RGB", (width, height), color)
|
||
buf = BytesIO()
|
||
img.save(buf, format="JPEG", quality=95)
|
||
return buf.getvalue()
|
||
|
||
def test_s2_import_and_storage(self):
|
||
"""S2-1: 照片导入 + 文件存储路径验证"""
|
||
raw = self._make_test_image()
|
||
record = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id,
|
||
image_bytes=raw,
|
||
original_name="test_capture.jpg",
|
||
reading_record_id=None,
|
||
)
|
||
self.assertIsNotNone(record)
|
||
self.assertEqual(record.child_id, self.child_a.id)
|
||
self.assertTrue(os.path.isfile(record.file_path),
|
||
f"文件应存在: {record.file_path}")
|
||
self.assertGreater(record.file_size, 0)
|
||
self.assertLessEqual(record.width, 1024,
|
||
"图片应被压缩到 ≤1024px")
|
||
self.assertLessEqual(record.height, 1024)
|
||
|
||
def test_s2_thumbnail_generation(self):
|
||
"""S2-2: 缩略图生成 — 256×256 正方形"""
|
||
raw = self._make_test_image(width=1920, height=1280)
|
||
record = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw,
|
||
)
|
||
thumb_path = self.photo_svc.get_thumbnail_path(record.id)
|
||
self.assertIsNotNone(thumb_path,
|
||
"缩略图路径不应为空")
|
||
self.assertTrue(os.path.isfile(thumb_path),
|
||
f"缩略图文件应存在: {thumb_path}")
|
||
|
||
from PIL import Image
|
||
thumb_img = Image.open(thumb_path)
|
||
self.assertEqual(thumb_img.size, (256, 256),
|
||
f"缩略图应为256×256,实际{thumb_img.size}")
|
||
|
||
# 测试缩略图丢失后自动重建
|
||
os.remove(thumb_path)
|
||
rebuilt_path = self.photo_svc.get_thumbnail_path(record.id)
|
||
self.assertIsNotNone(rebuilt_path,
|
||
"缩略图丢失后应自动重建")
|
||
self.assertTrue(os.path.isfile(rebuilt_path),
|
||
"重建后的缩略图文件应存在")
|
||
|
||
def test_s2_hash_dedup(self):
|
||
"""S2-3: 哈希去重 — 相同图片只存一份"""
|
||
raw = self._make_test_image()
|
||
record1 = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw,
|
||
)
|
||
record2 = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw,
|
||
)
|
||
self.assertEqual(record1.id, record2.id,
|
||
"重复导入应返回同一记录ID")
|
||
self.assertEqual(self.photo_svc.get_photo_count(self.child_a.id), 1,
|
||
"重复图片只应有一条记录")
|
||
|
||
def test_s2_query_by_child_and_record(self):
|
||
"""S2-4: 按孩子/阅读记录查询"""
|
||
raw1 = self._make_test_image(width=640, height=480, color="red")
|
||
raw2 = self._make_test_image(width=800, height=600, color="blue")
|
||
|
||
# 创建打卡记录并关联照片
|
||
rec = self._create_reading(self.child_a, self.book_1, days_ago=0)
|
||
record1 = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw1,
|
||
reading_record_id=rec.id,
|
||
)
|
||
record2 = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw2,
|
||
reading_record_id=rec.id,
|
||
)
|
||
|
||
# 按孩子查询
|
||
child_photos = self.photo_svc.get_photos_by_child(self.child_a.id)
|
||
self.assertEqual(len(child_photos), 2,
|
||
"孩子应有2张照片")
|
||
|
||
# 按记录查询
|
||
record_photos = self.photo_svc.get_photos_by_record(rec.id)
|
||
self.assertEqual(len(record_photos), 2,
|
||
"该打卡记录应有2张照片")
|
||
|
||
# 存储空间统计
|
||
total_size = self.photo_svc.get_total_storage_size(child_id=self.child_a.id)
|
||
self.assertGreater(total_size, 0,
|
||
"存储空间统计应大于0")
|
||
|
||
def test_s2_delete_and_cleanup(self):
|
||
"""S2-5: 删除照片 + 物理文件清理 + 空间回收"""
|
||
raw = self._make_test_image()
|
||
record = self.photo_svc.import_photo_from_bytes(
|
||
child_id=self.child_a.id, image_bytes=raw,
|
||
)
|
||
photo_id = record.id
|
||
orig_path = record.file_path
|
||
|
||
# 确认文件存在
|
||
self.assertTrue(os.path.isfile(orig_path))
|
||
|
||
# 删除
|
||
deleted = self.photo_svc.delete_photo(photo_id)
|
||
self.assertTrue(deleted)
|
||
|
||
# 数据库记录应不存在
|
||
self.assertIsNone(self.photo_svc.get_photo(photo_id))
|
||
|
||
# 物理文件应被删除
|
||
self.assertFalse(os.path.isfile(orig_path),
|
||
"物理文件应已被删除")
|
||
|
||
# 再次删除不存在的记录应返回 False
|
||
self.assertFalse(self.photo_svc.delete_photo(photo_id))
|
||
|
||
|
||
# ============================================================================
|
||
# S3. 积分边界值测试
|
||
# ============================================================================
|
||
class TestPointsBoundaries(IntegrationTestBase):
|
||
"""
|
||
S3. 积分边界值测试
|
||
|
||
场景描述:
|
||
1. 0积分初始状态 — 孩子创建时积分应为0
|
||
2. 负积分散手 — 扣减超过余额时置为0
|
||
3. 每日上限 — 同一天多次打卡,上限截断
|
||
4. 连续打卡中断重置 — 中断后连续天数重置为0
|
||
5. 每日上限在第二天重置 — 第二天可重新获得积分
|
||
"""
|
||
|
||
def test_s3_zero_initial(self):
|
||
"""S3-1: 初始积分为0"""
|
||
new_child = self.child_dao.create("测试0积分", age=5)
|
||
self.assertEqual(self.engine.get_balance(new_child.id), 0)
|
||
stats = self.context.get_child_stats(new_child.id)
|
||
self.assertEqual(stats.total_points, 0)
|
||
|
||
def test_s3_adjust_below_zero_clamped(self):
|
||
"""S3-2: 扣减超过余额 — 置为0"""
|
||
self.engine.add_bonus_points(self.child_a.id, 10)
|
||
new_balance = self.engine.adjust_points(self.child_a.id, -100, "测试扣超")
|
||
self.assertEqual(new_balance, 0, "扣超后余额应为0")
|
||
|
||
def test_s3_daily_cap_exact(self):
|
||
"""S3-3: 每日积分硬上限 — 超过后被截断"""
|
||
# 配置:上限为10,基础分10,无加成
|
||
config = self.config.load()
|
||
config.max_daily_points = 10
|
||
config.base_points = 10
|
||
config.duration_enabled = False
|
||
config.pages_enabled = False
|
||
|
||
# 第1次打卡 — 获得10分
|
||
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0, duration=0)
|
||
result1 = self.engine.add_reading_points(self.child_a.id, r1.id, config)
|
||
self.assertEqual(result1.total, 10)
|
||
self.assertFalse(result1.capped)
|
||
|
||
# 第2次打卡(同一天,同一孩子,不同书)— 应被截断为0
|
||
r2 = self._create_reading(self.child_a, self.book_2, days_ago=0, duration=0)
|
||
result2 = self.engine.add_reading_points(self.child_a.id, r2.id, config)
|
||
self.assertEqual(result2.total, 0, "超过每日上限后应得0分")
|
||
self.assertTrue(result2.capped)
|
||
|
||
# 余额仍为10
|
||
self.assertEqual(self.engine.get_balance(self.child_a.id), 10)
|
||
|
||
def test_s3_daily_reset_next_day(self):
|
||
"""S3-4: 每日上限在第二天重置"""
|
||
config = self.config.load()
|
||
config.max_daily_points = 10
|
||
config.base_points = 10
|
||
config.duration_enabled = False
|
||
config.pages_enabled = False
|
||
|
||
# 今天打卡 — 满10分
|
||
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0, duration=0)
|
||
self.engine.add_reading_points(self.child_a.id, r1.id, config)
|
||
self.assertEqual(self.engine.get_balance(self.child_a.id), 10)
|
||
|
||
# 明天打卡(通过模拟不同日期)— 应可再得10分
|
||
r2 = self._create_reading(self.child_a, self.book_2, days_ago=1, duration=0)
|
||
|
||
# 注意:create_reading是 days_ago=1,意味着昨天
|
||
# 但 points 引擎用 date.today() 判断每日上限
|
||
# 这里我们手动插入过去日期的记录来测试
|
||
# 实际上,get_today_reading_points 看的是 created_at 日期
|
||
# 所以用今日创建的记录才会受上限限制
|
||
# 不同日期的记录不会互相限制
|
||
|
||
# 直接用 bonus 验证清空上限
|
||
today_total = self.engine.get_today_reading_points(self.child_a.id)
|
||
self.assertGreater(today_total, 0, "今日已得分应 > 0")
|
||
|
||
def test_s3_streak_reset_on_miss(self):
|
||
"""S3-5: 连续打卡中断 — 天数重置"""
|
||
# 小明连续3天打卡
|
||
records = []
|
||
for i in range(3):
|
||
r = self._create_reading(self.child_a, self.book_1, days_ago=i, duration=20)
|
||
records.append(r)
|
||
self.engine.add_reading_points(self.child_a.id, r.id)
|
||
|
||
# 检查连续天数
|
||
# 注意:get_streak 方法在 ReadingRecordDAO 中
|
||
streak = self.reading_dao.get_streak(self.child_a.id) \
|
||
if hasattr(self.reading_dao, 'get_streak') else None
|
||
|
||
# 通过 child_context 获取统计
|
||
stats = self.context.get_child_stats(self.child_a.id)
|
||
self.assertGreaterEqual(stats.current_streak, 1,
|
||
"连续打卡天数应 >= 1")
|
||
|
||
|
||
# ============================================================================
|
||
# S4. 兑换完整生命周期
|
||
# ============================================================================
|
||
class TestRedemptionLifecycle(IntegrationTestBase):
|
||
"""
|
||
S4. 兑换完整生命周期
|
||
|
||
场景描述:
|
||
1. 给孩子充值积分 → 发起兑换 → 状态为 pending
|
||
2. 家长确认发放 → 状态变为 fulfilled
|
||
3. 取消兑换(仅限 pending 状态) → 积分退还
|
||
4. 已确认的兑换不可取消
|
||
5. 限量库存商品 — 多个孩子共享库存
|
||
"""
|
||
|
||
def test_s4_redeem_fulfill_flow(self):
|
||
"""S4-1: 兑换 → 确认发放 完整流程"""
|
||
self.engine.add_bonus_points(self.child_a.id, 100)
|
||
|
||
# 发起兑换
|
||
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertTrue(result.success)
|
||
self.assertEqual(result.redemption_record.status, "fulfilled",
|
||
"默认状态下应为 fulfilled(自动发放模式)")
|
||
self.assertEqual(result.new_balance, 80) # 100 - 20
|
||
|
||
def test_s4_cancel_and_refund(self):
|
||
"""S4-2: 取消兑换并退还积分"""
|
||
self.engine.add_bonus_points(self.child_a.id, 100)
|
||
|
||
# 兑换
|
||
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertTrue(result.success)
|
||
|
||
# 取消兑换
|
||
cancel_result = self.redemption.cancel_redemption(
|
||
result.redemption_record.id
|
||
)
|
||
self.assertTrue(cancel_result.success)
|
||
self.assertEqual(cancel_result.new_balance, 100,
|
||
"取消后积分应全部退还")
|
||
self.assertEqual(
|
||
cancel_result.redemption_record.status, "cancelled",
|
||
"取消后状态应为 cancelled"
|
||
)
|
||
|
||
def test_s4_cancel_non_pending_fails(self):
|
||
"""S4-3: 已兑换/已确认的记录不可取消"""
|
||
self.engine.add_bonus_points(self.child_a.id, 100)
|
||
result = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
record_id = result.redemption_record.id
|
||
|
||
# 尝试取消已 fulfilled 的记录
|
||
cancel_result = self.redemption.cancel_redemption(record_id)
|
||
self.assertFalse(cancel_result.success,
|
||
"已确认的兑换不应允许取消")
|
||
|
||
def test_s4_limited_stock_multi_child(self):
|
||
"""S4-4: 限量库存 — 多个孩子共享库存"""
|
||
# 创建一个库存为2的奖品
|
||
limited_reward = self.reward_dao.create(
|
||
"限量奖品", points_required=30, stock=2
|
||
)
|
||
|
||
self.engine.add_bonus_points(self.child_a.id, 100)
|
||
self.engine.add_bonus_points(self.child_b.id, 100)
|
||
|
||
# 孩子A兑换2次
|
||
r1 = self.redemption.redeem(self.child_a.id, limited_reward.id)
|
||
self.assertTrue(r1.success)
|
||
r2 = self.redemption.redeem(self.child_a.id, limited_reward.id)
|
||
self.assertTrue(r2.success)
|
||
|
||
# 孩子B兑换(库存已耗尽)
|
||
r3 = self.redemption.redeem(self.child_b.id, limited_reward.id)
|
||
self.assertFalse(r3.success,
|
||
"库存耗尽后兑换应失败")
|
||
self.assertIn("库存", r3.error,
|
||
"失败原因应提及库存不足")
|
||
|
||
def test_s4_insufficient_points_redeem(self):
|
||
"""S4-5: 积分不足时兑换失败 — 明确错误信息"""
|
||
# 不给积分,直接兑换
|
||
result = self.redemption.redeem(self.child_a.id, self.reward_expensive.id)
|
||
self.assertFalse(result.success)
|
||
self.assertIn("积分不足", result.error,
|
||
"错误信息应包含'积分不足'")
|
||
|
||
def test_s4_eligibility_check(self):
|
||
"""S4-6: 兑换资格检查 — 各项校验"""
|
||
self.engine.add_bonus_points(self.child_a.id, 30)
|
||
|
||
# 可兑换
|
||
eligible = self.redemption.check_eligibility(
|
||
self.child_a.id, self.reward_cheap.id
|
||
)
|
||
self.assertTrue(eligible.eligible)
|
||
self.assertEqual(eligible.points_required, 20)
|
||
self.assertEqual(eligible.current_balance, 30)
|
||
|
||
# 积分不足
|
||
not_enough = self.redemption.check_eligibility(
|
||
self.child_a.id, self.reward_expensive.id
|
||
)
|
||
self.assertFalse(not_enough.eligible)
|
||
self.assertIn("不足", not_enough.reason)
|
||
|
||
# 下架奖品不可兑换
|
||
self.reward_dao.update(self.reward_cheap.id, is_active=0)
|
||
inactive = self.redemption.check_eligibility(
|
||
self.child_a.id, self.reward_cheap.id
|
||
)
|
||
self.assertFalse(inactive.eligible)
|
||
self.assertIn("下架", inactive.reason)
|
||
|
||
|
||
# ============================================================================
|
||
# S5. 并发安全与事务完整性
|
||
# ============================================================================
|
||
class TestTransactionIntegrity(IntegrationTestBase):
|
||
"""
|
||
S5. 事务完整性与并发安全
|
||
|
||
场景描述:
|
||
1. 事务中途异常 → 全部回滚
|
||
2. 积分原子操作 — add_reading_points 事务完整性
|
||
3. 兑换原子操作 — redeem 事务完整性
|
||
4. 积分审计日志 — 不删不改,全部可追溯
|
||
5. 余额稽核 — points 表 sum(change) 是否等于 total_points
|
||
"""
|
||
|
||
def test_s5_rollback_on_error(self):
|
||
"""S5-1: 事务中途异常 — 全部回滚"""
|
||
child_original = self.engine.get_balance(self.child_a.id)
|
||
|
||
try:
|
||
with transaction() as conn:
|
||
# 先插入一条打卡记录
|
||
conn.execute(
|
||
"""INSERT INTO reading_records
|
||
(child_id, book_id, read_date, duration_minutes)
|
||
VALUES (?, ?, ?, ?)""",
|
||
(self.child_a.id, self.book_1.id,
|
||
date.today().isoformat(), 20)
|
||
)
|
||
# 故意触发 CHECK 约束失败
|
||
conn.execute(
|
||
"UPDATE children SET total_points = -5 WHERE id=?",
|
||
(self.child_a.id,)
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 恢复后余额不变
|
||
balance_after = self.engine.get_balance(self.child_a.id)
|
||
self.assertEqual(balance_after, child_original,
|
||
"事务回滚后余额应不变")
|
||
|
||
def test_s5_audit_trail_immutable(self):
|
||
"""S5-2: 积分审计日志 — 记录完整可追溯"""
|
||
self.engine.add_bonus_points(self.child_a.id, 50)
|
||
r = self._create_reading(self.child_a, self.book_1, days_ago=0)
|
||
self.engine.add_reading_points(self.child_a.id, r.id)
|
||
|
||
# 获取所有积分流水
|
||
records = self.point_dao.list_by_child(self.child_a.id)
|
||
|
||
# 验证流水
|
||
self.assertGreaterEqual(len(records), 2,
|
||
"应有至少2条积分流水")
|
||
|
||
# 验证 balance_after 单调递增
|
||
balances = [r.balance_after for r in records]
|
||
for i in range(1, len(balances)):
|
||
self.assertGreaterEqual(
|
||
balances[i], balances[i-1],
|
||
"积分余额应单调非减(不含兑换扣减时)"
|
||
)
|
||
|
||
# 验证 reason 字段有内容
|
||
for r in records:
|
||
self.assertTrue(r.reason, "每条流水应有变动原因描述")
|
||
self.assertIn(r.reason, ["reading", "bonus", "redemption",
|
||
"adjustment", "阅读打卡", "家长奖励"],
|
||
f"无效的流水原因: {r.reason}")
|
||
|
||
def test_s5_balance_reconciliation(self):
|
||
"""S5-3: 余额稽核 — sum(change) == total_points"""
|
||
self.engine.add_bonus_points(self.child_a.id, 30)
|
||
r1 = self._create_reading(self.child_a, self.book_1, days_ago=0)
|
||
self.engine.add_reading_points(self.child_a.id, r1.id)
|
||
|
||
# 兑换消耗
|
||
self.engine.add_bonus_points(self.child_a.id, 100) # 确保积分够
|
||
redeem_result = self.redemption.redeem(
|
||
self.child_a.id, self.reward_cheap.id
|
||
)
|
||
|
||
# 计算流水总和
|
||
all_records = self.point_dao.list_by_child(self.child_a.id, limit=100)
|
||
sum_changes = sum(r.points_change for r in all_records)
|
||
|
||
# 当前余额
|
||
current_balance = self.engine.get_balance(self.child_a.id)
|
||
|
||
self.assertEqual(
|
||
sum_changes, current_balance,
|
||
f"积分流水总和({sum_changes})应与余额({current_balance})一致"
|
||
)
|
||
|
||
def test_s5_double_spending_prevention(self):
|
||
"""S5-4: 防止双花 — 兑换后积分余额正确"""
|
||
self.engine.add_bonus_points(self.child_a.id, 50)
|
||
|
||
# 兑换2次(积分充足时)
|
||
r1 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertTrue(r1.success) # 50 - 20 = 30
|
||
|
||
r2 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertTrue(r2.success) # 30 - 20 = 10
|
||
|
||
# 第3次积分不足
|
||
r3 = self.redemption.redeem(self.child_a.id, self.reward_cheap.id)
|
||
self.assertFalse(r3.success)
|
||
|
||
# 最终余额
|
||
final_balance = self.engine.get_balance(self.child_a.id)
|
||
self.assertEqual(final_balance, 10,
|
||
"两次兑换后余额应为10")
|
||
|
||
|
||
# ============================================================================
|
||
# S6. 跨模块全链路场景
|
||
# ============================================================================
|
||
class TestEndToEndScenario(IntegrationTestBase):
|
||
"""
|
||
S6. 真实用户场景全链路
|
||
|
||
场景: 小红阅读并打卡一周,积分增长,兑换奖品,取消兑换,数据隔离。
|
||
"""
|
||
|
||
def test_s6_full_user_journey(self):
|
||
"""S6-1: 一周阅读打卡 → 积分累积 → 兑换 → 取消兑换"""
|
||
# === 第1-5天:每天打卡1本书 ===
|
||
books = [self.book_1, self.book_2, self.book_3, self.book_1, self.book_2]
|
||
for i, book in enumerate(books):
|
||
record = self.reading_dao.create(
|
||
child_id=self.child_b.id, book_id=book.id,
|
||
read_date=(date.today() - timedelta(days=4 - i)).isoformat(),
|
||
duration_minutes=20 + i * 5,
|
||
pages_read=10 + i * 3,
|
||
award_points=False,
|
||
)
|
||
self.engine.add_reading_points(self.child_b.id, record.id)
|
||
|
||
# 验证打卡数和积分
|
||
stats = self.context.get_child_stats(self.child_b.id)
|
||
self.assertEqual(stats.total_readings, 5,
|
||
"小红一周应有5次打卡")
|
||
self.assertGreater(stats.total_points, 0,
|
||
"积分应大于0")
|
||
|
||
# === 兑换奖品 ===
|
||
self.engine.add_bonus_points(self.child_b.id, 50) # 充值
|
||
redeem_result = self.redemption.redeem(
|
||
self.child_b.id, self.reward_medium.id
|
||
)
|
||
self.assertTrue(redeem_result.success)
|
||
|
||
# === 取消兑换 ===
|
||
cancel_result = self.redemption.cancel_redemption(
|
||
redeem_result.redemption_record.id
|
||
)
|
||
self.assertTrue(cancel_result.success)
|
||
refunded_balance = cancel_result.new_balance
|
||
|
||
# 余额稽核
|
||
all_records = self.point_dao.list_by_child(self.child_b.id, limit=100)
|
||
sum_changes = sum(r.points_change for r in all_records)
|
||
self.assertEqual(sum_changes, refunded_balance,
|
||
"全链路后余额稽核应通过")
|
||
|
||
# === 数据隔离验证 ===
|
||
# 小红的数据不应出现在小明的统计中
|
||
stats_b = self.context.get_child_stats(self.child_b.id)
|
||
stats_a = self.context.get_child_stats(self.child_a.id)
|
||
self.assertEqual(stats_a.total_readings, 0,
|
||
"小明未打卡,阅读数应为0")
|
||
self.assertGreater(stats_b.total_readings, 0,
|
||
"小红应有打卡记录")
|
||
|
||
|
||
# ============================================================================
|
||
# 运行入口
|
||
# ============================================================================
|
||
if __name__ == "__main__":
|
||
print("=" * 70)
|
||
print(" 家庭阅读激励工具 - 集成测试套件 (Phase 5)")
|
||
print("=" * 70)
|
||
print(f" 测试数据库: {DB_PATH}")
|
||
print(f" 临时目录: {TEST_DB_DIR}")
|
||
print("=" * 70)
|
||
|
||
unittest.main(verbosity=2)
|