From a0b43f55cccd5375ac8c38f1011cb61bf151ef3b Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Wed, 10 Jun 2026 18:52:43 +0530 Subject: [PATCH 1/6] test: expand activity signals validation coverage --- tests/test_activity_signals.py | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index edf45c9..6131e07 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -28,6 +28,75 @@ def test_ingest_from_different_streams(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) assert response.status_code in (200, 201) + def test_missing_stream_field(self, client) -> None: + """Signal ingestion should reject payloads missing the stream field. + + Current Phase 3 behavior: + - router still returns HTTP 501 (stub implementation) + + Future expected behavior: + - FastAPI/Pydantic validation should return HTTP 422 + """ + signal = { + "event_type": "match_created", + "payload": {}, + } + + response = client.post("/api/v1/signals/", json=signal) + + # Accept both for now while implementation is incomplete + assert response.status_code in (422, 501) + + + def test_missing_event_type(self, client) -> None: + """Signal ingestion should reject payloads missing event_type. + + event_type is required for downstream timeline processing + and recommendation-engine event classification. + """ + signal = { + "stream": "boardy", + "payload": {}, + } + + response = client.post("/api/v1/signals/", json=signal) + + assert response.status_code in (422, 501) + + + def test_invalid_payload_type(self, client) -> None: + """Signal payload should eventually require a dictionary object. + + Current implementation is still a Phase 3 stub, but this test + prepares validation coverage for malformed payload structures. + """ + signal = { + "stream": "boardy", + "event_type": "match_created", + "payload": "invalid_payload", + } + + response = client.post("/api/v1/signals/", json=signal) + + assert response.status_code in (422, 501) + + + def test_empty_payload(self, client) -> None: + """Empty payloads should not crash the ingestion endpoint. + + Empty payloads may still be considered valid depending on + stream-specific event schemas in later phases. + """ + signal = { + "stream": "boardy", + "event_type": "match_created", + "payload": {}, + } + + response = client.post("/api/v1/signals/", json=signal) + + # Stub currently returns 501 until implementation is wired + assert response.status_code in (201, 501) @pytest.mark.skip( reason=( @@ -45,6 +114,26 @@ def test_filter_by_stream(self, client) -> None: """Should filter signals by stream name.""" response = client.get("/api/v1/signals/?stream=boardy") assert response.status_code == 200 + + def test_empty_signal_list(self, client) -> None: + """GET /signals/ should safely return an empty list. + Current Phase 3 implementation intentionally returns [] + until real querying and persistence are implemented. + """ + response = client.get("/api/v1/signals/") + assert response.status_code == 200 + assert response.json() == [] + + + def test_filter_invalid_stream(self, client) -> None: + """Filtering by an unknown stream should not crash the API. + Future implementation should safely return an empty list + when no matching signals exist. + """ + response = client.get("/api/v1/signals/?stream=unknown") + + assert response.status_code == 200 + assert isinstance(response.json(), list) From 9b222cef6f1969b80858cebe39b6655112189255 Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Thu, 11 Jun 2026 20:03:01 +0530 Subject: [PATCH 2/6] test: add activity signal timestamp validation coverage --- src/lpi/models.py | 3 ++- tests/conftest.py | 1 + tests/test_activity_signals.py | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/lpi/models.py b/src/lpi/models.py index 9804288..296e2e7 100644 --- a/src/lpi/models.py +++ b/src/lpi/models.py @@ -115,13 +115,14 @@ class DeleteResponse(BaseModel): class SignalCreate(BaseModel): stream: str event_type: str + timestamp: datetime payload: dict = {} class Signal(SignalCreate): id: str user_id: str - timestamp: datetime + # ── Recommendation model (unchanged) ───────────────────────────────────────── diff --git a/tests/conftest.py b/tests/conftest.py index 451acff..9aab3b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,5 +99,6 @@ def sample_signal() -> dict: return { "stream": "boardy", "event_type": "match_created", + "timestamp": "2026-06-11T10:00:00Z", "payload": {"person_a": "Alice", "person_b": "Bob", "score": 0.85}, } diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index 6131e07..d56ac13 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -39,6 +39,7 @@ def test_missing_stream_field(self, client) -> None: """ signal = { "event_type": "match_created", + "timestamp": "2026-06-11T10:00:00Z", "payload": {}, } @@ -56,6 +57,7 @@ def test_missing_event_type(self, client) -> None: """ signal = { "stream": "boardy", + "timestamp": "2026-06-11T10:00:00Z", "payload": {}, } @@ -73,6 +75,7 @@ def test_invalid_payload_type(self, client) -> None: signal = { "stream": "boardy", "event_type": "match_created", + "timestamp": "2026-06-11T10:00:00Z", "payload": "invalid_payload", } @@ -90,6 +93,7 @@ def test_empty_payload(self, client) -> None: signal = { "stream": "boardy", "event_type": "match_created", + "timestamp": "2026-06-11T10:00:00Z", "payload": {}, } @@ -97,6 +101,47 @@ def test_empty_payload(self, client) -> None: # Stub currently returns 501 until implementation is wired assert response.status_code in (201, 501) + def test_invalid_timestamp_format(self, client) -> None: + """Reject malformed timestamps.""" + + signal = { + "stream": "boardy", + "event_type": "match_created", + "timestamp": "not-a-timestamp", + "payload": {}, + } + + response = client.post("/api/v1/signals/", json=signal) + + assert response.status_code == 422 + + def test_missing_timestamp(self, client) -> None: + """Reject payloads missing timestamp.""" + + signal = { + "stream": "boardy", + "event_type": "match_created", + "timestamp": "2026-06-11T10:00:00Z", + "payload": {}, + } + + response = client.post("/api/v1/signals/", json=signal) + + assert response.status_code == 422 + + def test_valid_timestamp_format(self, client, sample_signal) -> None: + """Accept ISO-8601 timestamps. + + Current implementation still returns 501 because ingestion is not + implemented, but the request should pass schema validation. + """ + + response = client.post( + "/api/v1/signals/", + json=sample_signal, + ) + + assert response.status_code == 501 @pytest.mark.skip( reason=( From cfde30cf15f1e39b18386f1efca23ee1f38f8e61 Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Tue, 16 Jun 2026 18:53:21 +0530 Subject: [PATCH 3/6] Merge staging into yashika/activity-signals-tests --- tests/test_activity_signals.py | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index 1ed413a..2343aaa 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -301,23 +301,16 @@ def test_empty_signal_list(self, client) -> None: data = response.json() assert isinstance(data, list) -<<<<<<< HEAD def test_filter_invalid_stream(self, client) -> None: - """Filtering by an unknown stream should not crash the API. + """Filtering by an unknown stream should not crash the API. - Future implementation should safely return an empty list - when no matching signals exist. - """ - response = client.get("/api/v1/signals/?stream=unknown") + Future implementation should safely return an empty list + when no matching signals exist. + """ + response = client.get("/api/v1/signals/?stream=unknown") - assert response.status_code == 200 - assert isinstance(response.json(), list) -======= - # Every returned signal must be from boardy — datapro must not appear - for signal in data: - assert signal["stream"] == "boardy", ( - f"Filter by stream=boardy returned a signal from '{signal['stream']}'" - ) + assert response.status_code == 200 + assert isinstance(response.json(), list) def test_filter_by_source(self, client) -> None: """?source=github_api should return only github_api signals. @@ -325,7 +318,6 @@ def test_filter_by_source(self, client) -> None: Phase 3 addition. This is the filter Phase 4 recommendation engine uses to exclude simulated test data from real signals. """ - # Insert a real GitHub signal github_signal = { "stream": "lpi", "event_type": "pr_merged", @@ -334,7 +326,6 @@ def test_filter_by_source(self, client) -> None: } client.post("/api/v1/signals/", json=github_signal) - # Insert a simulated signal (should NOT appear in github_api filter) simulated_signal = { "stream": "lpi", "event_type": "pr_merged", @@ -343,13 +334,9 @@ def test_filter_by_source(self, client) -> None: } client.post("/api/v1/signals/", json=simulated_signal) - # Filter by source=github_api response = client.get("/api/v1/signals/?source=github_api") assert response.status_code == 200 data = response.json() for signal in data: - assert signal["source"] == "github_api", ( - f"Filter source=github_api returned a signal with source='{signal['source']}'" - ) ->>>>>>> staging + assert signal["source"] == "github_api" \ No newline at end of file From fd90b191b6afff723df47bdfe415521ed6771ed0 Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Tue, 16 Jun 2026 23:55:54 +0530 Subject: [PATCH 4/6] merged files --- tests/test_activity_signals.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index 2343aaa..1c04676 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -301,16 +301,11 @@ def test_empty_signal_list(self, client) -> None: data = response.json() assert isinstance(data, list) - def test_filter_invalid_stream(self, client) -> None: - """Filtering by an unknown stream should not crash the API. - - Future implementation should safely return an empty list - when no matching signals exist. - """ - response = client.get("/api/v1/signals/?stream=unknown") - - assert response.status_code == 200 - assert isinstance(response.json(), list) + # Every returned signal must be from boardy — datapro must not appear + for signal in data: + assert signal["stream"] == "boardy", ( + f"Filter by stream=boardy returned a signal from '{signal['stream']}'" + ) def test_filter_by_source(self, client) -> None: """?source=github_api should return only github_api signals. @@ -318,6 +313,7 @@ def test_filter_by_source(self, client) -> None: Phase 3 addition. This is the filter Phase 4 recommendation engine uses to exclude simulated test data from real signals. """ + # Insert a real GitHub signal github_signal = { "stream": "lpi", "event_type": "pr_merged", @@ -326,6 +322,7 @@ def test_filter_by_source(self, client) -> None: } client.post("/api/v1/signals/", json=github_signal) + # Insert a simulated signal (should NOT appear in github_api filter) simulated_signal = { "stream": "lpi", "event_type": "pr_merged", @@ -334,9 +331,12 @@ def test_filter_by_source(self, client) -> None: } client.post("/api/v1/signals/", json=simulated_signal) + # Filter by source=github_api response = client.get("/api/v1/signals/?source=github_api") assert response.status_code == 200 data = response.json() for signal in data: - assert signal["source"] == "github_api" \ No newline at end of file + assert signal["source"] == "github_api", ( + f"Filter source=github_api returned a signal with source='{signal['source']}'" + ) From 7cb9fc26ebc278959f569f15298610d4ce6d783d Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Wed, 17 Jun 2026 02:33:21 +0530 Subject: [PATCH 5/6] activity tests integration --- src/lpi/models.py | 2 +- src/lpi/routers/signals.py | 1 + tests/conftest.py | 7 +- tests/test_activity_signals.py | 117 ++++++++++++++++++++++++++++----- 4 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/lpi/models.py b/src/lpi/models.py index ae25aa2..ccc00a5 100644 --- a/src/lpi/models.py +++ b/src/lpi/models.py @@ -152,7 +152,6 @@ class SignalCreate(BaseModel): stream: str event_type: str - timestamp: datetime payload: dict = {} # `source` is optional here (defaults to 'api') so that: # 1. Existing test fixtures (sample_signal in conftest.py) need no changes. @@ -175,6 +174,7 @@ class Signal(SignalCreate): id: str user_id: str + timestamp: datetime diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index edb0b61..8b0dda3 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -137,6 +137,7 @@ def ingest_signal( # Log to stdout — visible in uvicorn logs. Never breaks the endpoint. print(f"[ingest_signal] WARNING: logging failed for signal {new_signal.id}: {exc}") + print(new_signal.model_dump()) return new_signal diff --git a/tests/conftest.py b/tests/conftest.py index 1b99d88..1022f5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,7 +61,8 @@ def _supabase_available() -> bool: try: store.list_goals() return True - except Exception: + except Exception as e: + print("SUPABASE CHECK FAILED:", repr(e)) return False @@ -107,6 +108,10 @@ def client() -> TestClient: test_client.headers.update({"Authorization": f"Bearer {_make_token()}"}) return test_client +@pytest.fixture +def unauthenticated_client() -> TestClient: + """FastAPI test client without Authorization header.""" + return TestClient(app) @pytest.fixture def sample_goal() -> dict: diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index 1c04676..090bb0d 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -85,6 +85,23 @@ def test_ingest_with_explicit_source(self, client) -> None: data = response.json() assert data["source"] == "github_api" # must be preserved, not overwritten + def test_ingest_requires_auth( + self, + unauthenticated_client, +) -> None: + signal = { + "stream": "boardy", + "event_type": "match_created", + "payload": {}, + } + + response = unauthenticated_client.post( + "/api/v1/signals/", + json=signal, + ) + + assert response.status_code == 401 + def test_ingest_from_different_streams(self, client) -> None: """Should accept signals from any stream — no allowlist enforced. @@ -177,47 +194,64 @@ def test_empty_payload(self, client) -> None: # Stub currently returns 501 until implementation is wired assert response.status_code in (201, 501) - def test_invalid_timestamp_format(self, client) -> None: - """Reject malformed timestamps.""" - + def test_extra_timestamp_field_is_ignored(self, client) -> None: + """Client-supplied timestamp should be ignored. + + The server generates its own timestamp during ingestion. + """ + signal = { "stream": "boardy", "event_type": "match_created", "timestamp": "not-a-timestamp", "payload": {}, - } + } response = client.post("/api/v1/signals/", json=signal) - assert response.status_code == 422 + assert response.status_code in (200, 201) + + data = response.json() + + # Server-generated timestamp should still exist + assert "timestamp" in data - def test_missing_timestamp(self, client) -> None: - """Reject payloads missing timestamp.""" + def test_timestamp_not_required(self, client) -> None: + """Timestamp should not be required in requests. + + The server assigns the ingestion timestamp automatically. + """ signal = { "stream": "boardy", "event_type": "match_created", - "timestamp": "2026-06-11T10:00:00Z", "payload": {}, } response = client.post("/api/v1/signals/", json=signal) - assert response.status_code == 422 - - def test_valid_timestamp_format(self, client, sample_signal) -> None: - """Accept ISO-8601 timestamps. + assert response.status_code in (200, 201) - Current implementation still returns 501 because ingestion is not - implemented, but the request should pass schema validation. - """ + data = response.json() + + assert "timestamp" in data + + def test_valid_signal_ingestion(self, client, sample_signal) -> None: + """A valid signal should be accepted and persisted.""" response = client.post( "/api/v1/signals/", json=sample_signal, ) - assert response.status_code == 501 + assert response.status_code == 201 + + data = response.json() + + assert data["stream"] == sample_signal["stream"] + assert data["event_type"] == sample_signal["event_type"] + assert "id" in data + assert "timestamp" in data class TestQuerySignals: """Tests for GET /api/v1/signals/ @@ -260,6 +294,35 @@ def test_list_signals(self, client, sample_signal) -> None: for signal in data: assert signal["user_id"] == "00000000-0000-0000-0000-000000000001" + def test_get_signal_by_id( + self, + client, + sample_signal, +) -> None: + create_response = client.post( + "/api/v1/signals/", + json=sample_signal, + ) + + signal_id = create_response.json()["id"] + + response = client.get( + f"/api/v1/signals/{signal_id}" + ) + + assert response.status_code == 200 + assert response.json()["id"] == signal_id + + def test_get_nonexistent_signal_returns_404( + self, + client, +) -> None: + response = client.get( + "/api/v1/signals/00000000-0000-0000-0000-000000000000" + ) + + assert response.status_code == 404 + def test_filter_by_stream(self, client) -> None: """?stream=boardy should return only boardy signals. @@ -340,3 +403,25 @@ def test_filter_by_source(self, client) -> None: assert signal["source"] == "github_api", ( f"Filter source=github_api returned a signal with source='{signal['source']}'" ) + + def test_limit_parameter( + self, + client, +) -> None: + for i in range(5): + client.post( + "/api/v1/signals/", + json={ + "stream": "boardy", + "event_type": f"event_{i}", + "payload": {}, + }, + ) + + + response = client.get( + "/api/v1/signals/?limit=2" + ) + + assert response.status_code == 200 + assert len(response.json()) == 2 \ No newline at end of file From e2d2ee96eac0eb65615d860292148fa849c05291 Mon Sep 17 00:00:00 2001 From: Yashika Verma Date: Sat, 20 Jun 2026 20:20:16 +0530 Subject: [PATCH 6/6] Add Phase 3 Activity Signals integration tests --- src/lpi/routers/signals.py | 25 +++-- src/lpi/store.py | 32 +++--- tests/test_activity_signals.py | 177 ++++++++++++++++++++++++++------- 3 files changed, 176 insertions(+), 58 deletions(-) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 8b0dda3..9aee50f 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -58,6 +58,7 @@ # ── Wave 2: POST /api/v1/signals/ ──────────────────────────────────────────── + @router.post( "/", response_model=Signal, @@ -106,9 +107,9 @@ def ingest_signal( # fields (id, user_id, timestamp) on top. now = datetime.now(UTC) new_signal = Signal( - id=str(uuid.uuid4()), # UUID generated here, not by Postgres - user_id=user_id, # "default_user" in Phase 3 - timestamp=now, # always UTC + id=str(uuid.uuid4()), # UUID generated here, not by Postgres + user_id=user_id, # "default_user" in Phase 3 + timestamp=now, # always UTC **signal.model_dump(), # stream, event_type, payload, source ) @@ -143,6 +144,7 @@ def ingest_signal( # ── Wave 3: GET /api/v1/signals/ ───────────────────────────────────────────── + @router.get( "/", response_model=list[Signal], @@ -170,10 +172,18 @@ def list_signals( "exclude simulated signals." ), ), + start: datetime | None = Query( + default=None, + description="Return signals created at or after this UTC timestamp.", + ), + end: datetime | None = Query( + default=None, + description="Return signals created at or before this UTC timestamp.", + ), limit: int = Query( default=50, - ge=1, # minimum 1 row - le=200, # maximum 200 rows — prevents accidentally huge responses + ge=1, # minimum 1 row + le=200, # maximum 200 rows — prevents accidentally huge responses description=( "Max rows per page (1–200). Use with offset for pagination. " "Default 50 is enough for dashboards and the rec engine." @@ -181,7 +191,7 @@ def list_signals( ), offset: int = Query( default=0, - ge=0, # cannot be negative + ge=0, # cannot be negative description=( "Number of rows to skip. Page 1 = offset 0. " "Page 2 = offset 50 (if limit=50). " @@ -227,6 +237,8 @@ def list_signals( stream=stream, event_type=event_type, source=source, + start=start, + end=end, limit=limit, offset=offset, ) @@ -234,6 +246,7 @@ def list_signals( # ── Bonus: GET /api/v1/signals/{signal_id} ─────────────────────────────────── + @router.get( "/{signal_id}", response_model=Signal, diff --git a/src/lpi/store.py b/src/lpi/store.py index 423d12c..57a6d46 100644 --- a/src/lpi/store.py +++ b/src/lpi/store.py @@ -57,6 +57,7 @@ """ import threading +from datetime import datetime from typing import TYPE_CHECKING, cast from lpi.config import settings @@ -78,6 +79,7 @@ def _get_client() -> "Client": authenticated server-side operations bypass RLS as designed. """ from supabase import create_client # type: ignore[attr-defined] + key = settings.supabase_service_role_key or settings.supabase_key if not key: raise RuntimeError( @@ -99,6 +101,7 @@ def _get_client() -> "Client": # ── Goals ────────────────────────────────────────────────────────────────────── + def get_goal(goal_id: str) -> Goal | None: """Fetch a single goal by its UUID. Returns None if not found.""" result = _get_client().table("goals").select("*").eq("id", goal_id).execute() @@ -133,9 +136,7 @@ def insert_goal(goal: Goal) -> Goal: def update_goal(goal_id: str, updates: dict) -> Goal: """Apply a partial update dict to a goal row. Returns the updated goal.""" - result = ( - _get_client().table("goals").update(updates).eq("id", goal_id).execute() - ) + result = _get_client().table("goals").update(updates).eq("id", goal_id).execute() return Goal(**cast(dict, result.data[0])) @@ -153,6 +154,7 @@ def delete_goal(goal_id: str) -> None: # Pattern: identical to the goals functions above. If you understand # how insert_goal / list_goals / get_goal work, these are the same. + def insert_signal(signal: Signal) -> Signal: """Persist a new signal row to the activity_signals Supabase table. @@ -163,9 +165,7 @@ def insert_signal(signal: Signal) -> Signal: Returns the original signal unchanged (Supabase returns the inserted row but we already have it — no need to re-parse it). """ - _get_client().table("activity_signals").insert( - signal.model_dump(mode="json") - ).execute() + _get_client().table("activity_signals").insert(signal.model_dump(mode="json")).execute() return signal @@ -174,6 +174,8 @@ def list_signals( stream: str | None = None, event_type: str | None = None, source: str | None = None, + start: datetime | None = None, + end: datetime | None = None, limit: int = 50, offset: int = 0, ) -> list[Signal]: @@ -232,6 +234,11 @@ def list_signals( query = query.eq("event_type", event_type) if source: query = query.eq("source", source) + if start is not None: + query = query.gte("timestamp", start.isoformat()) + + if end is not None: + query = query.lte("timestamp", end.isoformat()) # Sort newest-first, then apply pagination. # .order("timestamp", desc=True) → ORDER BY timestamp DESC @@ -253,13 +260,7 @@ def get_signal(signal_id: str) -> Signal | None: Used by GET /api/v1/signals/{signal_id} — not yet in the router but defined here so Phase 4 can use it without touching the store. """ - result = ( - _get_client() - .table("activity_signals") - .select("*") - .eq("id", signal_id) - .execute() - ) + result = _get_client().table("activity_signals").select("*").eq("id", signal_id).execute() if not result.data: return None return Signal(**cast(dict, result.data[0])) @@ -267,6 +268,7 @@ def get_signal(signal_id: str) -> Signal | None: # ── Test helper ─────────────────────────────────────────────────────────────── + def clear_all() -> None: """Wipe all data from goals and activity_signals. Call ONLY from tests. @@ -284,9 +286,7 @@ def clear_all() -> None: in conftest.py, so tests never see each other's data. """ # Wipe all goals rows - _get_client().table("goals").delete().neq( - "user_id", "__sentinel_never_exists__" - ).execute() + _get_client().table("goals").delete().neq("user_id", "__sentinel_never_exists__").execute() # Wipe all activity_signals rows (Phase 3 addition) _get_client().table("activity_signals").delete().neq( diff --git a/tests/test_activity_signals.py b/tests/test_activity_signals.py index 090bb0d..93128be 100644 --- a/tests/test_activity_signals.py +++ b/tests/test_activity_signals.py @@ -24,6 +24,8 @@ pytest tests/test_activity_signals.py -v """ +from unittest.mock import patch + class TestIngestSignal: """Tests for POST /api/v1/signals/ @@ -86,9 +88,9 @@ def test_ingest_with_explicit_source(self, client) -> None: assert data["source"] == "github_api" # must be preserved, not overwritten def test_ingest_requires_auth( - self, - unauthenticated_client, -) -> None: + self, + unauthenticated_client, + ) -> None: signal = { "stream": "boardy", "event_type": "match_created", @@ -139,8 +141,7 @@ def test_missing_stream_field(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) # Accept both for now while implementation is incomplete - assert response.status_code in (422, 501) - + assert response.status_code == 422 def test_missing_event_type(self, client) -> None: """Signal ingestion should reject payloads missing event_type. @@ -156,8 +157,7 @@ def test_missing_event_type(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) - assert response.status_code in (422, 501) - + assert response.status_code == 422 def test_invalid_payload_type(self, client) -> None: """Signal payload should eventually require a dictionary object. @@ -174,8 +174,7 @@ def test_invalid_payload_type(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) - assert response.status_code in (422, 501) - + assert response.status_code == 422 def test_empty_payload(self, client) -> None: """Empty payloads should not crash the ingestion endpoint. @@ -193,7 +192,8 @@ def test_empty_payload(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) # Stub currently returns 501 until implementation is wired - assert response.status_code in (201, 501) + assert response.status_code == 201 + def test_extra_timestamp_field_is_ignored(self, client) -> None: """Client-supplied timestamp should be ignored. @@ -209,13 +209,14 @@ def test_extra_timestamp_field_is_ignored(self, client) -> None: response = client.post("/api/v1/signals/", json=signal) - assert response.status_code in (200, 201) + assert response.status_code == 201 data = response.json() # Server-generated timestamp should still exist assert "timestamp" in data - + assert data["timestamp"] != signal["timestamp"] + def test_timestamp_not_required(self, client) -> None: """Timestamp should not be required in requests. @@ -235,7 +236,7 @@ def test_timestamp_not_required(self, client) -> None: data = response.json() assert "timestamp" in data - + def test_valid_signal_ingestion(self, client, sample_signal) -> None: """A valid signal should be accepted and persisted.""" @@ -253,6 +254,51 @@ def test_valid_signal_ingestion(self, client, sample_signal) -> None: assert "id" in data assert "timestamp" in data + def test_signal_ingestion_logs_activity( + self, + client, + sample_signal, + ) -> None: + """Signal ingestion should log a user activity event.""" + + with patch("lpi.routers.signals.log_user_activity") as mock_log: + response = client.post( + "/api/v1/signals/", + json=sample_signal, + ) + + assert response.status_code == 201 + + mock_log.assert_called_once() + + _, kwargs = mock_log.call_args + + assert kwargs["action"] == "signal_ingested" + assert kwargs["user_id"] == "00000000-0000-0000-0000-000000000001" + + assert kwargs["metadata"]["stream"] == sample_signal["stream"] + assert kwargs["metadata"]["event_type"] == sample_signal["event_type"] + assert kwargs["metadata"]["source"] == "api" + + def test_signal_ingestion_succeeds_when_logging_fails( + self, + client, + sample_signal, + ) -> None: + """Signal ingestion should still succeed if activity logging fails.""" + + with patch( + "lpi.routers.signals.log_user_activity", + side_effect=Exception("Logging failed"), + ): + response = client.post( + "/api/v1/signals/", + json=sample_signal, + ) + + assert response.status_code == 201 + + class TestQuerySignals: """Tests for GET /api/v1/signals/ @@ -295,10 +341,10 @@ def test_list_signals(self, client, sample_signal) -> None: assert signal["user_id"] == "00000000-0000-0000-0000-000000000001" def test_get_signal_by_id( - self, - client, - sample_signal, -) -> None: + self, + client, + sample_signal, + ) -> None: create_response = client.post( "/api/v1/signals/", json=sample_signal, @@ -306,20 +352,16 @@ def test_get_signal_by_id( signal_id = create_response.json()["id"] - response = client.get( - f"/api/v1/signals/{signal_id}" - ) + response = client.get(f"/api/v1/signals/{signal_id}") assert response.status_code == 200 assert response.json()["id"] == signal_id - + def test_get_nonexistent_signal_returns_404( - self, - client, -) -> None: - response = client.get( - "/api/v1/signals/00000000-0000-0000-0000-000000000000" - ) + self, + client, + ) -> None: + response = client.get("/api/v1/signals/00000000-0000-0000-0000-000000000000") assert response.status_code == 404 @@ -349,7 +391,18 @@ def test_filter_by_stream(self, client) -> None: # Filter by boardy only response = client.get("/api/v1/signals/?stream=boardy") assert response.status_code == 200 - + data = response.json() + + # Should return at least one signal + assert len(data) >= 1 + + # Every returned signal must belong to the boardy stream + for signal in data: + assert signal["stream"] == "boardy" + + # Ensure the datapro signal is not included + assert all(signal["stream"] != "datapro" for signal in data) + def test_empty_signal_list(self, client) -> None: """GET /signals/ should safely return an empty list. @@ -404,10 +457,65 @@ def test_filter_by_source(self, client) -> None: f"Filter source=github_api returned a signal with source='{signal['source']}'" ) + def test_filter_by_event_type(self, client) -> None: + """?event_type=pr_merged should return only matching event types.""" + + # Insert a PR merged signal + pr_signal = { + "stream": "lpi", + "event_type": "pr_merged", + "payload": {}, + } + client.post("/api/v1/signals/", json=pr_signal) + + # Insert a different event type + match_signal = { + "stream": "boardy", + "event_type": "match_created", + "payload": {}, + } + client.post("/api/v1/signals/", json=match_signal) + + response = client.get("/api/v1/signals/?event_type=pr_merged") + + assert response.status_code == 200 + + data = response.json() + + assert len(data) >= 1 + + for signal in data: + assert signal["event_type"] == "pr_merged" + + assert all(signal["event_type"] != "match_created" for signal in data) + + def test_filter_by_time_range(self, client) -> None: + """Signals should be filtered by timestamp range.""" + + client.post( + "/api/v1/signals/", + json={ + "stream": "lpi", + "event_type": "pr_merged", + "payload": {}, + }, + ) + + response = client.get( + "/api/v1/signals/?start=2000-01-01T00:00:00Z&end=2100-01-01T00:00:00Z" + ) + + assert response.status_code == 200 + + data = response.json() + + assert isinstance(data, list) + assert len(data) >= 1 + def test_limit_parameter( - self, - client, -) -> None: + self, + client, + ) -> None: for i in range(5): client.post( "/api/v1/signals/", @@ -417,11 +525,8 @@ def test_limit_parameter( "payload": {}, }, ) - - response = client.get( - "/api/v1/signals/?limit=2" - ) + response = client.get("/api/v1/signals/?limit=2") assert response.status_code == 200 - assert len(response.json()) == 2 \ No newline at end of file + assert len(response.json()) == 2