Add rename and remove for a workspace's placeholder partner

Settings is gaining a partner display, which hides the add-form once a
partner exists. Without these routes a typo'd partner name would be
permanently unfixable, since no endpoint could change or drop one.

PATCH /workspace/{id}/parents/{uuid} renames; DELETE soft-deletes.
Soft rather than hard delete because PersonalData holds PII and the
repository is a SoftDeleteRepository specifically so erasure stays
auditable.

Removal refuses (422) when the member still has leave wishes, planned
leave, parental-leave rows or vacation days, naming the blockers, so a
removal can never orphan plan data. It also refuses for the caller's own
profile, and for a member who has their own login -- the invite/redeem
flow re-points a real user's personal_data into the inviter's workspace,
so soft-deleting that row would silently break their login.

Errors carry message_key + data rather than prose, per the no-Swedish-on
-the-wire rule; NotFoundError/ValidationError gained the same optional
shape DubbeldagError already had, defaulting empty so existing call
sites are unaffected.

Also fixes seed_demo.py constructing WorkspaceService with a stale
3-arg signature, which would have crashed at runtime -- invisible to
pytest since it is a standalone script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 18:54:34 +02:00
parent bd996245ba
commit 8ebfcb9462
9 changed files with 681 additions and 29 deletions
+15 -1
View File
@@ -237,8 +237,22 @@ def get_workspace_service(
ws_repo: WorkspaceRepositoryDep, ws_repo: WorkspaceRepositoryDep,
inv_repo: WorkspaceInvitationRepositoryDep, inv_repo: WorkspaceInvitationRepositoryDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
user_repo: UserRepositoryDep,
leave_wish_repo: LeaveWishRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
parental_leave_repo: ParentalLeaveRepositoryDep,
vacation_days_repo: VacationDaysRepositoryDep,
) -> WorkspaceService: ) -> 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)] WorkspaceServiceDep = Annotated[WorkspaceService, Depends(get_workspace_service)]
+5
View File
@@ -47,6 +47,11 @@ class PersonalDataResponse(BaseModel):
class PersonalDataCreate(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) display_name: str = Field(min_length=1, max_length=100)
+69 -20
View File
@@ -5,6 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from app.dependencies import CurrentUserDep, WorkspaceServiceDep from app.dependencies import CurrentUserDep, WorkspaceServiceDep
from app.entities.personal_data import PersonalData
from app.models.workspace import ( from app.models.workspace import (
InvitationResponse, InvitationResponse,
InviteResponse, InviteResponse,
@@ -20,6 +21,35 @@ from app.utils.errors import InvitationError, NotFoundError, ValidationError
router = APIRouter(prefix="/api/workspace", tags=["workspace"]) 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) @router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace( async def create_workspace(
payload: WorkspaceCreate, payload: WorkspaceCreate,
@@ -71,17 +101,7 @@ async def list_members(
_current_user: CurrentUserDep, _current_user: CurrentUserDep,
) -> list[PersonalDataResponse]: ) -> list[PersonalDataResponse]:
members = await service.list_members(workspace_id) members = await service.list_members(workspace_id)
return [ return [_to_response(m) for m in members]
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
]
@router.post( @router.post(
@@ -97,16 +117,45 @@ async def add_placeholder_parent(
) -> PersonalDataResponse: ) -> PersonalDataResponse:
try: try:
pd = await service.add_placeholder_parent(workspace_id, payload.display_name) pd = await service.add_placeholder_parent(workspace_id, payload.display_name)
except NotFoundError as exc: except (NotFoundError, ValidationError) as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc raise _to_http_error(exc) from exc
return PersonalDataResponse( return _to_response(pd)
uuid=pd.uuid,
workspace_id=pd.workspace_id,
name=pd.name, @router.patch(
salary_type=pd.salary_type, "/{workspace_id}/parents/{personal_data_uuid}",
employment_status=pd.employment_status, response_model=PersonalDataResponse,
created_at=pd.created_at, )
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( @router.post(
+151 -1
View File
@@ -17,7 +17,12 @@ from uuid import UUID, uuid4
from app.entities.personal_data import PersonalData from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace from app.entities.workspace import Workspace
from app.entities.workspace_invitation import WorkspaceInvitation 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.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 ( from app.repositories.workspace_repository import (
WorkspaceInvitationRepository, WorkspaceInvitationRepository,
WorkspaceRepository, WorkspaceRepository,
@@ -33,10 +38,20 @@ class WorkspaceService:
ws_repo: WorkspaceRepository, ws_repo: WorkspaceRepository,
inv_repo: WorkspaceInvitationRepository, inv_repo: WorkspaceInvitationRepository,
pd_repo: PersonalDataRepository, pd_repo: PersonalDataRepository,
user_repo: UserRepository,
leave_wish_repo: LeaveWishRepository,
planned_leave_repo: PlannedLeaveRepository,
parental_leave_repo: ParentalLeaveRepository,
vacation_days_repo: VacationDaysRepository,
) -> None: ) -> None:
self._ws = ws_repo self._ws = ws_repo
self._inv = inv_repo self._inv = inv_repo
self._pd = pd_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 # Workspace CRUD
@@ -49,7 +64,11 @@ class WorkspaceService:
async def get_workspace(self, workspace_id: UUID) -> Workspace: async def get_workspace(self, workspace_id: UUID) -> Workspace:
ws = await self._ws.get_by_id(workspace_id) ws = await self._ws.get_by_id(workspace_id)
if ws is None: 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 return ws
async def update_workspace( async def update_workspace(
@@ -80,6 +99,7 @@ class WorkspaceService:
async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData: 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).""" """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 await self.get_workspace(workspace_id) # verify workspace exists
pd = PersonalData( pd = PersonalData(
uuid=uuid4(), uuid=uuid4(),
@@ -89,6 +109,123 @@ class WorkspaceService:
) )
return await self._pd.create(pd) 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 # Invitation flow
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -152,3 +289,16 @@ class WorkspaceService:
def _sha256(value: str) -> str: def _sha256(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest() 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={},
)
+32 -2
View File
@@ -10,11 +10,41 @@ class DomainError(Exception):
class NotFoundError(DomainError): 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): 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): class InsufficientDaysError(DomainError):
+10 -1
View File
@@ -122,7 +122,16 @@ def _build_services(conn: aiosqlite.Connection) -> DemoServices:
return DemoServices( return DemoServices(
auth=AuthService(users, personal_data, workspaces), 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), child=ChildService(children),
vacation=VacationDaysService(vacation_repo), vacation=VacationDaysService(vacation_repo),
day_override=DayOverrideService(day_overrides), day_override=DayOverrideService(day_overrides),
+15 -1
View File
@@ -130,8 +130,22 @@ async def workspace_service(
workspace_repo: SQLiteWorkspaceRepository, workspace_repo: SQLiteWorkspaceRepository,
invitation_repo: SQLiteWorkspaceInvitationRepository, invitation_repo: SQLiteWorkspaceInvitationRepository,
personal_data_repo: SQLitePersonalDataRepository, personal_data_repo: SQLitePersonalDataRepository,
user_repo: SQLiteUserRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> WorkspaceService: ) -> 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,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+124 -1
View File
@@ -26,7 +26,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID, uuid4
import aiosqlite import aiosqlite
import pytest_asyncio import pytest_asyncio
@@ -700,3 +700,126 @@ class TestAge4ProjectionArithmeticIsSelfConsistent:
assert projection["child_id"] == str(child_id) assert projection["child_id"] == str(child_id)
expected = max(projection["projected_left_at_b4"] - 96, 0) expected = max(projection["projected_left_at_b4"] - 96, 0)
assert projection["expiring_at_b4"] == expected, projection 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
+259 -1
View File
@@ -1,15 +1,28 @@
"""Tests for the Workspace service. """Tests for the Workspace service.
Covers workspace creation/update, with a focus on the `planning_mode` field 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 from uuid import uuid4
import pytest 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.services.workspace_service import WorkspaceService
from app.utils.errors import NotFoundError, ValidationError from app.utils.errors import NotFoundError, ValidationError
from tests.conftest import SeededIds
class TestWorkspacePlanningMode: class TestWorkspacePlanningMode:
@@ -70,3 +83,248 @@ class TestWorkspacePlanningMode:
) )
assert updated.name == "New Name" assert updated.name == "New Name"
assert updated.planning_mode == "couple" 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
)