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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user