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:
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import aiosqlite
|
||||
import pytest_asyncio
|
||||
@@ -700,3 +700,126 @@ class TestAge4ProjectionArithmeticIsSelfConsistent:
|
||||
assert projection["child_id"] == str(child_id)
|
||||
expected = max(projection["projected_left_at_b4"] - 96, 0)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user