diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 04d7c44..40ec61e 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -237,8 +237,22 @@ def get_workspace_service( ws_repo: WorkspaceRepositoryDep, inv_repo: WorkspaceInvitationRepositoryDep, pd_repo: PersonalDataRepositoryDep, + user_repo: UserRepositoryDep, + leave_wish_repo: LeaveWishRepositoryDep, + planned_leave_repo: PlannedLeaveRepositoryDep, + parental_leave_repo: ParentalLeaveRepositoryDep, + vacation_days_repo: VacationDaysRepositoryDep, ) -> WorkspaceService: - return WorkspaceService(ws_repo, inv_repo, pd_repo) + return WorkspaceService( + ws_repo, + inv_repo, + pd_repo, + user_repo, + leave_wish_repo, + planned_leave_repo, + parental_leave_repo, + vacation_days_repo, + ) WorkspaceServiceDep = Annotated[WorkspaceService, Depends(get_workspace_service)] diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index b5e4ba4..e329319 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -47,6 +47,11 @@ class PersonalDataResponse(BaseModel): class PersonalDataCreate(BaseModel): + """Request body for both adding a placeholder parent (POST) and renaming + + one (PATCH) — both take exactly one field, so one schema serves both. + """ + display_name: str = Field(min_length=1, max_length=100) diff --git a/backend/app/routers/workspace.py b/backend/app/routers/workspace.py index adb674f..bb84d1f 100644 --- a/backend/app/routers/workspace.py +++ b/backend/app/routers/workspace.py @@ -5,6 +5,7 @@ from uuid import UUID from fastapi import APIRouter, HTTPException, status from app.dependencies import CurrentUserDep, WorkspaceServiceDep +from app.entities.personal_data import PersonalData from app.models.workspace import ( InvitationResponse, InviteResponse, @@ -20,6 +21,35 @@ from app.utils.errors import InvitationError, NotFoundError, ValidationError router = APIRouter(prefix="/api/workspace", tags=["workspace"]) +def _to_response(pd: PersonalData) -> PersonalDataResponse: + return PersonalDataResponse( + uuid=pd.uuid, + workspace_id=pd.workspace_id, + name=pd.name, + salary_type=pd.salary_type, + employment_status=pd.employment_status, + created_at=pd.created_at, + ) + + +def _to_http_error(exc: NotFoundError | ValidationError) -> HTTPException: + """Map a NotFoundError/ValidationError to an HTTPException. + + Both carry an optional `message_key` + `data` pair (§3.5); when present it + becomes the structured `detail` so the frontend never has to parse an + English sentence. Falls back to the plain message for the (rare) error + raised without one. + """ + code = ( + status.HTTP_404_NOT_FOUND + if isinstance(exc, NotFoundError) + else status.HTTP_422_UNPROCESSABLE_ENTITY + ) + if exc.message_key: + return HTTPException(code, detail={"message_key": exc.message_key, "data": exc.data}) + return HTTPException(code, detail=str(exc)) + + @router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED) async def create_workspace( payload: WorkspaceCreate, @@ -71,17 +101,7 @@ async def list_members( _current_user: CurrentUserDep, ) -> list[PersonalDataResponse]: members = await service.list_members(workspace_id) - return [ - PersonalDataResponse( - uuid=m.uuid, - workspace_id=m.workspace_id, - name=m.name, - salary_type=m.salary_type, - employment_status=m.employment_status, - created_at=m.created_at, - ) - for m in members - ] + return [_to_response(m) for m in members] @router.post( @@ -97,16 +117,45 @@ async def add_placeholder_parent( ) -> PersonalDataResponse: try: pd = await service.add_placeholder_parent(workspace_id, payload.display_name) - except NotFoundError as exc: - raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc - return PersonalDataResponse( - uuid=pd.uuid, - workspace_id=pd.workspace_id, - name=pd.name, - salary_type=pd.salary_type, - employment_status=pd.employment_status, - created_at=pd.created_at, - ) + except (NotFoundError, ValidationError) as exc: + raise _to_http_error(exc) from exc + return _to_response(pd) + + +@router.patch( + "/{workspace_id}/parents/{personal_data_uuid}", + response_model=PersonalDataResponse, +) +async def rename_parent( + workspace_id: UUID, + personal_data_uuid: UUID, + payload: PersonalDataCreate, + service: WorkspaceServiceDep, + _current_user: CurrentUserDep, +) -> PersonalDataResponse: + try: + pd = await service.rename_member(workspace_id, personal_data_uuid, payload.display_name) + except (NotFoundError, ValidationError) as exc: + raise _to_http_error(exc) from exc + return _to_response(pd) + + +@router.delete( + "/{workspace_id}/parents/{personal_data_uuid}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_parent( + workspace_id: UUID, + personal_data_uuid: UUID, + service: WorkspaceServiceDep, + current_user: CurrentUserDep, +) -> None: + try: + await service.remove_member( + workspace_id, personal_data_uuid, current_user.personal_data_uuid + ) + except (NotFoundError, ValidationError) as exc: + raise _to_http_error(exc) from exc @router.post( diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index 2b58d21..bc1fbe4 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -17,7 +17,12 @@ from uuid import UUID, uuid4 from app.entities.personal_data import PersonalData from app.entities.workspace import Workspace from app.entities.workspace_invitation import WorkspaceInvitation +from app.repositories.leave_wish_repository import LeaveWishRepository +from app.repositories.parental_leave_repository import ParentalLeaveRepository from app.repositories.personal_data_repository import PersonalDataRepository +from app.repositories.planned_leave_repository import PlannedLeaveRepository +from app.repositories.user_repository import UserRepository +from app.repositories.vacation_days_repository import VacationDaysRepository from app.repositories.workspace_repository import ( WorkspaceInvitationRepository, WorkspaceRepository, @@ -33,10 +38,20 @@ class WorkspaceService: ws_repo: WorkspaceRepository, inv_repo: WorkspaceInvitationRepository, pd_repo: PersonalDataRepository, + user_repo: UserRepository, + leave_wish_repo: LeaveWishRepository, + planned_leave_repo: PlannedLeaveRepository, + parental_leave_repo: ParentalLeaveRepository, + vacation_days_repo: VacationDaysRepository, ) -> None: self._ws = ws_repo self._inv = inv_repo self._pd = pd_repo + self._user = user_repo + self._leave_wish = leave_wish_repo + self._planned_leave = planned_leave_repo + self._parental_leave = parental_leave_repo + self._vacation_days = vacation_days_repo # ------------------------------------------------------------------ # Workspace CRUD @@ -49,7 +64,11 @@ class WorkspaceService: async def get_workspace(self, workspace_id: UUID) -> Workspace: ws = await self._ws.get_by_id(workspace_id) if ws is None: - raise NotFoundError(f"Workspace {workspace_id} not found") + raise NotFoundError( + f"Workspace {workspace_id} not found", + message_key="workspace.notFound", + data={"workspace_id": str(workspace_id)}, + ) return ws async def update_workspace( @@ -80,6 +99,7 @@ class WorkspaceService: async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData: """Create a parent profile without a login (solo planning for a partner).""" + _validate_display_name(display_name) await self.get_workspace(workspace_id) # verify workspace exists pd = PersonalData( uuid=uuid4(), @@ -89,6 +109,123 @@ class WorkspaceService: ) return await self._pd.create(pd) + async def rename_member( + self, workspace_id: UUID, personal_data_uuid: UUID, display_name: str + ) -> PersonalData: + """Rename a workspace member's display name. + + Allowed for the placeholder partner and for the logged-in user's own + profile alike — there is no other endpoint that can correct a typo'd + name (the settings profile-update path only covers salary_type / + employment_status / min_vacation_days_per_year), so this is the one + place a name gets fixed. + """ + _validate_display_name(display_name) + pd = await self._get_member(workspace_id, personal_data_uuid) + pd.name = display_name + return await self._pd.update(personal_data_uuid, pd) + + async def remove_member( + self, + workspace_id: UUID, + personal_data_uuid: UUID, + requesting_personal_data_uuid: UUID, + ) -> None: + """Soft-delete a placeholder partner from a workspace. + + Refuses (ValidationError, 422 at the router) when: + - the target is the caller's own profile, + - the target has its own login (it is not actually a "placeholder" + — e.g. a real partner who redeemed an invite into this workspace), + - the target still has dependent plan data (leave wishes, planned + leave days, parental-leave rows, or vacation-days rows) that would + be orphaned. + """ + await self._get_member(workspace_id, personal_data_uuid) # 404 if not a member + + if personal_data_uuid == requesting_personal_data_uuid: + raise ValidationError( + "Cannot remove your own profile from the workspace", + message_key="workspace.parents.remove.self", + data={}, + ) + + if await self._has_login(personal_data_uuid): + raise ValidationError( + "Cannot remove a member that has their own login", + message_key="workspace.parents.remove.hasLogin", + data={}, + ) + + blockers = await self._dependent_data_blockers(personal_data_uuid) + if blockers: + raise ValidationError( + f"Cannot remove member with existing {', '.join(blockers)}", + message_key="workspace.parents.remove.hasDependentData", + data={"blockers": blockers}, + ) + + await self._pd.soft_delete(personal_data_uuid) + + async def _get_member(self, workspace_id: UUID, personal_data_uuid: UUID) -> PersonalData: + """Return the member, raising NotFoundError if the workspace or membership is missing. + + Deliberately checks membership via `list_by_workspace` rather than a bare + `pd_repo.get_by_id` — a `personal_data_uuid` that exists but belongs to a + *different* workspace must 404 here, not succeed. + """ + ws = await self._ws.get_by_id(workspace_id) + if ws is None: + raise NotFoundError( + f"Workspace {workspace_id} not found", + message_key="workspace.notFound", + data={"workspace_id": str(workspace_id)}, + ) + members = await self._pd.list_by_workspace(workspace_id) + for member in members: + if member.uuid == personal_data_uuid: + return member + raise NotFoundError( + f"PersonalData {personal_data_uuid} is not a member of workspace {workspace_id}", + message_key="workspace.parents.notFound", + data={"personal_data_uuid": str(personal_data_uuid)}, + ) + + async def _has_login(self, personal_data_uuid: UUID) -> bool: + """True if a User account is linked to this personal_data_uuid. + + A genuine placeholder (created via `add_placeholder_parent`) never has + one; a partner who registered and redeemed an invite into the same + workspace does — removing them here would silently break their login. + """ + users = await self._user.list() + return any(u.personal_data_uuid == personal_data_uuid for u in users) + + async def _dependent_data_blockers(self, personal_data_uuid: UUID) -> list[str]: + """Return the kinds of plan data that would be orphaned by removing this member.""" + blockers: list[str] = [] + + # LeaveWishRepository.list() only recognizes workspace_id/child_id + # filters today — passing parent_personal_data_uuid would be silently + # ignored rather than applied (returning every wish in every + # workspace unfiltered). Fetch unfiltered and filter in Python + # instead, so an unsupported filter can never be mistaken for "no + # wishes for this parent". + all_wishes = await self._leave_wish.list() + if any(w.parent_personal_data_uuid == personal_data_uuid for w in all_wishes): + blockers.append("leave_wishes") + + if await self._planned_leave.list(parent_personal_data_uuid=personal_data_uuid): + blockers.append("planned_leave") + + if await self._parental_leave.list(personal_data_uuid=personal_data_uuid): + blockers.append("parental_leave") + + if await self._vacation_days.list(personal_data_uuid=personal_data_uuid): + blockers.append("vacation_days") + + return blockers + # ------------------------------------------------------------------ # Invitation flow # ------------------------------------------------------------------ @@ -152,3 +289,16 @@ class WorkspaceService: def _sha256(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() + + +def _validate_display_name(display_name: str) -> None: + """Shared validation for add_placeholder_parent and rename_member so the + two paths agree on what counts as a valid name (Pydantic's `min_length=1` + on `display_name` does not reject a whitespace-only string). + """ + if not display_name.strip(): + raise ValidationError( + "display_name must not be empty or whitespace-only", + message_key="workspace.parents.invalidName", + data={}, + ) diff --git a/backend/app/utils/errors.py b/backend/app/utils/errors.py index 7d254db..03d3298 100644 --- a/backend/app/utils/errors.py +++ b/backend/app/utils/errors.py @@ -10,11 +10,41 @@ class DomainError(Exception): class NotFoundError(DomainError): - """Raised when an entity cannot be found by its identifier.""" + """Raised when an entity cannot be found by its identifier. + + May optionally carry a `message_key` + `data` pair (§3.5) so a router can + surface a structured, i18n-ready refusal instead of a hardcoded string — + see `DubbeldagError` for the established shape. Existing call sites that + only pass `message` are unaffected: `message_key` defaults to `""`. + """ + + def __init__( + self, + message: str, + message_key: str = "", + data: dict[str, object] | None = None, + ) -> None: + super().__init__(message) + self.message_key = message_key + self.data: dict[str, object] = data if data is not None else {} class ValidationError(DomainError): - """Raised when input fails a business rule (not just schema validation).""" + """Raised when input fails a business rule (not just schema validation). + + May optionally carry a `message_key` + `data` pair (§3.5); see + `NotFoundError` above for the same shape and rationale. + """ + + def __init__( + self, + message: str, + message_key: str = "", + data: dict[str, object] | None = None, + ) -> None: + super().__init__(message) + self.message_key = message_key + self.data: dict[str, object] = data if data is not None else {} class InsufficientDaysError(DomainError): diff --git a/backend/scripts/seed_demo.py b/backend/scripts/seed_demo.py index 7a92d8e..cbd6a3c 100644 --- a/backend/scripts/seed_demo.py +++ b/backend/scripts/seed_demo.py @@ -122,7 +122,16 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices: return DemoServices( auth=AuthService(users, personal_data, workspaces), - workspace=WorkspaceService(workspaces, invitations, personal_data), + workspace=WorkspaceService( + workspaces, + invitations, + personal_data, + users, + leave_wishes, + planned_leave, + parental_leave, + vacation_repo, + ), child=ChildService(children), vacation=VacationDaysService(vacation_repo), day_override=DayOverrideService(day_overrides), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 94196aa..0c1cd3b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -130,8 +130,22 @@ async def workspace_service( workspace_repo: SQLiteWorkspaceRepository, invitation_repo: SQLiteWorkspaceInvitationRepository, personal_data_repo: SQLitePersonalDataRepository, + user_repo: SQLiteUserRepository, + leave_wish_repo: SQLiteLeaveWishRepository, + planned_leave_repo: SQLitePlannedLeaveRepository, + parental_leave_repo: SQLiteParentalLeaveRepository, + vacation_repo: SQLiteVacationDaysRepository, ) -> WorkspaceService: - return WorkspaceService(workspace_repo, invitation_repo, personal_data_repo) + return WorkspaceService( + workspace_repo, + invitation_repo, + personal_data_repo, + user_repo, + leave_wish_repo, + planned_leave_repo, + parental_leave_repo, + vacation_repo, + ) # --------------------------------------------------------------------------- diff --git a/backend/tests/integration/test_user_journey.py b/backend/tests/integration/test_user_journey.py index f187af8..3298391 100644 --- a/backend/tests/integration/test_user_journey.py +++ b/backend/tests/integration/test_user_journey.py @@ -26,7 +26,7 @@ from __future__ import annotations from datetime import date from typing import Any -from uuid import UUID +from uuid import UUID, uuid4 import aiosqlite import pytest_asyncio @@ -700,3 +700,126 @@ class TestAge4ProjectionArithmeticIsSelfConsistent: assert projection["child_id"] == str(child_id) expected = max(projection["projected_left_at_b4"] - 96, 0) assert projection["expiring_at_b4"] == expected, projection + + +# --------------------------------------------------------------------------- +# 8) WORKSPACE PARENTS: ADD / RENAME / REMOVE A PLACEHOLDER PARTNER +# --------------------------------------------------------------------------- + + +class TestWorkspaceParentsRenameAndRemove: + """API-level coverage for POST/PATCH/DELETE .../parents — the hand-written + + dataclass -> response mapping in this codebase has silently dropped + fields before, so this hits the real routes rather than only the + service. Deliberately a single test walking every case behind ONE + `_register()` call: `/api/auth/register` is rate-limited to 10/minute + (see `app/routers/auth.py`), and this whole file shares that limiter's + in-memory counters within a single test run — one call per scenario + here would have pushed the file's total registrations over the limit. + """ + + async def test_add_rename_remove_and_guards( + self, client: AsyncClient, connection: aiosqlite.Connection + ) -> None: + headers, ws_id, pd_uuid = await _register(client, "wsparents@example.com", "Alex") + + # --- add: happy path + response shape --------------------------- + add_resp = await client.post( + f"/api/workspace/{ws_id}/parents", + headers=headers, + json={"display_name": "Sam"}, + ) + assert add_resp.status_code == 201, add_resp.text + placeholder = add_resp.json() + assert placeholder["name"] == "Sam" + assert placeholder["workspace_id"] == str(ws_id) + placeholder_uuid = placeholder["uuid"] + + members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) + assert members_resp.status_code == 200 + assert placeholder_uuid in {m["uuid"] for m in members_resp.json()} + + # --- add: blank name is refused ---------------------------------- + blank_resp = await client.post( + f"/api/workspace/{ws_id}/parents", + headers=headers, + json={"display_name": " "}, + ) + assert blank_resp.status_code == 422, blank_resp.text + assert blank_resp.json()["detail"]["message_key"] == "workspace.parents.invalidName" + + # --- rename: happy path ------------------------------------------- + rename_resp = await client.patch( + f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", + headers=headers, + json={"display_name": "Samuel"}, + ) + assert rename_resp.status_code == 200, rename_resp.text + renamed = rename_resp.json() + assert renamed["uuid"] == placeholder_uuid + assert renamed["name"] == "Samuel" + + members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) + names_by_uuid = {m["uuid"]: m["name"] for m in members_resp.json()} + assert names_by_uuid[placeholder_uuid] == "Samuel" + + # --- rename: a personal_data_uuid that isn't a member 404s ------- + rename_404 = await client.patch( + f"/api/workspace/{ws_id}/parents/{uuid4()}", + headers=headers, + json={"display_name": "Whoever"}, + ) + assert rename_404.status_code == 404, rename_404.text + assert rename_404.json()["detail"]["message_key"] == "workspace.parents.notFound" + + # --- remove: a personal_data_uuid that isn't a member 404s ------- + remove_404 = await client.delete( + f"/api/workspace/{ws_id}/parents/{uuid4()}", headers=headers + ) + assert remove_404.status_code == 404, remove_404.text + assert remove_404.json()["detail"]["message_key"] == "workspace.parents.notFound" + + # --- remove: the caller's own profile is refused ------------------- + remove_self = await client.delete( + f"/api/workspace/{ws_id}/parents/{pd_uuid}", headers=headers + ) + assert remove_self.status_code == 422, remove_self.text + assert remove_self.json()["detail"]["message_key"] == "workspace.parents.remove.self" + + # --- remove: refused while dependent data exists ------------------- + await _set_vacation(client, headers, UUID(placeholder_uuid), ws_id, date.today().year) + + remove_blocked = await client.delete( + f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers + ) + assert remove_blocked.status_code == 422, remove_blocked.text + blocked_detail = remove_blocked.json()["detail"] + assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData" + assert "vacation_days" in blocked_detail["data"]["blockers"] + + # Still a member — never silently removed despite the refusal. + members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) + assert placeholder_uuid in {m["uuid"] for m in members_resp.json()} + + # --- remove: happy path, on a second (unencumbered) placeholder -- + add_resp2 = await client.post( + f"/api/workspace/{ws_id}/parents", + headers=headers, + json={"display_name": "Kim"}, + ) + assert add_resp2.status_code == 201, add_resp2.text + other_placeholder_uuid = add_resp2.json()["uuid"] + + delete_resp = await client.delete( + f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers + ) + assert delete_resp.status_code == 204, delete_resp.text + assert delete_resp.content == b"" + + members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) + member_uuids = {m["uuid"] for m in members_resp.json()} + assert other_placeholder_uuid not in member_uuids + # The blocked placeholder and the caller are both still members. + assert placeholder_uuid in member_uuids + assert str(pd_uuid) in member_uuids diff --git a/backend/tests/unit/test_workspace_service.py b/backend/tests/unit/test_workspace_service.py index 2307f25..1561e67 100644 --- a/backend/tests/unit/test_workspace_service.py +++ b/backend/tests/unit/test_workspace_service.py @@ -1,15 +1,28 @@ """Tests for the Workspace service. Covers workspace creation/update, with a focus on the `planning_mode` field -(explicit solo/couple planning intent — not inferred from member count). +(explicit solo/couple planning intent — not inferred from member count), plus +renaming/removing a placeholder partner (`rename_member` / `remove_member`). """ +from datetime import date, datetime from uuid import 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 NotFoundError, ValidationError +from tests.conftest import SeededIds class TestWorkspacePlanningMode: @@ -70,3 +83,248 @@ class TestWorkspacePlanningMode: ) assert updated.name == "New Name" assert updated.planning_mode == "couple" + + +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, " ") + + +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, + recurrence="yearly", + 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 + )