3921a657e8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Frontend Lint (push) Successful in 52s
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 / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m27s
CI/CD Pipeline / Unit Tests (push) Failing after 8m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
408 lines
14 KiB
Python
Executable File
408 lines
14 KiB
Python
Executable File
"""标题库 UseCase 单元测试."""
|
|
|
|
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
|
|
|
|
|
|
def _make_item(id: str, name: str, text: str, usage_count: int = 0, category: str = "default") -> TitleLibraryItem:
|
|
return TitleLibraryItem(
|
|
id=id,
|
|
user_id="user_1",
|
|
name=name,
|
|
text=text,
|
|
category=category,
|
|
description="",
|
|
tags=[],
|
|
usage_count=usage_count,
|
|
is_active=True,
|
|
metadata_={},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_repo():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_item():
|
|
return _make_item("title_1", "爆款标题", "这是一个爆款标题文案", usage_count=5)
|
|
|
|
|
|
class TestListTitleLibraryUseCase:
|
|
"""ListTitleLibraryUseCase 测试"""
|
|
|
|
def test_list_returns_results(self, mock_repo, sample_item):
|
|
"""正常返回标题列表"""
|
|
mock_repo.list_by_user.return_value = [sample_item]
|
|
use_case = ListTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("user_1")
|
|
|
|
assert len(result) == 1
|
|
assert result[0].id == "title_1"
|
|
mock_repo.list_by_user.assert_called_once_with("user_1", category=None, skip=0, limit=50)
|
|
|
|
def test_list_with_category(self, mock_repo, sample_item):
|
|
"""按分类过滤"""
|
|
mock_repo.list_by_user.return_value = [sample_item]
|
|
use_case = ListTitleLibraryUseCase(mock_repo)
|
|
|
|
use_case.execute("user_1", category="电商")
|
|
|
|
mock_repo.list_by_user.assert_called_once_with("user_1", category="电商", skip=0, limit=50)
|
|
|
|
def test_list_with_pagination(self, mock_repo, sample_item):
|
|
"""带分页参数"""
|
|
mock_repo.list_by_user.return_value = [sample_item]
|
|
use_case = ListTitleLibraryUseCase(mock_repo)
|
|
|
|
use_case.execute("user_1", skip=10, limit=20)
|
|
|
|
mock_repo.list_by_user.assert_called_once_with("user_1", category=None, skip=10, limit=20)
|
|
|
|
def test_empty_list(self, mock_repo):
|
|
"""空列表"""
|
|
mock_repo.list_by_user.return_value = []
|
|
use_case = ListTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("user_1")
|
|
|
|
assert result == []
|
|
|
|
|
|
class TestGetTitleLibraryUseCase:
|
|
"""GetTitleLibraryUseCase 测试"""
|
|
|
|
def test_get_existing(self, mock_repo, sample_item):
|
|
"""获取存在的标题"""
|
|
mock_repo.get.return_value = sample_item
|
|
use_case = GetTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("title_1", "user_1")
|
|
|
|
assert result is not None
|
|
assert result.id == "title_1"
|
|
mock_repo.get.assert_called_once_with("title_1", "user_1")
|
|
|
|
def test_get_nonexistent_returns_none(self, mock_repo):
|
|
"""获取不存在的标题返回 None"""
|
|
mock_repo.get.return_value = None
|
|
use_case = GetTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("nonexistent", "user_1")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestCreateTitleLibraryUseCase:
|
|
"""CreateTitleLibraryUseCase 测试"""
|
|
|
|
def test_create_success(self, mock_repo, sample_item):
|
|
"""创建成功"""
|
|
mock_repo.count_by_user.return_value = 0
|
|
mock_repo.create.return_value = sample_item
|
|
use_case = CreateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = CreateTitleLibraryCommand(
|
|
user_id="user_1",
|
|
name="新标题",
|
|
text="新标题文案",
|
|
category="default",
|
|
description="",
|
|
tags=[],
|
|
metadata_={},
|
|
)
|
|
result = use_case.execute(command, plan_name="free")
|
|
|
|
assert result.id == "title_1"
|
|
mock_repo.count_by_user.assert_called_once_with("user_1")
|
|
mock_repo.create.assert_called_once()
|
|
|
|
def test_create_quota_exceeded(self, mock_repo):
|
|
"""超过配额时抛出 QuotaExceededError"""
|
|
mock_repo.count_by_user.return_value = 9999
|
|
use_case = CreateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = CreateTitleLibraryCommand(
|
|
user_id="user_1",
|
|
name="新标题",
|
|
text="文案",
|
|
category="default",
|
|
description="",
|
|
tags=[],
|
|
metadata_={},
|
|
)
|
|
with pytest.raises(QuotaExceededError):
|
|
use_case.execute(command, plan_name="free")
|
|
|
|
mock_repo.create.assert_not_called()
|
|
|
|
def test_create_with_tags_and_metadata(self, mock_repo, sample_item):
|
|
"""创建时带 tags 和 metadata_"""
|
|
mock_repo.count_by_user.return_value = 0
|
|
mock_repo.create.return_value = sample_item
|
|
use_case = CreateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = CreateTitleLibraryCommand(
|
|
user_id="user_1",
|
|
name="带标签标题",
|
|
text="文案",
|
|
category="电商",
|
|
description="测试描述",
|
|
tags=["爆款", "促销"],
|
|
metadata_={"source": "manual"},
|
|
)
|
|
use_case.execute(command, plan_name="premium")
|
|
|
|
created = mock_repo.create.call_args[0][0]
|
|
assert isinstance(created, TitleLibraryItem)
|
|
assert created.name == "带标签标题"
|
|
assert created.category == "电商"
|
|
assert created.tags == ["爆款", "促销"]
|
|
assert created.metadata_ == {"source": "manual"}
|
|
|
|
|
|
class TestUpdateTitleLibraryUseCase:
|
|
"""UpdateTitleLibraryUseCase 测试"""
|
|
|
|
def test_update_name(self, mock_repo, sample_item):
|
|
"""更新标题名称"""
|
|
mock_repo.get.return_value = sample_item
|
|
mock_repo.update.side_effect = lambda x: x
|
|
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = UpdateTitleLibraryCommand(title_id="title_1", user_id="user_1", name="新名称")
|
|
result = use_case.execute(command)
|
|
|
|
assert result.name == "新名称"
|
|
# 其他字段不变
|
|
assert result.text == "这是一个爆款标题文案"
|
|
mock_repo.get.assert_called_once_with("title_1", "user_1")
|
|
mock_repo.update.assert_called_once()
|
|
|
|
def test_update_multiple_fields(self, mock_repo, sample_item):
|
|
"""同时更新多个字段"""
|
|
mock_repo.get.return_value = sample_item
|
|
mock_repo.update.side_effect = lambda x: x
|
|
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = UpdateTitleLibraryCommand(
|
|
title_id="title_1",
|
|
user_id="user_1",
|
|
text="新文案内容",
|
|
category="美食",
|
|
is_active=False,
|
|
)
|
|
result = use_case.execute(command)
|
|
|
|
assert result.text == "新文案内容"
|
|
assert result.category == "美食"
|
|
assert result.is_active is False
|
|
|
|
def test_update_nonexistent_raises(self, mock_repo):
|
|
"""更新不存在的标题抛出 NotFoundError"""
|
|
mock_repo.get.return_value = None
|
|
use_case = UpdateTitleLibraryUseCase(mock_repo)
|
|
|
|
command = UpdateTitleLibraryCommand(title_id="noexist", user_id="user_1", name="新名称")
|
|
with pytest.raises(NotFoundError, match="not found"):
|
|
use_case.execute(command)
|
|
|
|
mock_repo.update.assert_not_called()
|
|
|
|
|
|
class TestDeleteTitleLibraryUseCase:
|
|
"""DeleteTitleLibraryUseCase 测试"""
|
|
|
|
def test_delete_success(self, mock_repo):
|
|
"""删除成功"""
|
|
mock_repo.delete.return_value = True
|
|
use_case = DeleteTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("title_1", "user_1")
|
|
|
|
assert result is True
|
|
mock_repo.delete.assert_called_once_with("title_1", "user_1")
|
|
|
|
def test_delete_nonexistent_returns_false(self, mock_repo):
|
|
"""删除不存在的返回 False"""
|
|
mock_repo.delete.return_value = False
|
|
use_case = DeleteTitleLibraryUseCase(mock_repo)
|
|
|
|
result = use_case.execute("noexist", "user_1")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestIncrementTitleUsageUseCase:
|
|
"""IncrementTitleUsageUseCase 测试"""
|
|
|
|
def test_increment_positive(self, mock_repo):
|
|
"""正增量时调用 repository"""
|
|
mock_repo.increment_usage_count.return_value = True
|
|
use_case = IncrementTitleUsageUseCase(mock_repo)
|
|
|
|
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=1)
|
|
result = use_case.execute(command)
|
|
|
|
assert result is True
|
|
mock_repo.increment_usage_count.assert_called_once_with("title_1", "user_1", increment=1)
|
|
|
|
def test_increment_zero_returns_false(self, mock_repo):
|
|
"""增量为0返回False,不调用repository"""
|
|
use_case = IncrementTitleUsageUseCase(mock_repo)
|
|
|
|
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=0)
|
|
result = use_case.execute(command)
|
|
|
|
assert result is False
|
|
mock_repo.increment_usage_count.assert_not_called()
|
|
|
|
def test_increment_negative_returns_false(self, mock_repo):
|
|
"""负增量返回False"""
|
|
use_case = IncrementTitleUsageUseCase(mock_repo)
|
|
|
|
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=-1)
|
|
result = use_case.execute(command)
|
|
|
|
assert result is False
|
|
mock_repo.increment_usage_count.assert_not_called()
|
|
|
|
def test_increment_large_number(self, mock_repo):
|
|
"""大增量值"""
|
|
mock_repo.increment_usage_count.return_value = True
|
|
use_case = IncrementTitleUsageUseCase(mock_repo)
|
|
|
|
command = IncrementTitleUsageCommand(title_id="title_1", user_id="user_1", increment=10)
|
|
use_case.execute(command)
|
|
|
|
mock_repo.increment_usage_count.assert_called_once_with("title_1", "user_1", increment=10)
|
|
|
|
|
|
class TestPickTitleUseCase:
|
|
"""PickTitleUseCase 智能选标题测试"""
|
|
|
|
def test_pick_from_multiple(self, mock_repo):
|
|
"""从多个标题中选一个(最少使用的前5个中随机)"""
|
|
items = [_make_item(f"t{i}", f"标题{i}", f"文案{i}", usage_count=i) for i in range(10)]
|
|
mock_repo.list_by_user.return_value = items
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1")
|
|
result = use_case.execute(command)
|
|
|
|
assert result is not None
|
|
assert isinstance(result, TitleLibraryItem)
|
|
# 选出的应该是使用次数最少的前5个之一(0-4)
|
|
assert result.usage_count <= 4
|
|
mock_repo.list_by_user.assert_called_once()
|
|
|
|
def test_pick_empty_returns_none(self, mock_repo):
|
|
"""空标题库返回 None"""
|
|
mock_repo.list_by_user.return_value = []
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1")
|
|
result = use_case.execute(command)
|
|
|
|
assert result is None
|
|
|
|
def test_pick_with_category(self, mock_repo):
|
|
"""按分类选标题"""
|
|
items = [_make_item("t1", "标题1", "文案1", category="美食")]
|
|
mock_repo.list_by_user.return_value = items
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1", category="美食")
|
|
result = use_case.execute(command)
|
|
|
|
assert result is not None
|
|
call_kwargs = mock_repo.list_by_user.call_args[1]
|
|
assert call_kwargs["category"] == "美食"
|
|
assert call_kwargs["is_active"] is True
|
|
|
|
def test_pick_exclude_ids(self, mock_repo):
|
|
"""排除指定ID"""
|
|
items = [
|
|
_make_item("t1", "标题1", "文案1", usage_count=1),
|
|
_make_item("t2", "标题2", "文案2", usage_count=2),
|
|
_make_item("t3", "标题3", "文案3", usage_count=3),
|
|
]
|
|
mock_repo.list_by_user.return_value = items
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
|
|
result = use_case.execute(command)
|
|
|
|
# 排除两个后只剩t3
|
|
assert result.id == "t3"
|
|
|
|
def test_pick_exclude_all_falls_back(self, mock_repo):
|
|
"""排除全部时从所有标题中选"""
|
|
items = [
|
|
_make_item("t1", "标题1", "文案1", usage_count=1),
|
|
_make_item("t2", "标题2", "文案2", usage_count=2),
|
|
]
|
|
mock_repo.list_by_user.return_value = items
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1", exclude_ids=["t1", "t2"])
|
|
result = use_case.execute(command)
|
|
|
|
# 排除全部后fallback到全部,所以还是能选出一个
|
|
assert result is not None
|
|
assert result.id in ("t1", "t2")
|
|
|
|
def test_pick_single_item(self, mock_repo):
|
|
"""只有一个标题时选它"""
|
|
item = _make_item("only", "唯一标题", "唯一文案", usage_count=10)
|
|
mock_repo.list_by_user.return_value = [item]
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
command = PickTitleCommand(user_id="user_1")
|
|
result = use_case.execute(command)
|
|
|
|
assert result.id == "only"
|
|
|
|
def test_pick_prefers_less_used(self, mock_repo):
|
|
"""倾向于选择使用次数少的"""
|
|
items = [
|
|
_make_item("t_used", "常用", "常用", usage_count=100),
|
|
_make_item("t_fresh", "新的", "新的", usage_count=0),
|
|
]
|
|
mock_repo.list_by_user.return_value = items
|
|
use_case = PickTitleUseCase(mock_repo)
|
|
|
|
# 跑多次,验证使用少的出现在候选池里
|
|
results = set()
|
|
for _ in range(20):
|
|
command = PickTitleCommand(user_id="user_1")
|
|
r = use_case.execute(command)
|
|
if r:
|
|
results.add(r.id)
|
|
|
|
# 两个都在候选池(少于5个),所以都可能被选中
|
|
assert "t_used" in results or "t_fresh" in results
|