8ebfcb9462
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
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""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), 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:
|
|
"""Test planning_mode defaults and validation."""
|
|
|
|
async def test_create_workspace_defaults_to_solo(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""A newly created workspace defaults to solo planning mode."""
|
|
ws = await workspace_service.create_workspace("Test WS")
|
|
assert ws.planning_mode == "solo"
|
|
|
|
async def test_update_planning_mode_to_couple(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""Updating planning_mode to a valid value ('couple') succeeds."""
|
|
ws = await workspace_service.create_workspace("Test WS")
|
|
updated = await workspace_service.update_workspace(ws.id, planning_mode="couple")
|
|
assert updated.planning_mode == "couple"
|
|
|
|
async def test_update_planning_mode_back_to_solo(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""Updating planning_mode back to 'solo' succeeds."""
|
|
ws = await workspace_service.create_workspace("Test WS")
|
|
await workspace_service.update_workspace(ws.id, planning_mode="couple")
|
|
updated = await workspace_service.update_workspace(ws.id, planning_mode="solo")
|
|
assert updated.planning_mode == "solo"
|
|
|
|
async def test_update_planning_mode_invalid_raises(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""An invalid planning_mode value raises ValidationError."""
|
|
ws = await workspace_service.create_workspace("Test WS")
|
|
with pytest.raises(ValidationError, match="planning_mode must be one of"):
|
|
await workspace_service.update_workspace(ws.id, planning_mode="throuple")
|
|
|
|
async def test_update_planning_mode_missing_workspace_raises(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""Updating planning_mode on a missing workspace raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await workspace_service.update_workspace(uuid4(), planning_mode="couple")
|
|
|
|
async def test_update_name_and_planning_mode_together(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
) -> None:
|
|
"""Multiple fields can be updated in the same call."""
|
|
ws = await workspace_service.create_workspace("Old Name")
|
|
updated = await workspace_service.update_workspace(
|
|
ws.id, name="New Name", planning_mode="couple"
|
|
)
|
|
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
|
|
)
|