"""Tests for the Workspace service. Covers workspace creation/update (name only — whether a family plans solo or together is derived from member count, not a stored field; see `test_allocation_service.py`/`test_allocation_service_branches.py` for the allocator-side "couple wish-assignment gates on parent count" coverage), plus renaming/removing a placeholder partner (`rename_member` / `remove_member`). """ import hashlib from datetime import date, datetime from uuid import UUID, uuid4 import pytest from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.planned_leave import PlannedLeaveDay from app.entities.user import User from app.entities.vacation_days import VacationDays from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.services.workspace_service import WorkspaceService from app.utils.errors import InvitationError, NotFoundError, ValidationError from tests.conftest import SeededIds def _hash(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() async def _make_inviter(user_repo: SQLiteUserRepository, personal_data_uuid: UUID) -> UUID: """`workspace_invitation.invited_by_user_id` FKs to a real `user` row, which itself FKs to a real `personal_data` row — a bare `uuid4()` fails both constraints, so tests that create an invitation attribute it to a user backed by an already-seeded `PersonalData` row (`user.personal_ data_uuid` is UNIQUE, so this only works once per `personal_data_uuid` per test).""" user = await user_repo.create( User( id=uuid4(), personal_data_uuid=personal_data_uuid, email=f"inviter-{uuid4()}@example.com", hashed_password="not-a-real-hash", created_at=datetime.now(), ) ) return user.id class TestWorkspaceUpdate: """Workspace name updates (planning mode is no longer a stored field).""" async def test_update_name( self, workspace_service: WorkspaceService, ) -> None: ws = await workspace_service.create_workspace("Old Name") updated = await workspace_service.update_workspace(ws.id, name="New Name") assert updated.name == "New Name" async def test_update_missing_workspace_raises_not_found( self, workspace_service: WorkspaceService, ) -> None: with pytest.raises(NotFoundError): await workspace_service.update_workspace(uuid4(), name="Whatever") async def test_take_pl_on_red_days_defaults_to_false( self, workspace_service: WorkspaceService, ) -> None: ws = await workspace_service.create_workspace("New Workspace") assert ws.take_pl_on_red_days is False async def test_update_take_pl_on_red_days( self, workspace_service: WorkspaceService, ) -> None: ws = await workspace_service.create_workspace("Workspace") updated = await workspace_service.update_workspace(ws.id, take_pl_on_red_days=True) assert updated.take_pl_on_red_days is True # Persisted, not just returned in-memory -- re-fetch to be sure. refetched = await workspace_service.get_workspace(ws.id) assert refetched.take_pl_on_red_days is True # `None` (the default) means "leave unchanged", mirroring `name`'s # omit-to-keep convention. unchanged = await workspace_service.update_workspace(ws.id, name="Renamed") assert unchanged.take_pl_on_red_days is True class TestRenameMember: """Rename a workspace member's display name (placeholder or self).""" async def test_rename_placeholder_happy_path( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") renamed = await workspace_service.rename_member( seeded_ids.workspace_id, placeholder.uuid, "Samuel" ) assert renamed.name == "Samuel" assert renamed.uuid == placeholder.uuid async def test_rename_own_profile_is_allowed( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: """No other endpoint can fix a typo'd own name, so this route allows it.""" renamed = await workspace_service.rename_member( seeded_ids.workspace_id, seeded_ids.personal_data_uuid, "Corrected Name" ) assert renamed.name == "Corrected Name" async def test_rename_non_member_raises_not_found( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: with pytest.raises(NotFoundError): await workspace_service.rename_member(seeded_ids.workspace_id, uuid4(), "Nope") async def test_rename_member_of_different_workspace_raises_not_found( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: """A real personal_data_uuid from a DIFFERENT workspace must 404, not succeed.""" other_ws = await workspace_service.create_workspace("Other WS") other_placeholder = await workspace_service.add_placeholder_parent(other_ws.id, "Other") with pytest.raises(NotFoundError): await workspace_service.rename_member( seeded_ids.workspace_id, other_placeholder.uuid, "Hijacked" ) async def test_rename_missing_workspace_raises_not_found( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: with pytest.raises(NotFoundError): await workspace_service.rename_member(uuid4(), seeded_ids.personal_data_uuid, "Whoever") async def test_rename_blank_name_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") with pytest.raises(ValidationError, match="empty or whitespace-only"): await workspace_service.rename_member(seeded_ids.workspace_id, placeholder.uuid, " ") async def test_add_placeholder_blank_name_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: """add_placeholder_parent applies the same blank-name rule as rename_member.""" with pytest.raises(ValidationError, match="empty or whitespace-only"): await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, " ") async def test_add_placeholder_beyond_two_parents_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: """A workspace is capped at 2 parents — `seeded_ids.personal_data_uuid` (1) plus one placeholder (2) already fill it; a third is refused.""" await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") with pytest.raises(ValidationError, match="maximum number of parents"): await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Kim") members = await workspace_service.list_members(seeded_ids.workspace_id) assert {m.name for m in members} == {"Test Parent", "Sam"} class TestRemoveMember: """Remove (soft-delete) a placeholder partner, guarding against orphaned data.""" async def test_remove_placeholder_happy_path( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) members = await workspace_service.list_members(seeded_ids.workspace_id) assert placeholder.uuid not in {m.uuid for m in members} async def test_remove_non_member_raises_not_found( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: with pytest.raises(NotFoundError): await workspace_service.remove_member( seeded_ids.workspace_id, uuid4(), seeded_ids.personal_data_uuid ) async def test_remove_self_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, ) -> None: with pytest.raises(ValidationError, match="own profile"): await workspace_service.remove_member( seeded_ids.workspace_id, seeded_ids.personal_data_uuid, seeded_ids.personal_data_uuid, ) async def test_remove_member_with_login_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: """A member with their own login is not actually a placeholder.""" placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await user_repo.create( User( id=uuid4(), personal_data_uuid=placeholder.uuid, email="sam@example.com", hashed_password="hash", created_at=datetime.now(), ) ) with pytest.raises(ValidationError, match="own login"): await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) async def test_remove_member_with_leave_wish_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, leave_wish_repo: SQLiteLeaveWishRepository, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await leave_wish_repo.create( LeaveWish( id=uuid4(), workspace_id=seeded_ids.workspace_id, child_id=None, name="Sommar", window_start_month=6, window_start_day=1, window_end_month=8, window_end_day=31, target_weeks=4, first_year=2026, last_year=None, priority=1, parent_mode="specific", parent_personal_data_uuid=placeholder.uuid, ) ) with pytest.raises(ValidationError, match="leave_wishes"): await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) async def test_remove_member_with_planned_leave_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, planned_leave_repo: SQLitePlannedLeaveRepository, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=placeholder.uuid, date=date(2026, 6, 1), day_type="sjukpenning", ) ) with pytest.raises(ValidationError, match="planned_leave"): await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) async def test_remove_member_with_parental_leave_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, parental_leave_repo: SQLiteParentalLeaveRepository, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await parental_leave_repo.create( ParentalLeaveFetch( id=uuid4(), personal_data_uuid=placeholder.uuid, workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, source="manual", fetched_at=datetime.now(), total_days=480, days_used=0, days_left=480, reserved_days_total=90, reserved_days_left=90, transferable_days_left=390, dubbeldagar_left=60, tier_data=TierData( sjukpenning_total=390, sjukpenning_used=0, sjukpenning_left=390, lagsta_niva_total=90, lagsta_niva_used=0, lagsta_niva_left=90, ), ) ) with pytest.raises(ValidationError, match="parental_leave"): await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) async def test_remove_member_with_vacation_days_raises_validation_error( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, vacation_repo: SQLiteVacationDaysRepository, ) -> None: placeholder = await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Sam") await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=placeholder.uuid, workspace_id=seeded_ids.workspace_id, year=2026, total_days=25, ) ) with pytest.raises(ValidationError, match="vacation_days"): await workspace_service.remove_member( seeded_ids.workspace_id, placeholder.uuid, seeded_ids.personal_data_uuid ) class TestInvitationScoping: """F2 (T1 authz audit): self-invitation used to grant full membership of any workspace whose id was known, and `revoke_invitation` was unscoped. """ async def test_revoke_invitation_from_a_different_workspace_raises_not_found( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: """An invitation belonging to workspace B must not be revocable by naming workspace A — closes F6/part of F2 (`revoke_invitation` gained a `workspace_id` argument).""" other_ws = await workspace_service.create_workspace("Other WS") token = await workspace_service.invite_partner( other_ws.id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) ) inv = await workspace_service._inv.get_by_token_hash(_hash(token)) assert inv is not None # `seeded_ids.workspace_id` is NOT where the invitation lives. with pytest.raises(NotFoundError): await workspace_service.revoke_invitation(inv.id, seeded_ids.workspace_id) async def test_revoke_invitation_from_its_own_workspace_succeeds( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: token = await workspace_service.invite_partner( seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) ) inv = await workspace_service._inv.get_by_token_hash(_hash(token)) assert inv is not None await workspace_service.revoke_invitation(inv.id, seeded_ids.workspace_id) refreshed = await workspace_service._inv.get_by_id(inv.id) assert refreshed is not None assert refreshed.status == "revoked" async def test_redeem_refuses_when_already_a_member_of_the_target_workspace( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: """The exploit from F2: a caller who is ALREADY a member of a workspace could invite themselves and "redeem" their own invite, confirming nothing new but exercising a codepath that must still be closed for a caller re-redeeming while already a member.""" token = await workspace_service.invite_partner( seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) ) with pytest.raises(InvitationError, match="already belong"): await workspace_service.redeem_invitation(token, seeded_ids.personal_data_uuid) async def test_redeem_refuses_when_workspace_already_has_two_members( self, workspace_service: WorkspaceService, seeded_ids: SeededIds, user_repo: SQLiteUserRepository, ) -> None: # Fill the target workspace to its 2-member cap first. await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner") other_ws = await workspace_service.create_workspace("Other WS") other_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer") token = await workspace_service.invite_partner( seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid) ) with pytest.raises(InvitationError, match="maximum number of members"): await workspace_service.redeem_invitation(token, other_pd.uuid)