"""DB write-cycle tests: CRUD for every business entity. Each test creates a fresh record via the API, verifies it via the list endpoint, updates one field, re-verifies, then soft-deletes. Uses the existing conftest.py fixtures (client, registered_user, seed_data). No real HTTP connection – async ASGITransport. """ from __future__ import annotations from typing import Any import pytest from httpx import AsyncClient # ========================================================= # Helper # ========================================================= async def _crud_cycle( client: AsyncClient, auth_headers: dict[str, str], entity: str, create_payload: dict[str, Any], update_payload: dict[str, Any], read_field: str, skip_update: bool = False, ) -> dict[str, Any]: """Run create -> verify-in-list -> update -> verify-in-list -> delete.""" list_path = f"/api/v1/{entity}/" detail_base = f"/api/v1/{entity}/" # ---------- CREATE ---------- create_resp = await client.post(list_path, json=create_payload, headers=auth_headers) assert create_resp.status_code == 201, f"CREATE {entity}: {create_resp.status_code} {create_resp.text}" created = create_resp.json() obj_id = created.get("id") assert obj_id is not None, f"CREATE {entity} returned no id" # ---------- VERIFY IN LIST ---------- list_resp = await client.get(list_path, headers=auth_headers) assert list_resp.status_code == 200, f"LIST {entity}: {list_resp.status_code}" items = list_resp.json() if isinstance(items, dict): items = items.get("items", []) matching = [i for i in items if i.get("id") == obj_id] assert len(matching) == 1, f"LIST {entity}: object with id {obj_id} not found in list" assert matching[0].get(read_field) == create_payload[read_field], \ f"LIST {entity}: field '{read_field}' mismatch" # ---------- UPDATE ---------- if not skip_update: patch_path = f"{detail_base}{obj_id}" patch_resp = await client.patch(patch_path, json=update_payload, headers=auth_headers) assert patch_resp.status_code == 200, f"PATCH {entity} {obj_id}: {patch_resp.status_code} {patch_resp.text}" patched = patch_resp.json() update_field = next(iter(update_payload)) assert patched[update_field] == update_payload[update_field], \ f"PATCH result mismatch for {entity}" # Verify update persisted via list list_after = await client.get(list_path, headers=auth_headers) items_after = list_after.json() if isinstance(items_after, dict): items_after = items_after.get("items", []) updated_match = [i for i in items_after if i.get("id") == obj_id] assert updated_match, f"Object {obj_id} still missing from list after PATCH" # ---------- DELETE ---------- delete_resp = await client.delete(f"{detail_base}{obj_id}", headers=auth_headers) assert delete_resp.status_code == 204, f"DELETE {entity} {obj_id}: {delete_resp.status_code}" # Verify deleted from list list_deleted = await client.get(list_path, headers=auth_headers) items_deleted = list_deleted.json() if isinstance(items_deleted, dict): items_deleted = items_deleted.get("items", []) deleted_match = [i for i in items_deleted if i.get("id") == obj_id] assert len(deleted_match) == 0, f"Object {obj_id} still appears in list after DELETE" return created # ========================================================= # 1. Users CRUD # ========================================================= @pytest.mark.asyncio async def test_user_crud(client: AsyncClient, registered_user: dict[str, Any]) -> None: """Create user → verify in list → update → delete.""" headers = registered_user["headers"] await _crud_cycle( client, headers, entity="users", create_payload={"email": "crud_user@test.com", "password": "Test1234!", "name": "CRUD User", "role": "sales_rep"}, update_payload={"name": "Updated CRUD User"}, read_field="name", ) # ========================================================= # 2. Accounts CRUD # ========================================================= @pytest.mark.asyncio async def test_account_crud(client: AsyncClient, registered_user: dict[str, Any]) -> None: """Create account → verify in list → update → delete.""" headers = registered_user["headers"] await _crud_cycle( client, headers, entity="accounts", create_payload={"name": "CRUD Account", "industry": "sme"}, update_payload={"name": "Updated Account"}, read_field="name", ) # ========================================================= # 3. Contacts CRUD # ========================================================= @pytest.mark.asyncio async def test_contact_crud(client: AsyncClient, registered_user: dict[str, Any]) -> None: """Create contact → verify in list → update → delete.""" headers = registered_user["headers"] await _crud_cycle( client, headers, entity="contacts", create_payload={"first_name": "Jane", "last_name": "Doe"}, update_payload={"first_name": "Janet"}, read_field="first_name", ) # ========================================================= # 4. Deals CRUD (needs an account_id) # ========================================================= @pytest.mark.asyncio async def test_deal_crud(client: AsyncClient, seed_data: dict[str, Any]) -> None: """Create deal → verify in list → update → delete.""" headers = seed_data["headers"] account_id = seed_data["account_ids"][0] await _crud_cycle( client, headers, entity="deals", create_payload={"title": "CRUD Deal", "account_id": account_id}, update_payload={"title": "Updated Deal"}, read_field="title", ) # ========================================================= # 5. Activities CRUD # ========================================================= @pytest.mark.asyncio async def test_activity_crud(client: AsyncClient, seed_data: dict[str, Any]) -> None: """Create activity → verify in list → update → delete.""" headers = seed_data["headers"] account_id = seed_data["account_ids"][0] await _crud_cycle( client, headers, entity="activities", create_payload={"type": "task", "subject": "CRUD Activity", "account_id": account_id}, update_payload={"subject": "Updated Activity"}, read_field="subject", ) # ========================================================= # 6. Notes CRUD (needs parent_type/parent_id) # ========================================================= @pytest.mark.asyncio async def test_note_crud(client: AsyncClient, seed_data: dict[str, Any]) -> None: """Create note → verify in list → update → delete.""" headers = seed_data["headers"] account_id = seed_data["account_ids"][0] await _crud_cycle( client, headers, entity="notes", create_payload={"body": "CRUD Note", "parent_type": "account", "parent_id": account_id}, update_payload={"body": "Updated Note"}, read_field="body", ) # ========================================================= # 7. Tags CRUD (no GET-by-ID, no PATCH) # ========================================================= @pytest.mark.asyncio async def test_tag_crud(client: AsyncClient, registered_user: dict[str, Any]) -> None: """Create tag → verify in list. (No DELETE endpoint for single tags; unlink is via /tags/link).""" headers = registered_user["headers"] list_path = "/api/v1/tags/" create_payload = {"name": "CRUD Tag", "color": "#FFCC00"} # CREATE create_resp = await client.post(list_path, json=create_payload, headers=headers) assert create_resp.status_code == 201, f"CREATE tags: {create_resp.status_code} {create_resp.text}" created = create_resp.json() obj_id = created.get("id") assert obj_id is not None # VERIFY IN LIST list_resp = await client.get(list_path, headers=headers) assert list_resp.status_code == 200 items = list_resp.json() matching = [i for i in items if i.get("id") == obj_id] assert len(matching) == 1 assert matching[0].get("name") == create_payload["name"] # ========================================================= # 8. Org – only GET + PATCH exist, but neither is implemented # ========================================================= @pytest.mark.asyncio async def test_org_endpoint_not_implemented( client: AsyncClient, registered_user: dict[str, Any] ) -> None: """Verify /api/v1/org returns 404 (endpoint not yet implemented).""" headers = registered_user["headers"] get_resp = await client.get("/api/v1/org", headers=headers) assert get_resp.status_code == 404, \ f"GET /api/v1/org → {get_resp.status_code} (expected 404, endpoint not implemented in Phase 4a-4d)" patch_resp = await client.patch( "/api/v1/org", json={"name": "Test"}, headers=headers, ) assert patch_resp.status_code in (404, 405), \ f"PATCH /api/v1/org → {patch_resp.status_code} (expected 404 or 405)"