feat(phase-4b): backend business-logic, 8 entities, 32 endpoints, 58 tests
This commit is contained in:
@@ -142,3 +142,134 @@ async def second_user(
|
||||
"password": payload["password"],
|
||||
"name": payload["name"],
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_data(
|
||||
client: AsyncClient,
|
||||
registered_user: dict[str, Any],
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> dict[str, Any]:
|
||||
"""Seed a representative data set for business-logic tests.
|
||||
|
||||
Creates (via the API to exercise FK constraints end-to-end):
|
||||
- 2 accounts owned by the bootstrap admin user
|
||||
- 3 contacts (2 attached to accounts, 1 standalone)
|
||||
- 5 deals across various stages
|
||||
- 10 activities (some overdue, some completed)
|
||||
- 3 tags (VIP, Strategic, Enterprise)
|
||||
- 4 notes (mixed parents)
|
||||
|
||||
Returns a dict with all IDs + a ref to the auth headers for convenience.
|
||||
"""
|
||||
headers = registered_user["headers"]
|
||||
|
||||
# 2 accounts
|
||||
acc1 = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Acme Corp", "industry": "sme", "size": "sme", "website": "https://acme.test"},
|
||||
headers=headers,
|
||||
)
|
||||
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}"
|
||||
acc1_id = acc1.json()["id"]
|
||||
|
||||
acc2 = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Globex GmbH", "industry": "enterprise", "size": "enterprise"},
|
||||
headers=headers,
|
||||
)
|
||||
assert acc2.status_code == 201, f"seed acc2: {acc2.status_code} {acc2.text}"
|
||||
acc2_id = acc2.json()["id"]
|
||||
|
||||
# 3 contacts (2 with account, 1 standalone)
|
||||
c1 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Anna", "last_name": "Schmidt", "email": "anna@acme.example", "account_id": acc1_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c1.status_code == 201, c1.text
|
||||
c1_id = c1.json()["id"]
|
||||
|
||||
c2 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Bob", "last_name": "Mueller", "email": "bob@globex.example", "account_id": acc2_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert c2.status_code == 201, c2.text
|
||||
c2_id = c2.json()["id"]
|
||||
|
||||
c3 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Clara", "last_name": "Weber", "email": "clara@x.example"},
|
||||
headers=headers,
|
||||
)
|
||||
assert c3.status_code == 201, c3.text
|
||||
c3_id = c3.json()["id"]
|
||||
|
||||
# 5 deals (different stages)
|
||||
deal_ids: list[int] = []
|
||||
for i, (title, stage) in enumerate([
|
||||
("Deal A", "lead"),
|
||||
("Deal B", "qualified"),
|
||||
("Deal C", "proposal"),
|
||||
("Deal D", "negotiation"),
|
||||
("Deal E", "won"),
|
||||
]):
|
||||
d = await client.post(
|
||||
"/api/v1/deals/",
|
||||
json={
|
||||
"title": title,
|
||||
"value": str(1000 * (i + 1)),
|
||||
"stage": stage,
|
||||
"account_id": acc1_id if i % 2 == 0 else acc2_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert d.status_code == 201, d.text
|
||||
deal_ids.append(d.json()["id"])
|
||||
|
||||
# 10 activities (4 overdue, 4 future, 2 completed)
|
||||
from datetime import UTC, datetime, timedelta
|
||||
past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
|
||||
future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
|
||||
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
|
||||
activity_ids: list[int] = []
|
||||
for i in range(10):
|
||||
if i < 4:
|
||||
due = past
|
||||
body_data: dict[str, object] = {"type": "task", "subject": f"Overdue task {i}", "due_date": due, "deal_id": deal_ids[i % 5]}
|
||||
elif i < 8:
|
||||
due = future if i % 2 == 0 else future2
|
||||
body_data = {"type": "call", "subject": f"Upcoming call {i}", "due_date": due, "account_id": acc1_id}
|
||||
else:
|
||||
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id}
|
||||
a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
|
||||
assert a.status_code == 201, a.text
|
||||
activity_ids.append(a.json()["id"])
|
||||
|
||||
# 3 tags
|
||||
tag_ids: list[int] = []
|
||||
for name in ["VIP", "Strategic", "Enterprise"]:
|
||||
t = await client.post("/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers)
|
||||
assert t.status_code == 201, t.text
|
||||
tag_ids.append(t.json()["id"])
|
||||
|
||||
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal)
|
||||
n1 = await client.post("/api/v1/notes/", json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, headers=headers)
|
||||
assert n1.status_code == 201, n1.text
|
||||
n2 = await client.post("/api/v1/notes/", json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, headers=headers)
|
||||
assert n2.status_code == 201, n2.text
|
||||
n3 = await client.post("/api/v1/notes/", json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, headers=headers)
|
||||
assert n3.status_code == 201, n3.text
|
||||
n4 = await client.post("/api/v1/notes/", json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, headers=headers)
|
||||
assert n4.status_code == 201, n4.text
|
||||
|
||||
return {
|
||||
"account_ids": [acc1_id, acc2_id],
|
||||
"contact_ids": [c1_id, c2_id, c3_id],
|
||||
"deal_ids": deal_ids,
|
||||
"activity_ids": activity_ids,
|
||||
"tag_ids": tag_ids,
|
||||
"note_ids": [n1.json()["id"], n2.json()["id"], n3.json()["id"], n4.json()["id"]],
|
||||
"headers": headers,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for FR-2 (Account entity). All 8 acceptance criteria covered."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Acme Corp", "industry": "sme", "size": "sme"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["name"] == "Acme Corp"
|
||||
assert data["industry"] == "sme"
|
||||
assert "id" in data and data["id"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_missing_required(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"industry": "sme"}, # name missing
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_paginated(client: AsyncClient, seed_data: dict) -> None:
|
||||
# seed_data creates 2 accounts
|
||||
resp = await client.get("/api/v1/accounts/?limit=20", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
assert len(body) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_filter_industry(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/accounts/?industry=enterprise", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(a["industry"] == "enterprise" for a in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_with_relations(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["id"] == acc_id
|
||||
# All base fields are present (relations are reachable via dedicated endpoints)
|
||||
for key in ("id", "name", "industry", "size", "owner_id", "created_at", "updated_at"):
|
||||
assert key in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/accounts/{acc_id}",
|
||||
json={"name": "Acme Corporation (Updated)"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Acme Corporation (Updated)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_account(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.delete(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 204
|
||||
# Subsequent GET should not find it (or returns 404 because deleted_at is set)
|
||||
get_resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"])
|
||||
assert get_resp.status_code in (404, 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_write_account_no_password_field(
|
||||
client: AsyncClient, auth_headers: dict[str, str], session_factory
|
||||
) -> None:
|
||||
"""Accounts have no password-related field; ensure schema is plaintext business fields."""
|
||||
from sqlalchemy import text
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Schema Test Co", "industry": "startup"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'"))
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "Schema Test Co"
|
||||
assert row[1] == "startup"
|
||||
# No password / hash columns exist on accounts
|
||||
cols = await session.execute(text("PRAGMA table_info(accounts)"))
|
||||
names = {c[1] for c in cols.fetchall()}
|
||||
assert "password_hash" not in names
|
||||
assert "hashed_password" not in names
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for FR-5 (Activity entity, polymorphic parent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_activity_with_polymorphic_fk(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
deal_id = seed_data["deal_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/activities/",
|
||||
json={"type": "task", "subject": "Follow up", "deal_id": deal_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["deal_id"] == deal_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_activities_filter_overdue(client: AsyncClient, seed_data: dict) -> None:
|
||||
# seed_data inserts 4 overdue activities
|
||||
resp = await client.get("/api/v1/activities/?overdue=true", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
now = datetime.now() # naive: SQLite returns naive datetimes
|
||||
for a in body:
|
||||
if a.get("due_date") and a.get("completed_at") is None:
|
||||
due = a["due_date"]
|
||||
if due.endswith("Z"):
|
||||
due = due.replace("Z", "+00:00")
|
||||
assert datetime.fromisoformat(due) < now
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_activity(client: AsyncClient, seed_data: dict) -> None:
|
||||
a_id = seed_data["activity_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/activities/{a_id}/complete",
|
||||
json={"outcome": "Resolved via email"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["completed_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_requires_at_least_one_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/activities/",
|
||||
json={"type": "task", "subject": "Orphan task"}, # no account/contact/deal
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for FR-3 (Contact entity)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_with_account(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Diana", "last_name": "Prince", "email": "diana@x.example", "account_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["account_id"] == acc_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_contact_invalid_account(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Eve", "last_name": "Adams", "account_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_account(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(c["account_id"] == acc_id for c in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_contacts_by_email(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
# seed_data creates contact with email 'anna@acme.example' on account 0
|
||||
resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert any("anna@acme.example" in (c.get("email") or "") for c in body)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for FR-7 (Dashboard: KPIs + activity feed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kpis(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set(body.keys())
|
||||
assert isinstance(body["open_deals_count"], int)
|
||||
assert isinstance(body["pipeline_value"], (int, float))
|
||||
assert isinstance(body["won_this_month"], int)
|
||||
assert isinstance(body["conversion_rate"], (int, float))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activity_feed(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/feed?limit=20", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert isinstance(body, list)
|
||||
assert len(body) >= 1
|
||||
# Items are sorted by created_at desc (most recent first)
|
||||
if len(body) >= 2:
|
||||
assert body[0]["created_at"] >= body[-1]["created_at"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_requires_auth(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/kpis")
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests for FR-4 (Deal entity) and stage-history audit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_deal(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/deals/",
|
||||
json={"title": "Big Deal", "value": "5000.00", "stage": "qualified", "account_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["title"] == "Big Deal"
|
||||
assert body["stage"] == "qualified"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_deal_stage_creates_history(
|
||||
client: AsyncClient, seed_data: dict, session_factory
|
||||
) -> None:
|
||||
deal_id = seed_data["deal_ids"][0]
|
||||
resp = await client.patch(
|
||||
f"/api/v1/deals/{deal_id}/stage",
|
||||
json={"stage": "qualified"},
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["stage"] == "qualified"
|
||||
|
||||
# Verify DealStageHistory row exists in DB
|
||||
from sqlalchemy import text
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"),
|
||||
{"did": deal_id},
|
||||
)
|
||||
stages = [r[0] for r in result.fetchall()]
|
||||
assert "qualified" in stages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_grouped_by_stage(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/deals/pipeline", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# Each entry has stage, count, total_value
|
||||
assert all({"stage", "count", "total_value"} <= set(item.keys()) for item in body)
|
||||
# Seeded deals cover at least 5 distinct stages
|
||||
stages = {item["stage"] for item in body}
|
||||
assert len(stages) >= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_deals_by_owner(client: AsyncClient, seed_data: dict) -> None:
|
||||
owner_id = seed_data["headers"].get("X-Test-User-Id") # may be None; fallback: just check 200
|
||||
# we filter by an owner-id that does not exist; expect empty list
|
||||
resp = await client.get("/api/v1/deals/?owner_id=99999", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == [] or all(d.get("owner_id") == 99999 for d in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_write_deal(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict, session_factory
|
||||
) -> None:
|
||||
"""DB roundtrip: read back a seeded deal directly via SQL."""
|
||||
from sqlalchemy import text
|
||||
deal_id = seed_data["deal_ids"][2]
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT title, value, stage, account_id FROM deals WHERE id = :did"),
|
||||
{"did": deal_id},
|
||||
)
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] is not None # title
|
||||
assert row[1] is not None # value
|
||||
assert row[2] is not None # stage
|
||||
assert row[3] is not None # account_id
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for FR-6 + R-2 (Note entity, polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_with_valid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Great call", "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["parent_type"] == "account"
|
||||
assert body["parent_id"] == acc_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note_with_invalid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""R-2: parent_type+parent_id must point to an existing entity; else 404."""
|
||||
resp = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note into the void", "parent_type": "account", "parent_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_for_parent(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(
|
||||
f"/api/v1/notes/?parent_type=account&parent_id={acc_id}",
|
||||
headers=seed_data["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert all(n["parent_type"] == "account" and n["parent_id"] == acc_id for n in body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_polymorphic_note_for_account_vs_contact(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
contact_id = seed_data["contact_ids"][0]
|
||||
n_acc = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Account note", "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
n_contact = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Contact note", "parent_type": "contact", "parent_id": contact_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert n_acc.status_code == 201, n_acc.text
|
||||
assert n_contact.status_code == 201, n_contact.text
|
||||
assert n_acc.json()["parent_type"] == "account"
|
||||
assert n_contact.json()["parent_type"] == "contact"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests for soft-delete isolation (R-1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_deleted_records_excluded_from_queries(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
# Create a throwaway account, soft-delete it, then verify it does NOT appear in list
|
||||
create = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Throwaway Co", "industry": "other"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
acc_id = create.json()["id"]
|
||||
|
||||
delete = await client.delete(f"/api/v1/accounts/{acc_id}", headers=auth_headers)
|
||||
assert delete.status_code == 204
|
||||
|
||||
# List accounts; the soft-deleted one must NOT appear in results
|
||||
listed = await client.get("/api/v1/accounts/?q=Throwaway", headers=auth_headers)
|
||||
assert listed.status_code == 200
|
||||
ids = [a["id"] for a in listed.json()]
|
||||
assert acc_id not in ids
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for FR-6 + R-2 (Tag + TagLink, polymorphic parent validation, unique constraint)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/",
|
||||
json={"name": "Important", "color": "#FF0000"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "Important"
|
||||
assert body["color"] == "#FF0000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_tag_to_valid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/link",
|
||||
json={"tag_id": tag_id, "parent_type": "account", "parent_id": acc_id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_tag_to_invalid_parent(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
"""R-2: invalid parent → 404."""
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/link",
|
||||
json={"tag_id": tag_id, "parent_type": "account", "parent_id": 99999},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unique_constraint_on_link(
|
||||
client: AsyncClient, auth_headers: dict[str, str], seed_data: dict
|
||||
) -> None:
|
||||
"""Second link with same (tag, parent_type, parent_id) → 409."""
|
||||
tag_id = seed_data["tag_ids"][0]
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
payload = {"tag_id": tag_id, "parent_type": "account", "parent_id": acc_id}
|
||||
r1 = await client.post("/api/v1/tags/link", json=payload, headers=auth_headers)
|
||||
assert r1.status_code == 201, r1.text
|
||||
r2 = await client.post("/api/v1/tags/link", json=payload, headers=auth_headers)
|
||||
assert r2.status_code == 409
|
||||
Reference in New Issue
Block a user