From 14cd89ded509ac0a234c4ab4feeea59d9381fedf Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 2 Oct 2026 11:59:29 +0000 Subject: [PATCH] test: persist account initialization and avatar ownership fixtures (#43199) --- .../services/test_account_avatar_service.py | 51 ++++++++--- .../test_account_initialization_service.py | 85 +++++++++++++------ 2 files changed, 96 insertions(+), 40 deletions(-) diff --git a/api/tests/unit_tests/services/test_account_avatar_service.py b/api/tests/unit_tests/services/test_account_avatar_service.py index 9df586e8b2f..6ee566af3c6 100644 --- a/api/tests/unit_tests/services/test_account_avatar_service.py +++ b/api/tests/unit_tests/services/test_account_avatar_service.py @@ -1,11 +1,12 @@ -from unittest.mock import Mock - import pytest +from pytest_mock import MockerFixture +from sqlalchemy.orm import Session, sessionmaker from machinery.context import RequestContext +from services.account_avatar_file_gateway import SQLAlchemyAccountAvatarFileGateway from services.account_avatar_service import AccountAvatarService from services.account_errors import AvatarFileNotFoundError -from services.account_ports import AccountAvatarFileGateway +from tests.unit_tests.model_factories import make_upload_file def _context() -> RequestContext: @@ -17,30 +18,54 @@ def _context() -> RequestContext: ) -def test_resolve_passes_through_external_avatar_without_calling_gateway() -> None: - files = Mock(spec=AccountAvatarFileGateway) +@pytest.fixture +def files(sqlite_session_factory: sessionmaker[Session]) -> SQLAlchemyAccountAvatarFileGateway: + return SQLAlchemyAccountAvatarFileGateway(session_factory=sqlite_session_factory) + + +def test_resolve_passes_through_external_avatar_without_calling_gateway( + files: SQLAlchemyAccountAvatarFileGateway, + mocker: MockerFixture, +) -> None: + get_owned_url = mocker.spy(files, "get_owned_signed_url") service = AccountAvatarService(files=files) result = service.resolve(_context(), "https://cdn.example/avatar.png") assert result == "https://cdn.example/avatar.png" - files.get_owned_signed_url.assert_not_called() + get_owned_url.assert_not_called() -def test_resolve_returns_owned_signed_url() -> None: - files = Mock(spec=AccountAvatarFileGateway) - files.get_owned_signed_url.return_value = "https://signed.example/avatar" +def test_resolve_returns_owned_signed_url( + files: SQLAlchemyAccountAvatarFileGateway, + sqlite_session_factory: sessionmaker[Session], + mocker: MockerFixture, +) -> None: + with sqlite_session_factory.begin() as session: + session.add(make_upload_file(file_id="file-1")) + get_owned_url = mocker.spy(files, "get_owned_signed_url") + sign_url = mocker.patch( + "services.account_avatar_file_gateway.file_helpers.get_signed_file_url", + return_value="https://signed.example/avatar", + ) service = AccountAvatarService(files=files) result = service.resolve(_context(), "file-1") assert result == "https://signed.example/avatar" - files.get_owned_signed_url.assert_called_once_with(account_id="account-1", upload_file_id="file-1") + sign_url.assert_called_once_with(upload_file_id="file-1") + get_owned_url.assert_called_once_with(account_id="account-1", upload_file_id="file-1") -def test_resolve_rejects_missing_or_unowned_file() -> None: - files = Mock(spec=AccountAvatarFileGateway) - files.get_owned_signed_url.return_value = None +@pytest.mark.parametrize("exists", [False, True]) +def test_resolve_rejects_missing_or_unowned_file( + files: SQLAlchemyAccountAvatarFileGateway, + sqlite_session_factory: sessionmaker[Session], + exists: bool, +) -> None: + if exists: + with sqlite_session_factory.begin() as session: + session.add(make_upload_file(file_id="file-1", created_by="another-account")) service = AccountAvatarService(files=files) with pytest.raises(AvatarFileNotFoundError): diff --git a/api/tests/unit_tests/services/test_account_initialization_service.py b/api/tests/unit_tests/services/test_account_initialization_service.py index 212fc61d149..36877dc9000 100644 --- a/api/tests/unit_tests/services/test_account_initialization_service.py +++ b/api/tests/unit_tests/services/test_account_initialization_service.py @@ -1,25 +1,25 @@ from __future__ import annotations from datetime import datetime -from unittest.mock import Mock import pytest +from pytest_mock import MockerFixture +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker from machinery.context import RequestContext +from models.account import Account, AccountStatus, InvitationCode, InvitationCodeStatus +from repositories.account.repository import SQLAlchemyAccountRepository from services.account_errors import ( AccountAlreadyInitializedError, InvalidInvitationCodeError, MissingInvitationCodeError, ) from services.account_initialization_service import AccountInitializationService -from services.account_ports import AccountRepository from services.entities.account_entities import ( AccountInitialization, - AccountInitializationResult, - AccountInitializationStatus, - AccountSnapshot, ) -from tests.unit_tests.model_factories import make_account_snapshot +from tests.unit_tests.model_factories import make_account def _context() -> RequestContext: @@ -31,17 +31,21 @@ def _context() -> RequestContext: ) -def _account(*, status: str = "uninitialized") -> AccountSnapshot: - return make_account_snapshot(status=status) +@pytest.fixture +def accounts(sqlite_session_factory: sessionmaker[Session]) -> SQLAlchemyAccountRepository: + with sqlite_session_factory.begin() as session: + session.add(make_account(status=AccountStatus.UNINITIALIZED)) + session.add(InvitationCode(batch="test", code="invite-1")) + return SQLAlchemyAccountRepository(sqlite_session_factory) -def test_cloud_initialization_consumes_invitation_and_updates_account_atomically() -> None: +def test_cloud_initialization_consumes_invitation_and_updates_account_atomically( + accounts: SQLAlchemyAccountRepository, + sqlite_session_factory: sessionmaker[Session], + mocker: MockerFixture, +) -> None: initialized_at = datetime(2026, 8, 10, 12, 0) - accounts = Mock(spec=AccountRepository) - accounts.initialize.return_value = AccountInitializationResult( - status=AccountInitializationStatus.INITIALIZED, - account=_account(status="active"), - ) + initialize = mocker.spy(accounts, "initialize") service = AccountInitializationService( accounts=accounts, invitation_required=True, @@ -56,7 +60,7 @@ def test_cloud_initialization_consumes_invitation_and_updates_account_atomically ) assert result.status == "active" - accounts.initialize.assert_called_once_with( + initialize.assert_called_once_with( "account-1", AccountInitialization( interface_language="zh-Hans", @@ -68,9 +72,26 @@ def test_cloud_initialization_consumes_invitation_and_updates_account_atomically workspace_id="workspace-1", ) + with sqlite_session_factory() as session: + invitation = session.scalar(select(InvitationCode).where(InvitationCode.code == "invite-1")) + account = session.get(Account, "account-1") + assert invitation is not None + assert account is not None + assert invitation.status == InvitationCodeStatus.USED + assert invitation.used_at == account.initialized_at == initialized_at + assert invitation.used_by_account_id == account.id + assert invitation.used_by_tenant_id == "workspace-1" + assert account.status == AccountStatus.ACTIVE + assert account.interface_language == "zh-Hans" + assert account.interface_theme == "light" + assert account.timezone == "Asia/Shanghai" -def test_cloud_initialization_rejects_missing_or_invalid_invitation() -> None: - accounts = Mock(spec=AccountRepository) + +def test_cloud_initialization_rejects_missing_or_invalid_invitation( + accounts: SQLAlchemyAccountRepository, + mocker: MockerFixture, +) -> None: + initialize = mocker.spy(accounts, "initialize") service = AccountInitializationService( accounts=accounts, invitation_required=True, @@ -80,20 +101,24 @@ def test_cloud_initialization_rejects_missing_or_invalid_invitation() -> None: with pytest.raises(MissingInvitationCodeError): service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code=None) - accounts.initialize.return_value = AccountInitializationResult( - status=AccountInitializationStatus.INVALID_INVITATION - ) + initialize.assert_not_called() with pytest.raises(InvalidInvitationCodeError): service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code="used") - accounts.initialize.assert_called_once() + initialize.assert_called_once() + account = accounts.get("account-1") + assert account is not None + assert account.status == "uninitialized" -def test_initialization_rejects_an_active_account_before_consuming_invitation() -> None: - accounts = Mock(spec=AccountRepository) - accounts.initialize.return_value = AccountInitializationResult( - status=AccountInitializationStatus.ALREADY_INITIALIZED - ) +def test_initialization_rejects_an_active_account_before_consuming_invitation( + accounts: SQLAlchemyAccountRepository, + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory.begin() as session: + account = session.get(Account, "account-1") + assert account is not None + account.status = AccountStatus.ACTIVE service = AccountInitializationService( accounts=accounts, invitation_required=True, @@ -101,4 +126,10 @@ def test_initialization_rejects_an_active_account_before_consuming_invitation() ) with pytest.raises(AccountAlreadyInitializedError): - service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code="invite") + service.initialize(_context(), interface_language="en-US", timezone="UTC", invitation_code="invite-1") + + with sqlite_session_factory() as session: + invitation = session.scalar(select(InvitationCode).where(InvitationCode.code == "invite-1")) + assert invitation is not None + assert invitation.status == InvitationCodeStatus.UNUSED + assert invitation.used_at is None