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:
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user