from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest from app.application.site.contract import SiteMutation, SitePriorityMutation from app.application.site.mutation import SiteMutationCommand from app.schemas.site import Site def _command(**overrides): """构造可观察站点写用例及其依赖。""" repository = Mock() repository.get_by_id = AsyncMock(return_value=object()) repository.async_get_by_domain = AsyncMock(return_value=None) repository.stage_create = AsyncMock() repository.stage_update = AsyncMock(return_value=True) repository.stage_delete = AsyncMock() repository.stage_priorities = AsyncMock() unit_of_work = Mock() unit_of_work.commit = AsyncMock() unit_of_work.rollback = AsyncMock() dependencies = { "repository": repository, "unit_of_work": unit_of_work, "auth_level_provider": Mock(return_value=2), "indexer_loader": AsyncMock(return_value={"name": "Demo", "public": True}), "domain_extractor": lambda value: "demo.example", "url_normalizer": lambda value: "https://demo.example/", "publish_updated": AsyncMock(), "publish_deleted": AsyncMock(), } dependencies.update(overrides) return SiteMutationCommand(**dependencies), dependencies @pytest.mark.asyncio async def test_create_site_commits_before_updated_event(): """新增站点必须先提交,再发布站点更新事件。""" calls = [] command, dependencies = _command( unit_of_work=Mock( commit=AsyncMock(side_effect=lambda: calls.append("commit")), rollback=AsyncMock(), ), publish_updated=AsyncMock(side_effect=lambda _payload: calls.append("event")), ) result = await command.create({"url": "https://demo.example/path"}) assert result.success is True assert calls == ["commit", "event"] mutation = dependencies["repository"].stage_create.await_args.args[0] assert isinstance(mutation, SiteMutation) assert mutation.values["domain"] == "demo.example" assert mutation.values["url"] == "https://demo.example/" assert mutation.values["name"] == "Demo" assert mutation.values["public"] == 1 @pytest.mark.asyncio async def test_create_site_ignores_schema_identity_field(): """手动新增站点应忽略请求 Schema 自动补出的空主键。""" command, dependencies = _command() result = await command.create( Site(url="https://demo.example/path").model_dump() ) assert result.success is True mutation = dependencies["repository"].stage_create.await_args.args[0] assert "id" not in mutation.values @pytest.mark.asyncio async def test_update_site_returns_legacy_not_found_without_writes(): """更新不存在站点时保持失败响应且不产生事务或事件。""" repository = Mock() repository.get_by_id = AsyncMock(return_value=None) command, dependencies = _command(repository=repository) result = await command.update({"id": 7, "url": "https://demo.example"}) assert result.success is False assert result.message == "站点不存在" dependencies["unit_of_work"].commit.assert_not_awaited() dependencies["publish_updated"].assert_not_awaited() @pytest.mark.asyncio async def test_set_cookie_updates_only_cookie_fields_after_commit(): """浏览器 Cookie 精细写入不得覆盖其他站点配置,并须先提交再发事件。""" site = SimpleNamespace( domain="demo.example", name="Demo", url="https://demo.example/", ) calls = [] repository = Mock() repository.get_by_id = AsyncMock(return_value=site) repository.stage_update = AsyncMock() command, dependencies = _command( repository=repository, unit_of_work=Mock( commit=AsyncMock(side_effect=lambda: calls.append("commit")), rollback=AsyncMock(), ), publish_updated=AsyncMock(side_effect=lambda _payload: calls.append("event")), ) result = await command.set_cookie(7, "sid=browser", "Browser UA") assert result.success is True assert calls == ["commit", "event"] mutation = repository.stage_update.await_args.args[1] assert isinstance(mutation, SiteMutation) assert mutation.values == {"cookie": "sid=browser", "ua": "Browser UA"} dependencies["publish_updated"].assert_awaited_once_with( { "site_id": 7, "domain": "demo.example", "name": "Demo", "site_url": "https://demo.example/", } ) @pytest.mark.asyncio async def test_delete_site_commit_failure_rolls_back_without_event(): """删除提交失败时必须回滚且不得发送 SiteDeleted。""" unit_of_work = Mock() unit_of_work.commit = AsyncMock(side_effect=RuntimeError("commit failed")) unit_of_work.rollback = AsyncMock() command, dependencies = _command(unit_of_work=unit_of_work) with pytest.raises(RuntimeError, match="commit failed"): await command.delete(7) unit_of_work.rollback.assert_awaited_once_with() dependencies["publish_deleted"].assert_not_awaited() @pytest.mark.asyncio async def test_update_priorities_uses_one_transaction(): """批量站点优先级必须由一个请求级事务统一提交。""" command, dependencies = _command() priorities = [{"id": 1, "pri": 2}, {"id": 2, "pri": 1}] result = await command.update_priorities(priorities) assert result.success is True dependencies["repository"].stage_priorities.assert_awaited_once_with( ( SitePriorityMutation(site_id=1, priority=2), SitePriorityMutation(site_id=2, priority=1), ) ) dependencies["unit_of_work"].commit.assert_awaited_once_with()