Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
"""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": "[email protected]",
|
||||
"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": "[email protected]", "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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"company_ids": [company_a["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
second = client.post(
|
||||
"/api/v1/notification-destinations",
|
||||
json={
|
||||
"type": "email",
|
||||
"destination_value": "[email protected]", # 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": "[email protected]",
|
||||
"company_ids": [company["id"]],
|
||||
},
|
||||
headers=headers,
|
||||
).json()
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/notification-destinations/{created['id']}",
|
||||
json={"destination_value": "[email protected]"},
|
||||
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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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": "[email protected]",
|
||||
"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"]]
|
||||
Reference in New Issue
Block a user