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
471 lines
16 KiB
Python
Executable File
471 lines
16 KiB
Python
Executable File
"""标题库 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"})
|