Files
F-r-ldraledigplannerare/backend/tests/unit/test_workspace_service.py
T
jacobh2 b87ad0e7ad Let a household choose to be paid for the red days
Asking for a full week off never spent anything on the Saturday, the
Sunday or a holiday, and a test now says so outright rather than leaving
it to be rediscovered. Those days are free because vacation cannot be
taken on a day that is not a working day.

Parental benefit is a payment rather than a schedule, though, so it can
be drawn on a red day to turn an unpaid day off into a paid one — at the
cost of a day the family would otherwise keep. That is now a workspace
setting, off by default, and it never reaches for a vacation day. With it
off the planner behaves exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:28:38 +02:00

431 lines
18 KiB
Python

"""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,
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
)
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)