"""Notification destination CRUD, ownership isolation, and the company-linking behavior this feature is built around: reusing an existing destination by (type, value) instead of duplicating it, dedupe display, and garbage-collecting a destination once no company references it.""" from __future__ import annotations import uuid def _register_and_login(client) -> dict[str, str]: email = f"user-{uuid.uuid4().hex[:12]}@example.com" client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "Test User"}, ) tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() return {"Authorization": f"Bearer {tokens['access_token']}"} def _create_company(client, headers, name: str | None = None) -> dict: return client.post( "/api/v1/companies", json={"name": name or f"Co {uuid.uuid4().hex[:6]}", "frequency_type": "weekly"}, headers=headers, ).json() def test_create_email_destination(client): headers = _register_and_login(client) company = _create_company(client, headers) resp = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=headers, ) assert resp.status_code == 201 body = resp.json() assert body["type"] == "email" assert body["verified"] is False assert body["minimum_severity"] == "medium" assert [c["id"] for c in body["companies"]] == [company["id"]] def test_create_destination_requires_at_least_one_company(client): headers = _register_and_login(client) resp = client.post( "/api/v1/notification-destinations", json={"type": "email", "destination_value": "alerts@example.com", "company_ids": []}, headers=headers, ) assert resp.status_code == 422 def test_create_destination_rejects_a_company_id_the_user_doesnt_own(client): owner_headers = _register_and_login(client) other_headers = _register_and_login(client) other_company = _create_company(client, other_headers) resp = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [other_company["id"]], }, headers=owner_headers, ) assert resp.status_code == 400 def test_create_email_destination_rejects_invalid_email(client): headers = _register_and_login(client) company = _create_company(client, headers) resp = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "not-an-email", "company_ids": [company["id"]], }, headers=headers, ) assert resp.status_code == 422 def test_create_sms_destination_rejects_invalid_phone(client): headers = _register_and_login(client) company = _create_company(client, headers) resp = client.post( "/api/v1/notification-destinations", json={"type": "sms", "destination_value": "not-a-phone", "company_ids": [company["id"]]}, headers=headers, ) assert resp.status_code == 422 def test_create_sms_destination_accepts_e164(client): headers = _register_and_login(client) company = _create_company(client, headers) resp = client.post( "/api/v1/notification-destinations", json={ "type": "sms", "destination_value": "+15551234567", "company_ids": [company["id"]], }, headers=headers, ) assert resp.status_code == 201 def test_reusing_the_same_email_for_a_second_company_links_instead_of_duplicating(client): """The bug this feature exists to fix: the wizard used to create a brand new NotificationDestination row per company even when the email was already registered. Now it must reuse the same row and just add a link.""" headers = _register_and_login(client) company_a = _create_company(client, headers, name="Company A") company_b = _create_company(client, headers, name="Company B") first = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "shared@example.com", "company_ids": [company_a["id"]], }, headers=headers, ).json() second = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "SHARED@example.com", # different casing on purpose "company_ids": [company_b["id"]], }, headers=headers, ).json() assert first["id"] == second["id"] listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert len(listing) == 1 linked_ids = {c["id"] for c in listing[0]["companies"]} assert linked_ids == {company_a["id"], company_b["id"]} def test_update_destination_value_resets_verification(client): headers = _register_and_login(client) company = _create_company(client, headers) created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=headers, ).json() resp = client.patch( f"/api/v1/notification-destinations/{created['id']}", json={"destination_value": "new-alerts@example.com"}, headers=headers, ) assert resp.status_code == 200 assert resp.json()["verified"] is False def test_destinations_scoped_to_owner(client): owner_headers = _register_and_login(client) other_headers = _register_and_login(client) company = _create_company(client, owner_headers) created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=owner_headers, ).json() resp = client.patch( f"/api/v1/notification-destinations/{created['id']}", json={"enabled": False}, headers=other_headers, ) assert resp.status_code == 404 def test_delete_destination(client): headers = _register_and_login(client) company = _create_company(client, headers) created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=headers, ).json() resp = client.delete(f"/api/v1/notification-destinations/{created['id']}", headers=headers) assert resp.status_code == 204 listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert all(d["id"] != created["id"] for d in listing) def test_deleting_a_companys_only_destination_link_garbage_collects_it(client): headers = _register_and_login(client) company = _create_company(client, headers) client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=headers, ) resp = client.delete(f"/api/v1/companies/{company['id']}", headers=headers) assert resp.status_code == 204 listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert listing == [] def test_deleting_one_of_two_linked_companies_keeps_the_destination(client): headers = _register_and_login(client) company_a = _create_company(client, headers, name="Keep") company_b = _create_company(client, headers, name="Delete me") client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company_a["id"], company_b["id"]], }, headers=headers, ) resp = client.delete(f"/api/v1/companies/{company_b['id']}", headers=headers) assert resp.status_code == 204 listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert len(listing) == 1 assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]] def test_unlink_company_keeps_the_destination_when_other_links_remain(client): headers = _register_and_login(client) company_a = _create_company(client, headers, name="Keep") company_b = _create_company(client, headers, name="Unlink me") created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company_a["id"], company_b["id"]], }, headers=headers, ).json() resp = client.delete( f"/api/v1/notification-destinations/{created['id']}/companies/{company_b['id']}", headers=headers, ) assert resp.status_code == 204 listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert len(listing) == 1 assert [c["id"] for c in listing[0]["companies"]] == [company_a["id"]] def test_unlinking_the_last_company_garbage_collects_the_destination(client): headers = _register_and_login(client) company = _create_company(client, headers) created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=headers, ).json() resp = client.delete( f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}", headers=headers, ) assert resp.status_code == 204 listing = client.get("/api/v1/notification-destinations", headers=headers).json() assert listing == [] def test_unlink_company_is_scoped_to_the_destinations_owner(client): owner_headers = _register_and_login(client) other_headers = _register_and_login(client) company = _create_company(client, owner_headers) created = client.post( "/api/v1/notification-destinations", json={ "type": "email", "destination_value": "alerts@example.com", "company_ids": [company["id"]], }, headers=owner_headers, ).json() resp = client.delete( f"/api/v1/notification-destinations/{created['id']}/companies/{company['id']}", headers=other_headers, ) assert resp.status_code == 404 # The destination and its link both survive the rejected attempt. listing = client.get("/api/v1/notification-destinations", headers=owner_headers).json() assert len(listing) == 1 assert [c["id"] for c in listing[0]["companies"]] == [company["id"]]