Files
xiaoxia-saas/tests/unit/test_title_library_use_cases.py
xiaoxia 675945f4fb
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m5s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m3s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Unit Tests (push) Successful in 8m25s
CI/CD Pipeline / Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m37s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 48s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 45s
CI/CD Pipeline / CI Gate (push) Has been skipped
test: wave217 title_library use cases +29单测(8个UC含智能选标题) (#1189)
2026-07-30 08:45:19 +08:00

471 lines
16 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""标题库 Use Cases 单元测试 — wave217"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from packages.application.title_library.commands import (
CreateTitleLibraryCommand,
IncrementTitleUsageCommand,
PickTitleCommand,
UpdateTitleLibraryCommand,
)
from packages.application.title_library.use_cases import (
CreateTitleLibraryUseCase,
DeleteTitleLibraryUseCase,
GetTitleLibraryUseCase,
IncrementTitleUsageUseCase,
ListTitleLibraryUseCase,
PickTitleUseCase,
UpdateTitleLibraryUseCase,
)
from packages.domain.exceptions import NotFoundError, QuotaExceededError
from packages.domain.title_library import TitleLibraryItem
# ── helpers ──────────────────────────────────────────────────────────────────
def _make_item(
item_id="t1",
user_id="u1",
name="标题A",
text="这是一个标题",
category="default",
usage_count=0,
is_active=True,
description="",
tags=None,
metadata_=None,
):
return TitleLibraryItem(
id=item_id,
user_id=user_id,
name=name,
text=text,
category=category,
description=description,
tags=tags or [],
usage_count=usage_count,
is_active=is_active,
metadata_=metadata_ or {},
)
# ── ListTitleLibraryUseCase ─────────────────────────────────────────────────
class TestListTitleLibraryUseCase:
def test_list_default_params(self):
items = [_make_item("t1"), _make_item("t2")]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = ListTitleLibraryUseCase(repo)
result = uc.execute("u1")
assert len(result) == 2
repo.list_by_user.assert_called_once_with("u1", category=None, skip=0, limit=50)
def test_list_with_category(self):
repo = MagicMock()
repo.list_by_user.return_value = []
uc = ListTitleLibraryUseCase(repo)
result = uc.execute("u1", category="marketing")
repo.list_by_user.assert_called_once_with("u1", category="marketing", skip=0, limit=50)
assert result == []
def test_list_pagination(self):
repo = MagicMock()
repo.list_by_user.return_value = []
uc = ListTitleLibraryUseCase(repo)
uc.execute("u1", skip=10, limit=20)
repo.list_by_user.assert_called_once_with("u1", category=None, skip=10, limit=20)
# ── GetTitleLibraryUseCase ──────────────────────────────────────────────────
class TestGetTitleLibraryUseCase:
def test_get_found(self):
item = _make_item()
repo = MagicMock()
repo.get.return_value = item
uc = GetTitleLibraryUseCase(repo)
result = uc.execute("t1", "u1")
assert result.id == "t1"
repo.get.assert_called_once_with("t1", "u1")
def test_get_not_found(self):
repo = MagicMock()
repo.get.return_value = None
uc = GetTitleLibraryUseCase(repo)
result = uc.execute("t999", "u1")
assert result is None
# ── CreateTitleLibraryUseCase ───────────────────────────────────────────────
class TestCreateTitleLibraryUseCase:
def test_create_success_free_plan_within_quota(self):
repo = MagicMock()
repo.count_by_user.return_value = 0 # 已用数量
repo.create.side_effect = lambda x: x
uc = CreateTitleLibraryUseCase(repo)
cmd = CreateTitleLibraryCommand(user_id="u1", name="好标题", text="这是一个好标题的内容")
with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc:
mock_result = MagicMock()
mock_result.allowed = True
mock_result.limit = 10
mock_result.used = 0
mock_qc.check.return_value = mock_result
result = uc.execute(cmd, plan_name="free")
assert result.name == "好标题"
assert result.user_id == "u1"
repo.create.assert_called_once()
def test_create_quota_exceeded_raises(self):
repo = MagicMock()
repo.count_by_user.return_value = 100
uc = CreateTitleLibraryUseCase(repo)
cmd = CreateTitleLibraryCommand(user_id="u1", name="超了", text="配额超限了")
with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc:
mock_result = MagicMock()
mock_result.allowed = False
mock_result.limit = 5
mock_result.used = 5
mock_qc.check.return_value = mock_result
with pytest.raises(QuotaExceededError):
uc.execute(cmd, plan_name="free")
def test_create_with_tags_and_metadata(self):
repo = MagicMock()
repo.count_by_user.return_value = 0
repo.create.side_effect = lambda x: x
uc = CreateTitleLibraryUseCase(repo)
cmd = CreateTitleLibraryCommand(
user_id="u1",
name="标题",
text="内容",
category="marketing",
description="描述",
tags=["tag1", "tag2"],
metadata_={"key": "value"},
)
with patch("packages.application.title_library.use_cases.quota_checker") as mock_qc:
mock_result = MagicMock()
mock_result.allowed = True
mock_result.limit = 100
mock_result.used = 0
mock_qc.check.return_value = mock_result
result = uc.execute(cmd)
assert result.category == "marketing"
assert result.description == "描述"
assert result.tags == ["tag1", "tag2"]
assert result.metadata_ == {"key": "value"}
# ── UpdateTitleLibraryUseCase ───────────────────────────────────────────────
class TestUpdateTitleLibraryUseCase:
def test_update_name(self):
existing = _make_item(name="old")
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", name="new")
result = uc.execute(cmd)
assert result.name == "new"
repo.update.assert_called_once()
def test_update_text(self):
existing = _make_item(text="old")
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", text="new text")
result = uc.execute(cmd)
assert result.text == "new text"
def test_update_category(self):
existing = _make_item(category="old")
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", category="new_cat")
result = uc.execute(cmd)
assert result.category == "new_cat"
def test_update_tags(self):
existing = _make_item(tags=["old"])
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", tags=["a", "b"])
result = uc.execute(cmd)
assert result.tags == ["a", "b"]
def test_update_is_active(self):
existing = _make_item(is_active=True)
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1", is_active=False)
result = uc.execute(cmd)
assert result.is_active is False
def test_update_not_found_raises(self):
repo = MagicMock()
repo.get.return_value = None
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t999", user_id="u1", name="x")
with pytest.raises(NotFoundError):
uc.execute(cmd)
def test_update_none_fields_not_modified(self):
existing = _make_item(name="keep", category="keep_cat", description="keep_desc")
repo = MagicMock()
repo.get.return_value = existing
repo.update.return_value = existing
uc = UpdateTitleLibraryUseCase(repo)
cmd = UpdateTitleLibraryCommand(title_id="t1", user_id="u1") # 全None
result = uc.execute(cmd)
assert result.name == "keep"
assert result.category == "keep_cat"
assert result.description == "keep_desc"
# ── DeleteTitleLibraryUseCase ───────────────────────────────────────────────
class TestDeleteTitleLibraryUseCase:
def test_delete_success(self):
repo = MagicMock()
repo.delete.return_value = True
uc = DeleteTitleLibraryUseCase(repo)
result = uc.execute("t1", "u1")
assert result is True
repo.delete.assert_called_once_with("t1", "u1")
def test_delete_not_found(self):
repo = MagicMock()
repo.delete.return_value = False
uc = DeleteTitleLibraryUseCase(repo)
result = uc.execute("t999", "u1")
assert result is False
# ── IncrementTitleUsageUseCase ──────────────────────────────────────────────
class TestIncrementTitleUsageUseCase:
def test_increment_default_1(self):
repo = MagicMock()
repo.increment_usage_count.return_value = True
uc = IncrementTitleUsageUseCase(repo)
cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1")
result = uc.execute(cmd)
assert result is True
repo.increment_usage_count.assert_called_once_with("t1", "u1", increment=1)
def test_increment_custom_amount(self):
repo = MagicMock()
repo.increment_usage_count.return_value = True
uc = IncrementTitleUsageUseCase(repo)
cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=5)
result = uc.execute(cmd)
repo.increment_usage_count.assert_called_once_with("t1", "u1", increment=5)
def test_increment_zero_returns_false(self):
repo = MagicMock()
uc = IncrementTitleUsageUseCase(repo)
cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=0)
result = uc.execute(cmd)
assert result is False
repo.increment_usage_count.assert_not_called()
def test_increment_negative_returns_false(self):
repo = MagicMock()
uc = IncrementTitleUsageUseCase(repo)
cmd = IncrementTitleUsageCommand(title_id="t1", user_id="u1", increment=-1)
result = uc.execute(cmd)
assert result is False
repo.increment_usage_count.assert_not_called()
# ── PickTitleUseCase ────────────────────────────────────────────────────────
class TestPickTitleUseCase:
def test_pick_from_multiple_returns_least_used_in_pool(self):
items = [
_make_item("t1", usage_count=10),
_make_item("t2", usage_count=1), # 最少
_make_item("t3", usage_count=5),
_make_item("t4", usage_count=3),
_make_item("t5", usage_count=8),
_make_item("t6", usage_count=2),
]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1")
# 由于有随机性,多次验证都在候选池(最少使用的5个)中
for _ in range(10):
result = uc.execute(cmd)
assert result is not None
# 最少使用的5个是: t2(1), t6(2), t4(3), t3(5), t5(8)
assert result.id in {"t1", "t2", "t3", "t4", "t5", "t6"}
# 选中的一定是使用次数最少的5个之一 (usage_count <= 8)
assert result.usage_count <= 8
# 验证查询参数
repo.list_by_user.assert_called()
call_args = repo.list_by_user.call_args
assert call_args[0][0] == "u1"
assert call_args[1]["is_active"] is True
def test_pick_empty_returns_none(self):
repo = MagicMock()
repo.list_by_user.return_value = []
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1")
result = uc.execute(cmd)
assert result is None
def test_pick_single_item(self):
item = _make_item("t1")
repo = MagicMock()
repo.list_by_user.return_value = [item]
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1")
result = uc.execute(cmd)
assert result.id == "t1"
def test_pick_with_category_filter(self):
repo = MagicMock()
repo.list_by_user.return_value = [_make_item("t1", category="marketing")]
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1", category="marketing")
result = uc.execute(cmd)
assert result is not None
repo.list_by_user.assert_called_once()
assert repo.list_by_user.call_args[1]["category"] == "marketing"
def test_pick_exclude_ids(self):
items = [
_make_item("t1", usage_count=1),
_make_item("t2", usage_count=2),
_make_item("t3", usage_count=3),
]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1", exclude_ids=["t1", "t2"])
# 排除 t1, t2 后只剩 t3
result = uc.execute(cmd)
assert result.id == "t3"
def test_pick_exclude_all_fallback_to_all(self):
items = [
_make_item("t1", usage_count=1),
_make_item("t2", usage_count=2),
]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1", exclude_ids=["t1", "t2"])
# 排除后没了,回退到从全部选
result = uc.execute(cmd)
assert result is not None
assert result.id in {"t1", "t2"}
def test_pick_pool_size_is_5(self):
# 10个标题,使用次数从 1~10
items = [_make_item(f"t{i}", usage_count=i) for i in range(1, 11)]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1")
# 运行多次,确保选中的都在前5个使用最少的里(t1~t5, usage 1~5
for _ in range(20):
result = uc.execute(cmd)
assert int(result.id[1:]) <= 5 # 只从前5个里选
def test_pick_fewer_than_pool_size(self):
# 只有3个标题,不足5个池大小
items = [
_make_item("t1", usage_count=3),
_make_item("t2", usage_count=1),
_make_item("t3", usage_count=2),
]
repo = MagicMock()
repo.list_by_user.return_value = items
uc = PickTitleUseCase(repo)
cmd = PickTitleCommand(user_id="u1")
results = set()
for _ in range(30):
result = uc.execute(cmd)
results.add(result.id)
# 3个都有可能被选中(随机性+少量样本,大概率至少出现2个)
assert len(results) >= 1
assert results.issubset({"t1", "t2", "t3"})