"""Integration tests for activity match service (activity_match_service.py). Tests cover: resolve_activity_stage, create_activity_choice, cancel_activity_choice, list_activity_candidates, list_my_activity_choices, list_activity_liked_me, list_activity_mutual_matches, get_activity_match_detail. """ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import patch, AsyncMock import pytest import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession from app.models.activity import Activity from app.models.activity_guest_choice import ActivityGuestChoice from app.models.registration import Registration from app.models.user import User from app.services.activity_match_service import ( cancel_activity_choice, create_activity_choice, get_activity_match_detail, list_activity_candidates, list_activity_liked_me, list_activity_mutual_matches, list_my_activity_choices, resolve_activity_stage, ) from tests.conftest import register_user # Patch sanitize_user_media_payload to skip filesystem checks @pytest.fixture(autouse=True) def _mock_sanitize(): with patch( "app.services.activity_match_service.sanitize_user_media_payload", side_effect=lambda d: d, ): yield # ============================================================================ # resolve_activity_stage # ============================================================================ class TestResolveActivityStage: def test_before_start(self): activity = Activity( start_time=datetime.now() + timedelta(hours=1), end_time=datetime.now() + timedelta(hours=5), match_deadline=datetime.now() + timedelta(hours=48), ) assert resolve_activity_stage(activity) == "before_start" def test_matching_open(self): activity = Activity( start_time=datetime.now() - timedelta(hours=1), end_time=datetime.now() + timedelta(hours=5), match_deadline=datetime.now() + timedelta(hours=48), ) assert resolve_activity_stage(activity) == "matching_open" def test_matching_closed(self): activity = Activity( start_time=datetime.now() - timedelta(hours=1), end_time=datetime.now() + timedelta(hours=5), match_deadline=datetime.now() - timedelta(hours=1), ) assert resolve_activity_stage(activity) == "matching_closed" def test_ended(self): activity = Activity( start_time=datetime.now() - timedelta(days=2), end_time=datetime.now() - timedelta(days=1), match_deadline=datetime.now() - timedelta(hours=12), ) assert resolve_activity_stage(activity) == "ended" def test_no_deadline_still_open(self): activity = Activity( start_time=datetime.now() - timedelta(hours=1), end_time=datetime.now() + timedelta(hours=5), match_deadline=None, ) assert resolve_activity_stage(activity) == "matching_open" # ============================================================================ # list_activity_candidates # ============================================================================ class TestListActivityCandidates: @pytest.mark.asyncio async def test_returns_opposite_gender( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) items = await list_activity_candidates(db_session, activity_open, user_male_a) user_ids = [item["user_id"] for item in items] assert user_female_b.id in user_ids assert user_male_a.id not in user_ids @pytest.mark.asyncio async def test_excludes_unregistered_users( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) # B is NOT registered items = await list_activity_candidates(db_session, activity_open, user_male_a) user_ids = [item["user_id"] for item in items] assert user_female_b.id not in user_ids @pytest.mark.asyncio async def test_before_start_rejected( self, db_session: AsyncSession, user_male_a: User, activity_before_start: Activity, ): await register_user(db_session, user_male_a, activity_before_start) with pytest.raises(PermissionError, match="活动开始前"): await list_activity_candidates(db_session, activity_before_start, user_male_a) @pytest.mark.asyncio async def test_unregistered_user_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_female_b, activity_open) # A is NOT registered with pytest.raises(PermissionError, match="请先绑定"): await list_activity_candidates(db_session, activity_open, user_male_a) @pytest.mark.asyncio async def test_selected_flag( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) # A selects B await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) items = await list_activity_candidates(db_session, activity_open, user_male_a) b_item = next(i for i in items if i["user_id"] == user_female_b.id) assert b_item["selected"] is True # ============================================================================ # create_activity_choice # ============================================================================ class TestCreateActivityChoice: @pytest.mark.asyncio async def test_basic_selection( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) result = await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) assert result["selected_count"] == 1 assert result["remaining_count"] == 2 assert result["is_mutual"] is False @pytest.mark.asyncio async def test_mutual_selection( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) result = await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) assert result["is_mutual"] is True assert result["match_user_id"] == user_male_a.id @pytest.mark.asyncio async def test_self_selection_rejected( self, db_session: AsyncSession, user_male_a: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) with pytest.raises(ValueError, match="不能选择自己"): await create_activity_choice(db_session, activity_open, user_male_a, user_male_a) @pytest.mark.asyncio async def test_same_gender_rejected( self, db_session: AsyncSession, user_male_a: User, user_male_c: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_male_c, activity_open) with pytest.raises(ValueError, match="异性"): await create_activity_choice(db_session, activity_open, user_male_a, user_male_c) @pytest.mark.asyncio async def test_unregistered_target_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) # B is NOT registered with pytest.raises(PermissionError, match="请先绑定"): await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) @pytest.mark.asyncio async def test_selection_limit_enforced( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, user_female_d: User, activity_open: Activity, ): # activity_open has selection_limit=3 await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await register_user(db_session, user_female_d, activity_open) # Create a third female user for the limit test user_female_e = User( id=5, openid="openid_female_e", nickname="小周", gender=2, birth_year=1996, audit_status=2, is_active=1, updated_at=datetime.now(), ) db_session.add(user_female_e) await db_session.flush() await register_user(db_session, user_female_e, activity_open) user_female_f = User( id=6, openid="openid_female_f", nickname="小吴", gender=2, birth_year=1998, audit_status=2, is_active=1, updated_at=datetime.now(), ) db_session.add(user_female_f) await db_session.flush() await register_user(db_session, user_female_f, activity_open) # Select 3 users (the limit) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) await create_activity_choice(db_session, activity_open, user_male_a, user_female_d) await create_activity_choice(db_session, activity_open, user_male_a, user_female_e) # 4th should fail with pytest.raises(ValueError, match="可选人数已达上限"): await create_activity_choice(db_session, activity_open, user_male_a, user_female_f) @pytest.mark.asyncio async def test_duplicate_selection_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) with pytest.raises(ValueError, match="已选择该嘉宾"): await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) @pytest.mark.asyncio async def test_closed_stage_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_closed: Activity, ): await register_user(db_session, user_male_a, activity_closed) await register_user(db_session, user_female_b, activity_closed) with pytest.raises(ValueError, match="已锁定"): await create_activity_choice(db_session, activity_closed, user_male_a, user_female_b) # ============================================================================ # cancel_activity_choice # ============================================================================ class TestCancelActivityChoice: @pytest.mark.asyncio async def test_cancel_choice( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) result = await cancel_activity_choice(db_session, activity_open, user_male_a, user_female_b.id) assert result["selected_count"] == 0 assert result["remaining_count"] == 3 @pytest.mark.asyncio async def test_cancel_nonexistent_choice( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) with pytest.raises(ValueError, match="未找到"): await cancel_activity_choice(db_session, activity_open, user_male_a, user_female_b.id) @pytest.mark.asyncio async def test_cancel_and_reselect( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) await cancel_activity_choice(db_session, activity_open, user_male_a, user_female_b.id) # Re-select should work result = await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) assert result["selected_count"] == 1 @pytest.mark.asyncio async def test_cancel_on_closed_stage_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_closed: Activity, ): await register_user(db_session, user_male_a, activity_closed) await register_user(db_session, user_female_b, activity_closed) # Add a choice directly to DB for testing cancel on closed stage choice = ActivityGuestChoice( activity_id=activity_closed.id, from_user_id=user_male_a.id, to_user_id=user_female_b.id, status=1, ) db_session.add(choice) await db_session.flush() with pytest.raises(ValueError, match="已锁定"): await cancel_activity_choice(db_session, activity_closed, user_male_a, user_female_b.id) # ============================================================================ # list_my_activity_choices # ============================================================================ class TestListMyActivityChoices: @pytest.mark.asyncio async def test_lists_selected_users( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) items = await list_my_activity_choices(db_session, activity_open, user_male_a) assert len(items) == 1 assert items[0]["user_id"] == user_female_b.id assert items[0]["match_status"] == "pending" @pytest.mark.asyncio async def test_shows_mutual_status( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) items = await list_my_activity_choices(db_session, activity_open, user_male_a) assert len(items) == 1 assert items[0]["match_status"] == "success" assert items[0]["can_view_detail"] is True # ============================================================================ # list_activity_liked_me # ============================================================================ class TestListActivityLikedMe: @pytest.mark.asyncio async def test_lists_who_chose_me( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) items = await list_activity_liked_me(db_session, activity_open, user_male_a) assert len(items) == 1 assert items[0]["user_id"] == user_female_b.id assert items[0]["match_status"] == "pending" # ============================================================================ # list_activity_mutual_matches # ============================================================================ class TestListActivityMutualMatches: @pytest.mark.asyncio async def test_only_mutual( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, user_female_d: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await register_user(db_session, user_female_d, activity_open) # Mutual with B await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) # One-way with D await create_activity_choice(db_session, activity_open, user_male_a, user_female_d) items = await list_activity_mutual_matches(db_session, activity_open, user_male_a) assert len(items) == 1 assert items[0]["user_id"] == user_female_b.id assert items[0]["match_status"] == "success" # ============================================================================ # get_activity_match_detail # ============================================================================ class TestGetActivityMatchDetail: @pytest.mark.asyncio async def test_mutual_can_view_detail( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) detail = await get_activity_match_detail( db_session, activity_open, user_male_a, user_female_b.id ) assert detail is not None assert detail["target_user_id"] == user_female_b.id other = detail["other_user"] # Level-3 fields assert "job_industry" in other assert "self_intro" in other @pytest.mark.asyncio async def test_non_mutual_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) # Only A selects B, no mutual await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) with pytest.raises(PermissionError, match="互选成功"): await get_activity_match_detail( db_session, activity_open, user_male_a, user_female_b.id ) @pytest.mark.asyncio async def test_unregistered_user_rejected( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): # A is NOT registered await register_user(db_session, user_female_b, activity_open) with pytest.raises(PermissionError, match="请先绑定"): await get_activity_match_detail( db_session, activity_open, user_male_a, user_female_b.id ) # ============================================================================ # Full flow: register → select → mutual → view detail # ============================================================================ class TestFullActivityMatchFlow: @pytest.mark.asyncio async def test_end_to_end( self, db_session: AsyncSession, user_male_a: User, user_female_b: User, activity_open: Activity, ): # Step 1: Both register await register_user(db_session, user_male_a, activity_open) await register_user(db_session, user_female_b, activity_open) # Step 2: View candidates candidates = await list_activity_candidates(db_session, activity_open, user_male_a) assert len(candidates) == 1 assert candidates[0]["user_id"] == user_female_b.id assert candidates[0]["selected"] is False # Step 3: A selects B result = await create_activity_choice(db_session, activity_open, user_male_a, user_female_b) assert result["is_mutual"] is False assert result["selected_count"] == 1 # Step 4: Check my-choices my_choices = await list_my_activity_choices(db_session, activity_open, user_male_a) assert len(my_choices) == 1 assert my_choices[0]["match_status"] == "pending" # Step 5: Check liked-me for B liked_me = await list_activity_liked_me(db_session, activity_open, user_female_b) assert len(liked_me) == 1 assert liked_me[0]["user_id"] == user_male_a.id # Step 6: B selects A → mutual result = await create_activity_choice(db_session, activity_open, user_female_b, user_male_a) assert result["is_mutual"] is True # Step 7: Mutual matches list mutual = await list_activity_mutual_matches(db_session, activity_open, user_male_a) assert len(mutual) == 1 assert mutual[0]["match_status"] == "success" # Step 8: View match detail detail = await get_activity_match_detail( db_session, activity_open, user_male_a, user_female_b.id ) assert detail is not None assert detail["other_user"]["nickname"] == "小李"