c052e857ff
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 2m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m34s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m30s
CI/CD Pipeline / Unit Tests (push) Successful in 5m57s
CI/CD Pipeline / Integration Tests (push) Successful in 3m16s
CI/CD Pipeline / Frontend Lint (push) Successful in 40s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 50s
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 12m4s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
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 56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m37s
453 lines
17 KiB
Python
Executable File
453 lines
17 KiB
Python
Executable File
"""Project 领域模型 + UseCase 单元测试(P3-1 第30波)。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from packages.application.projects import (
|
|
CreateProjectCommand,
|
|
CreateProjectUseCase,
|
|
DeleteProjectUseCase,
|
|
GetProjectUseCase,
|
|
ListProjectsUseCase,
|
|
ShareProjectUseCase,
|
|
UnshareProjectUseCase,
|
|
)
|
|
from packages.domain.entities import Project
|
|
|
|
# ── Project 领域模型 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestProjectDomain:
|
|
"""Project 领域模型单元测试。"""
|
|
|
|
def test_create_project_basic(self):
|
|
"""创建项目基本字段正确。"""
|
|
project = Project.create(owner_user_id="user_001", name="我的项目")
|
|
|
|
assert project.id
|
|
assert project.owner_user_id == "user_001"
|
|
assert project.name == "我的项目"
|
|
assert project.description == ""
|
|
assert project.shared_users == []
|
|
assert project.created_at
|
|
|
|
def test_create_project_with_description(self):
|
|
"""创建项目带描述。"""
|
|
project = Project.create(
|
|
owner_user_id="user_001",
|
|
name="测试项目",
|
|
description="这是一个测试项目",
|
|
)
|
|
|
|
assert project.name == "测试项目"
|
|
assert project.description == "这是一个测试项目"
|
|
|
|
def test_create_project_name_stripped(self):
|
|
"""项目名称首尾空格被清理。"""
|
|
project = Project.create(owner_user_id="user_001", name=" 我的项目 ")
|
|
|
|
assert project.name == "我的项目"
|
|
|
|
def test_create_project_description_stripped(self):
|
|
"""项目描述首尾空格被清理。"""
|
|
project = Project.create(
|
|
owner_user_id="user_001",
|
|
name="测试",
|
|
description=" 描述内容 ",
|
|
)
|
|
|
|
assert project.description == "描述内容"
|
|
|
|
def test_create_project_empty_name_raises(self):
|
|
"""空项目名抛 ValueError。"""
|
|
with pytest.raises(ValueError, match="项目名称不能为空"):
|
|
Project.create(owner_user_id="user_001", name="")
|
|
|
|
def test_create_project_whitespace_name_raises(self):
|
|
"""全空格项目名抛 ValueError。"""
|
|
with pytest.raises(ValueError, match="项目名称不能为空"):
|
|
Project.create(owner_user_id="user_001", name=" ")
|
|
|
|
def test_is_owner_true(self):
|
|
"""is_owner 所有者返回 True。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
|
|
assert project.is_owner("user_001") is True
|
|
|
|
def test_is_owner_false(self):
|
|
"""is_owner 非所有者返回 False。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
|
|
assert project.is_owner("user_002") is False
|
|
|
|
def test_is_shared_with_true(self):
|
|
"""is_shared_with 已共享用户返回 True。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002", "user_003"]
|
|
|
|
assert project.is_shared_with("user_002") is True
|
|
assert project.is_shared_with("user_003") is True
|
|
|
|
def test_is_shared_with_false(self):
|
|
"""is_shared_with 未共享用户返回 False。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
|
|
assert project.is_shared_with("user_004") is False
|
|
|
|
def test_is_shared_with_empty_list(self):
|
|
"""共享列表为空时返回 False。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
|
|
assert project.is_shared_with("user_002") is False
|
|
|
|
def test_can_access_owner(self):
|
|
"""所有者可以访问。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
|
|
assert project.can_access("user_001") is True
|
|
|
|
def test_can_access_shared_user(self):
|
|
"""共享用户可以访问。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
|
|
assert project.can_access("user_002") is True
|
|
|
|
def test_can_access_other_user(self):
|
|
"""其他用户不能访问。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
|
|
assert project.can_access("user_003") is False
|
|
|
|
|
|
# ── ListProjectsUseCase ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestListProjectsUseCase:
|
|
"""ListProjectsUseCase 单元测试。"""
|
|
|
|
def test_list_projects_success(self):
|
|
"""正常列出用户项目。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_accessible_projects.return_value = [
|
|
Project.create(owner_user_id="user_001", name="项目1"),
|
|
Project.create(owner_user_id="user_001", name="项目2"),
|
|
]
|
|
use_case = ListProjectsUseCase(mock_repo)
|
|
|
|
result = use_case.execute("user_001")
|
|
|
|
assert len(result) == 2
|
|
mock_repo.find_accessible_projects.assert_called_once_with("user_001")
|
|
|
|
def test_list_projects_empty(self):
|
|
"""用户没有项目返回空列表。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_accessible_projects.return_value = []
|
|
use_case = ListProjectsUseCase(mock_repo)
|
|
|
|
result = use_case.execute("user_001")
|
|
|
|
assert result == []
|
|
|
|
def test_list_projects_empty_user_id_raises(self):
|
|
"""空 user_id 抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
use_case = ListProjectsUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="user_id 不能为空"):
|
|
use_case.execute("")
|
|
|
|
def test_list_projects_whitespace_user_id_raises(self):
|
|
"""全空格 user_id 抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
use_case = ListProjectsUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="user_id 不能为空"):
|
|
use_case.execute(" ")
|
|
|
|
def test_list_projects_user_id_stripped(self):
|
|
"""user_id 首尾空格被清理。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_accessible_projects.return_value = []
|
|
use_case = ListProjectsUseCase(mock_repo)
|
|
|
|
use_case.execute(" user_001 ")
|
|
|
|
mock_repo.find_accessible_projects.assert_called_once_with("user_001")
|
|
|
|
|
|
# ── GetProjectUseCase ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetProjectUseCase:
|
|
"""GetProjectUseCase 单元测试。"""
|
|
|
|
def test_get_project_success(self):
|
|
"""正常获取项目。"""
|
|
project = Project.create(owner_user_id="user_001", name="测试项目")
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
use_case = GetProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute("proj_001")
|
|
|
|
assert result == project
|
|
mock_repo.find_by_id.assert_called_once_with("proj_001")
|
|
|
|
def test_get_project_not_found(self):
|
|
"""项目不存在返回 None。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = None
|
|
use_case = GetProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute("proj_001")
|
|
|
|
assert result is None
|
|
|
|
def test_get_project_empty_id_raises(self):
|
|
"""空 project_id 抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
use_case = GetProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="project_id 不能为空"):
|
|
use_case.execute("")
|
|
|
|
def test_get_project_whitespace_id_raises(self):
|
|
"""全空格 project_id 抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
use_case = GetProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="project_id 不能为空"):
|
|
use_case.execute(" ")
|
|
|
|
def test_get_project_id_stripped(self):
|
|
"""project_id 首尾空格被清理。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = None
|
|
use_case = GetProjectUseCase(mock_repo)
|
|
|
|
use_case.execute(" proj_001 ")
|
|
|
|
mock_repo.find_by_id.assert_called_once_with("proj_001")
|
|
|
|
|
|
# ── CreateProjectUseCase ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCreateProjectUseCase:
|
|
"""CreateProjectUseCase 单元测试。"""
|
|
|
|
def test_create_project_success(self):
|
|
"""正常创建项目。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = CreateProjectUseCase(mock_repo)
|
|
command = CreateProjectCommand(name="新项目", description="项目描述")
|
|
|
|
result = use_case.execute(command, "user_001")
|
|
|
|
assert result.name == "新项目"
|
|
assert result.description == "项目描述"
|
|
assert result.owner_user_id == "user_001"
|
|
mock_repo.save.assert_called_once()
|
|
|
|
def test_create_project_default_description(self):
|
|
"""不传描述默认为空。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = CreateProjectUseCase(mock_repo)
|
|
command = CreateProjectCommand(name="新项目")
|
|
|
|
result = use_case.execute(command, "user_001")
|
|
|
|
assert result.description == ""
|
|
|
|
def test_create_project_empty_name_raises(self):
|
|
"""空项目名抛 ValueError(领域层校验)。"""
|
|
mock_repo = MagicMock()
|
|
use_case = CreateProjectUseCase(mock_repo)
|
|
command = CreateProjectCommand(name="")
|
|
|
|
with pytest.raises(ValueError, match="项目名称不能为空"):
|
|
use_case.execute(command, "user_001")
|
|
|
|
mock_repo.save.assert_not_called()
|
|
|
|
|
|
# ── ShareProjectUseCase ─────────────────────────────────────────────────────
|
|
|
|
|
|
class TestShareProjectUseCase:
|
|
"""ShareProjectUseCase 单元测试。"""
|
|
|
|
def test_share_project_success(self):
|
|
"""正常共享项目给用户。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = ShareProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute(project.id, "user_001", "user_002")
|
|
|
|
assert "user_002" in result.shared_users
|
|
mock_repo.save.assert_called_once()
|
|
|
|
def test_share_project_already_shared_no_duplicate(self):
|
|
"""已共享用户再次共享不重复添加。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = ShareProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute(project.id, "user_001", "user_002")
|
|
|
|
assert result.shared_users == ["user_002"]
|
|
mock_repo.save.assert_not_called()
|
|
|
|
def test_share_project_not_found_raises(self):
|
|
"""项目不存在抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = None
|
|
use_case = ShareProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="项目不存在"):
|
|
use_case.execute("proj_xxx", "user_001", "user_002")
|
|
|
|
mock_repo.save.assert_not_called()
|
|
|
|
def test_share_project_not_owner_raises(self):
|
|
"""非所有者不能共享项目。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
use_case = ShareProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="只有项目所有者可以共享项目"):
|
|
use_case.execute(project.id, "user_002", "user_003")
|
|
|
|
mock_repo.save.assert_not_called()
|
|
|
|
|
|
# ── UnshareProjectUseCase ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestUnshareProjectUseCase:
|
|
"""UnshareProjectUseCase 单元测试。"""
|
|
|
|
def test_unshare_project_success(self):
|
|
"""正常取消共享。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002", "user_003"]
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = UnshareProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute(project.id, "user_001", "user_002")
|
|
|
|
assert result.shared_users == ["user_003"]
|
|
mock_repo.save.assert_called_once()
|
|
|
|
def test_unshare_project_not_shared_noop(self):
|
|
"""未共享的用户取消共享不操作。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_003"]
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
mock_repo.save.side_effect = lambda p: p
|
|
use_case = UnshareProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute(project.id, "user_001", "user_002")
|
|
|
|
assert result.shared_users == ["user_003"]
|
|
mock_repo.save.assert_not_called()
|
|
|
|
def test_unshare_project_not_found_raises(self):
|
|
"""项目不存在抛 ValueError。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = None
|
|
use_case = UnshareProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="项目不存在"):
|
|
use_case.execute("proj_xxx", "user_001", "user_002")
|
|
|
|
mock_repo.save.assert_not_called()
|
|
|
|
def test_unshare_project_not_owner_raises(self):
|
|
"""非所有者不能取消共享。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
use_case = UnshareProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(ValueError, match="只有项目所有者可以取消共享"):
|
|
use_case.execute(project.id, "user_002", "user_003")
|
|
|
|
mock_repo.save.assert_not_called()
|
|
|
|
|
|
# ── DeleteProjectUseCase ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDeleteProjectUseCase:
|
|
"""DeleteProjectUseCase 单元测试。"""
|
|
|
|
def test_delete_project_success(self):
|
|
"""所有者正常删除项目。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
mock_repo.delete.return_value = True
|
|
use_case = DeleteProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute(project.id, "user_001")
|
|
|
|
assert result is True
|
|
mock_repo.delete.assert_called_once_with(project.id)
|
|
|
|
def test_delete_project_not_found(self):
|
|
"""项目不存在返回 False。"""
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = None
|
|
use_case = DeleteProjectUseCase(mock_repo)
|
|
|
|
result = use_case.execute("proj_xxx", "user_001")
|
|
|
|
assert result is False
|
|
mock_repo.delete.assert_not_called()
|
|
|
|
def test_delete_project_not_owner_raises(self):
|
|
"""非所有者删除抛 PermissionError。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
use_case = DeleteProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(PermissionError, match="只有项目所有者可以删除项目"):
|
|
use_case.execute(project.id, "user_002")
|
|
|
|
mock_repo.delete.assert_not_called()
|
|
|
|
def test_delete_project_shared_user_raises(self):
|
|
"""共享用户不能删除项目。"""
|
|
project = Project.create(owner_user_id="user_001", name="项目")
|
|
project.shared_users = ["user_002"]
|
|
mock_repo = MagicMock()
|
|
mock_repo.find_by_id.return_value = project
|
|
use_case = DeleteProjectUseCase(mock_repo)
|
|
|
|
with pytest.raises(PermissionError, match="只有项目所有者可以删除项目"):
|
|
use_case.execute(project.id, "user_002")
|
|
|
|
mock_repo.delete.assert_not_called()
|