0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
535 lines
22 KiB
Python
535 lines
22 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,
|
|
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_claims_placeholder_instead_of_refusing_when_full_of_a_placeholder(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
seeded_ids: SeededIds,
|
|
user_repo: SQLiteUserRepository,
|
|
) -> None:
|
|
"""Regression for "the second parent can't log in": the target
|
|
workspace's second slot is commonly a placeholder auto-created by the
|
|
onboarding wizard (`add_placeholder_parent`), not a genuinely full
|
|
workspace. Redemption must CLAIM that placeholder -- re-pointing the
|
|
redeemer's login onto it and preserving whatever plan data the
|
|
placeholder already holds -- rather than refusing with "maximum
|
|
number of members", which used to make the entire invite happy path
|
|
impossible (see `WorkspaceService.redeem_invitation` docstring)."""
|
|
placeholder = await workspace_service.add_placeholder_parent(
|
|
seeded_ids.workspace_id, "Partner"
|
|
)
|
|
|
|
other_ws = await workspace_service.create_workspace("Other WS")
|
|
redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer")
|
|
redeemer_user = await user_repo.create(
|
|
User(
|
|
id=uuid4(),
|
|
personal_data_uuid=redeemer_pd.uuid,
|
|
email="redeemer@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
token = await workspace_service.invite_partner(
|
|
seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid)
|
|
)
|
|
inv = await workspace_service.redeem_invitation(token, redeemer_pd.uuid)
|
|
assert inv.status == "accepted"
|
|
|
|
# Login re-pointed onto the placeholder, not left on the (now
|
|
# discarded) auto-created profile.
|
|
refreshed_user = await user_repo.get_by_id(redeemer_user.id)
|
|
assert refreshed_user is not None
|
|
assert refreshed_user.personal_data_uuid == placeholder.uuid
|
|
|
|
# The placeholder took over the redeemer's already-chosen name.
|
|
members = await workspace_service.list_members(seeded_ids.workspace_id)
|
|
assert {m.name for m in members} == {"Test Parent", "Redeemer"}
|
|
|
|
# The redeemer's own auto-created profile is gone -- not left behind
|
|
# as a second, orphaned membership of the target workspace.
|
|
assert await workspace_service._pd.get_by_id(redeemer_pd.uuid) is None
|
|
|
|
async def test_redeem_refuses_when_both_slots_have_real_logins(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
seeded_ids: SeededIds,
|
|
user_repo: SQLiteUserRepository,
|
|
) -> None:
|
|
"""Unlike the placeholder case above, a workspace whose second slot
|
|
is a real, logged-in member has genuinely no room -- redemption must
|
|
still be refused."""
|
|
second_member = await workspace_service.add_placeholder_parent(
|
|
seeded_ids.workspace_id, "Partner"
|
|
)
|
|
await user_repo.create(
|
|
User(
|
|
id=uuid4(),
|
|
personal_data_uuid=second_member.uuid,
|
|
email="partner@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
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)
|
|
|
|
async def test_redeem_refuses_claim_when_redeemer_has_dependent_plan_data(
|
|
self,
|
|
workspace_service: WorkspaceService,
|
|
seeded_ids: SeededIds,
|
|
user_repo: SQLiteUserRepository,
|
|
vacation_repo: SQLiteVacationDaysRepository,
|
|
) -> None:
|
|
"""A redeemer who already entered plan data under their own profile
|
|
cannot be safely claimed onto the placeholder: `vacation_days` (and
|
|
siblings) denormalize `workspace_id` alongside `personal_data_uuid`,
|
|
and no repository method exists to repoint that column -- silently
|
|
discarding or merging it would lose or corrupt data, so this is
|
|
refused instead."""
|
|
await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner")
|
|
|
|
other_ws = await workspace_service.create_workspace("Other WS")
|
|
redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer")
|
|
await user_repo.create(
|
|
User(
|
|
id=uuid4(),
|
|
personal_data_uuid=redeemer_pd.uuid,
|
|
email="redeemer2@example.com",
|
|
hashed_password="not-a-real-hash",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=redeemer_pd.uuid,
|
|
workspace_id=other_ws.id,
|
|
year=2026,
|
|
total_days=25,
|
|
)
|
|
)
|
|
|
|
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="vacation_days"):
|
|
await workspace_service.redeem_invitation(token, redeemer_pd.uuid)
|