From acbff854f2da96bddcaede1385e7fefeba0fb34b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 9 Apr 2026 19:45:45 -0500 Subject: initial commit --- tests/conftest.py | 58 +++++++ tests/test_aggregator.py | 79 ++++++++++ tests/test_contributions_api.py | 120 ++++++++++++++ tests/test_health.py | 15 ++ tests/test_ingestion.py | 337 ++++++++++++++++++++++++++++++++++++++++ tests/test_migrations.py | 117 ++++++++++++++ tests/test_polling_api.py | 71 +++++++++ tests/test_repositories_api.py | 77 +++++++++ tests/test_srht_client.py | 56 +++++++ 9 files changed, 930 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_aggregator.py create mode 100644 tests/test_contributions_api.py create mode 100644 tests/test_health.py create mode 100644 tests/test_ingestion.py create mode 100644 tests/test_migrations.py create mode 100644 tests/test_polling_api.py create mode 100644 tests/test_repositories_api.py create mode 100644 tests/test_srht_client.py (limited to 'tests') diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6de9c8e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session, sessionmaker + +from srht_contrib.config import Settings +from srht_contrib.db import Base, make_engine +from srht_contrib.main import create_app + + +@pytest.fixture() +def settings() -> Settings: + return Settings( + API_KEY="test-api-key", + ENABLE_SCHEDULER=False, + SRHT_TOKEN="test-token", + DATABASE_URL="sqlite://", + TODO_SRHT_ENDPOINT="https://todo.sr.ht/query", + GIT_SRHT_ENDPOINT="https://git.sr.ht/query", + DEFAULT_ACTOR="~ccleberg", + POLL_INTERVAL_SECONDS=3600, + GIT_TRACKED_REPOSITORIES=[], + ) + + +@pytest.fixture() +def db_engine(settings: Settings): + engine = make_engine(settings) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def session_factory(db_engine) -> sessionmaker[Session]: + return sessionmaker(bind=db_engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +@pytest.fixture() +def db_session(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]: + session = session_factory() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(settings: Settings, db_engine, session_factory: sessionmaker[Session]) -> Generator[TestClient, None, None]: + app = create_app(settings, engine=db_engine, session_factory=session_factory) + with TestClient(app) as test_client: + test_client.headers.update({"X-API-Key": settings.api_key}) + yield test_client diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py new file mode 100644 index 0000000..c840cac --- /dev/null +++ b/tests/test_aggregator.py @@ -0,0 +1,79 @@ +from datetime import UTC, datetime, date + +from sqlalchemy.orm import Session + +from srht_contrib.models import ContributionEvent +from srht_contrib.services.aggregator import ContributionAggregator + + +def test_aggregator_zero_fills_days(db_session: Session) -> None: + db_session.add( + ContributionEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:1:created", + occurred_at=datetime(2026, 3, 28, 12, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ) + ) + db_session.commit() + + calendar = ContributionAggregator().build_calendar( + db_session, + "~ccleberg", + date(2026, 3, 28), + date(2026, 3, 30), + ) + + assert [day.model_dump() for day in calendar.days] == [ + {"date": date(2026, 3, 28), "count": 1, "score": 1.0}, + {"date": date(2026, 3, 29), "count": 0, "score": 0.0}, + {"date": date(2026, 3, 30), "count": 0, "score": 0.0}, + ] + + +def test_stats_calculation(db_session: Session) -> None: + db_session.add_all( + [ + ContributionEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:1:created", + occurred_at=datetime(2026, 3, 28, 12, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ), + ContributionEvent( + service="todo", + event_type="ticket_comment", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:1:comment:1", + occurred_at=datetime(2026, 3, 29, 12, 0, tzinfo=UTC), + weight=0.5, + raw_payload_json=None, + ), + ] + ) + db_session.commit() + + stats = ContributionAggregator().build_stats( + db_session, + "~ccleberg", + date(2026, 3, 28), + date(2026, 3, 30), + ) + + assert stats.total_events == 2 + assert stats.total_score == 1.5 + assert stats.active_days == 2 + assert stats.longest_streak == 2 + assert stats.current_streak == 0 diff --git a/tests/test_contributions_api.py b/tests/test_contributions_api.py new file mode 100644 index 0000000..d90aceb --- /dev/null +++ b/tests/test_contributions_api.py @@ -0,0 +1,120 @@ +from datetime import UTC, datetime + +from fastapi.testclient import TestClient + +from srht_contrib.main import create_app +from srht_contrib.models import ContributionEvent + + +def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None: + app = create_app(settings, engine=db_engine, session_factory=session_factory) + with TestClient(app) as open_client: + response = open_client.get("/health") + assert response.status_code == 200 + + with TestClient(app) as unauthorized: + unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") + assert unauthorized_response.status_code == 401 + + with TestClient(app) as invalid: + invalid.headers.update({"X-API-Key": "wrong-key"}) + invalid_response = invalid.get("/api/repositories") + assert invalid_response.status_code == 401 + + +def test_contributions_api_returns_zero_filled_range(client: TestClient, db_session) -> None: + db_session.add( + ContributionEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:ticket:1:created", + occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ) + ) + db_session.commit() + + response = client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") + + assert response.status_code == 200 + assert response.json()["days"] == [ + {"date": "2026-03-28", "count": 0, "score": 0.0}, + {"date": "2026-03-29", "count": 0, "score": 0.0}, + {"date": "2026-03-30", "count": 1, "score": 1.0}, + ] + + +def test_contribution_stats_api(client: TestClient, db_session) -> None: + db_session.add_all( + [ + ContributionEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:ticket:1:created", + occurred_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ), + ContributionEvent( + service="todo", + event_type="ticket_comment", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:ticket:1:comment:2", + occurred_at=datetime(2026, 3, 30, 11, 0, tzinfo=UTC), + weight=0.5, + raw_payload_json=None, + ), + ] + ) + db_session.commit() + + response = client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30") + + assert response.status_code == 200 + assert response.json()["total_events"] == 2 + assert response.json()["total_score"] == 1.5 + assert response.json()["longest_streak"] == 2 + assert response.json()["current_streak"] == 2 + + +def test_invalid_date_input_returns_400(client: TestClient) -> None: + response = client.get("/api/contributions/~ccleberg?from=2026-13-01&to=2026-03-30") + + assert response.status_code == 400 + assert "Invalid date format" in response.json()["detail"] + + +def test_contribution_routes_use_settings_backed_alias_resolution(settings, db_engine, session_factory) -> None: + alias_settings = settings.model_copy(update={"actor_aliases_json": {"~ccleberg": ["cmc@example.com"]}}) + app = create_app(alias_settings, engine=db_engine, session_factory=session_factory) + with session_factory() as session: + session.add( + ContributionEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name=None, + resource_id="1", + external_uid="todo:alias:1", + occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ) + ) + session.commit() + + with TestClient(app) as client: + client.headers.update({"X-API-Key": alias_settings.api_key}) + response = client.get("/api/contributions/cmc@example.com?from=2026-03-30&to=2026-03-30") + + assert response.status_code == 200 + assert response.json()["actor"] == "~ccleberg" diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..763bc2b --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,15 @@ +from fastapi.testclient import TestClient + +from srht_contrib.main import create_app + + +def test_health_endpoint() -> None: + with TestClient(create_app()) as client: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_scheduler_is_disabled_by_default(client: TestClient) -> None: + assert client.app.state.scheduler is None diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py new file mode 100644 index 0000000..2322b4a --- /dev/null +++ b/tests/test_ingestion.py @@ -0,0 +1,337 @@ +from datetime import UTC, datetime + +from sqlalchemy import select + +from srht_contrib.config import Settings +from srht_contrib.jobs.poller import PollerService +from srht_contrib.models import SyncState, TrackedRepository +from srht_contrib.schemas import NormalizedEvent +from srht_contrib.services.git import GitIngestionService, GitPollResult +from srht_contrib.services.todo import TodoIngestionService, TodoPollResult + + +class StubClient: + def __init__(self, payload: dict | None = None, payloads_by_query: dict[str, dict] | None = None) -> None: + self.payload = payload or {} + self.payloads_by_query = payloads_by_query or {} + self.calls: list[tuple[str, dict | None]] = [] + + def execute(self, query: str, variables: dict | None = None) -> dict: + self.calls.append((query, variables)) + for marker, payload in self.payloads_by_query.items(): + if marker in query: + return payload + return self.payload + + +class RecordingTodoService: + service_name = "todo" + + def __init__(self, events_by_call: list[list[NormalizedEvent]]) -> None: + self.events_by_call = events_by_call + self.calls: list[datetime] = [] + + def fetch_recent_events(self, actor: str, since: datetime | None = None) -> TodoPollResult: + assert since is not None + self.calls.append(since) + events = self.events_by_call.pop(0) + return TodoPollResult(events=events, cursor="2026-03-31T00:00:00+00:00") + + +class EmptyGitService: + service_name = "git" + + def __init__(self) -> None: + self.settings = Settings( + SRHT_TOKEN="x", + DATABASE_URL="sqlite://", + DEFAULT_ACTOR="~ccleberg", + TODO_SRHT_ENDPOINT="https://todo.sr.ht/query", + GIT_SRHT_ENDPOINT="https://git.sr.ht/query", + POLL_INTERVAL_SECONDS=60, + ) + + def fetch_recent_events(self, actor: str, since: datetime | None = None, repositories=None) -> GitPollResult: + return GitPollResult(events=[], cursor="2026-03-31T00:00:00+00:00") + + +def make_settings(**overrides) -> Settings: + values = { + "API_KEY": "test-api-key", + "ENABLE_SCHEDULER": False, + "SRHT_TOKEN": "x", + "DATABASE_URL": "sqlite://", + "DEFAULT_ACTOR": "~ccleberg", + "TODO_SRHT_ENDPOINT": "https://todo.sr.ht/query", + "GIT_SRHT_ENDPOINT": "https://git.sr.ht/query", + "POLL_INTERVAL_SECONDS": 60, + } + values.update(overrides) + return Settings(**values) + + +def test_todo_ingestion_is_idempotent(db_session) -> None: + settings = make_settings() + payload = { + "me": {"canonicalName": "~ccleberg"}, + "events": { + "results": [ + { + "id": "1001", + "created": "2026-03-29T10:00:00Z", + "ticket": { + "id": "123", + "ref": "~ccleberg/todo/123", + "status": "RESOLVED", + "resolution": "CLOSED", + "tracker": {"name": "todo"}, + }, + "changes": [ + { + "__typename": "Created", + "eventType": "CREATED", + "ticket": {"id": "123"}, + "author": {"canonicalName": "~ccleberg"}, + } + ], + }, + { + "id": "1002", + "created": "2026-03-30T09:00:00Z", + "ticket": { + "id": "123", + "ref": "~ccleberg/todo/123", + "status": "RESOLVED", + "resolution": "CLOSED", + "tracker": {"name": "todo"}, + }, + "changes": [ + { + "__typename": "Comment", + "eventType": "COMMENT", + "ticket": {"id": "123"}, + "author": {"canonicalName": "~ccleberg"}, + } + ], + }, + { + "id": "1003", + "created": "2026-03-30T10:00:00Z", + "ticket": { + "id": "123", + "ref": "~ccleberg/todo/123", + "status": "RESOLVED", + "resolution": "CLOSED", + "tracker": {"name": "todo"}, + }, + "changes": [ + { + "__typename": "StatusChange", + "eventType": "STATUS_CHANGE", + "ticket": {"id": "123"}, + "editor": {"canonicalName": "~ccleberg"}, + "oldStatus": "IN_PROGRESS", + "newStatus": "RESOLVED", + "oldResolution": "UNRESOLVED", + "newResolution": "CLOSED", + } + ], + }, + ], + "cursor": None, + }, + } + + todo_service = TodoIngestionService(StubClient(payload), settings) + git_service = GitIngestionService(StubClient(payload={}), settings) + poller = PollerService(todo_service=todo_service, git_service=git_service) + + first_inserted = poller.poll_all(db_session, "~ccleberg") + second_inserted = poller.poll_all(db_session, "~ccleberg") + + assert first_inserted == 3 + assert second_inserted == 0 + + +def test_todo_ingestion_falls_back_to_tracker_crawl(db_session) -> None: + settings = make_settings() + client = StubClient( + payloads_by_query={ + "query TodoActivity": { + "me": {"canonicalName": "~ccleberg"}, + "events": {"results": [], "cursor": None}, + }, + "query TodoTrackers": { + "me": { + "canonicalName": "~ccleberg", + "trackers": {"results": [{"id": "1", "rid": "tracker-rid", "name": "todo"}], "cursor": None}, + } + }, + "query TodoTrackerTickets": { + "tracker": { + "id": "1", + "name": "todo", + "tickets": { + "results": [ + { + "id": 123, + "ref": "~ccleberg/todo/123", + "created": "2026-03-29T09:00:00Z", + "updated": "2026-03-30T09:00:00Z", + "status": "RESOLVED", + "resolution": "CLOSED", + "submitter": {"canonicalName": "~ccleberg"}, + } + ], + "cursor": None, + }, + } + }, + "query TodoTicketEvents": { + "tracker": { + "ticket": { + "id": 123, + "ref": "~ccleberg/todo/123", + "status": "RESOLVED", + "resolution": "CLOSED", + "events": { + "results": [ + { + "id": "evt-1", + "created": "2026-03-30T09:00:00Z", + "changes": [ + { + "__typename": "Comment", + "eventType": "COMMENT", + "ticket": {"id": "123"}, + "author": {"canonicalName": "~ccleberg"}, + } + ], + } + ], + "cursor": None, + }, + } + } + }, + } + ) + todo_service = TodoIngestionService(client, settings) + git_service = GitIngestionService(StubClient(payload={}), settings) + poller = PollerService(todo_service=todo_service, git_service=git_service) + + inserted = poller.poll_all(db_session, "~ccleberg") + + assert inserted == 1 + assert any("query TodoTrackers" in call[0] for call in client.calls) + + +def test_unsupported_todo_changes_are_ignored(db_session) -> None: + settings = make_settings() + payload = { + "me": {"canonicalName": "~ccleberg"}, + "events": { + "results": [ + { + "id": "1001", + "created": "2026-03-29T10:00:00Z", + "ticket": { + "id": "123", + "ref": "~ccleberg/todo/123", + "status": "OPEN", + "resolution": "UNRESOLVED", + "tracker": {"name": "todo"}, + }, + "changes": [ + {"__typename": "LabelUpdate", "eventType": "LABEL_UPDATE", "ticket": {"id": "123"}}, + {"__typename": "TicketMention", "eventType": "TICKET_MENTION", "ticket": {"id": "123"}}, + ], + } + ], + "cursor": None, + }, + } + + todo_service = TodoIngestionService(StubClient(payload), settings) + git_service = GitIngestionService(StubClient(payload={}), settings) + poller = PollerService(todo_service=todo_service, git_service=git_service) + + inserted = poller.poll_all(db_session, "~ccleberg") + + assert inserted == 0 + + +def test_git_ingestion_normalizes_commit_aliases_and_repository_names(db_session) -> None: + settings = make_settings( + ACTOR_ALIASES_JSON={"~ccleberg": ["cmc@example.com", "Chris Cleberg"]}, + GIT_TRACKED_REPOSITORIES=["Hutch"], + ) + git_payload = { + "user": { + "repository": { + "name": "Hutch", + "owner": {"canonicalName": "~ccleberg"}, + "log": { + "results": [ + { + "id": "abc123", + "shortId": "abc123", + "author": { + "name": "Chris Cleberg", + "email": "cmc@example.com", + "time": "2026-03-30T12:00:00Z", + }, + "committer": { + "name": "Chris Cleberg", + "email": "cmc@example.com", + "time": "2026-03-30T12:00:00Z", + }, + "message": "Add contribution calendar", + } + ], + "cursor": None, + }, + } + } + } + + todo_service = TodoIngestionService( + StubClient(payload={"me": {"canonicalName": "~ccleberg"}, "events": {"results": [], "cursor": None}}), + settings, + ) + git_service = GitIngestionService(StubClient(payloads_by_query={"query RepositoryLog": git_payload}), settings) + poller = PollerService(todo_service=todo_service, git_service=git_service) + + inserted = poller.poll_all(db_session, "~ccleberg") + + assert inserted == 1 + + tracked_repositories = db_session.scalars(select(TrackedRepository.repo_name)).all() + assert tracked_repositories == ["~ccleberg/Hutch"] + + +def test_sync_overlap_reuses_cursor_window_and_suppresses_duplicates(db_session) -> None: + event = NormalizedEvent( + service="todo", + event_type="ticket_created", + actor="~ccleberg", + repo_name="todo", + resource_id="123", + external_uid="todo:event:123:created:123", + occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ) + todo_service = RecordingTodoService(events_by_call=[[event], [event]]) + poller = PollerService(todo_service=todo_service, git_service=EmptyGitService()) + + first_inserted = poller.poll_all(db_session, "~ccleberg") + second_inserted = poller.poll_all(db_session, "~ccleberg") + + state = db_session.scalar(select(SyncState).where(SyncState.service == "todo").where(SyncState.actor == "~ccleberg")) + + assert first_inserted == 1 + assert second_inserted == 0 + assert state is not None + assert len(todo_service.calls) == 2 + assert todo_service.calls[1].isoformat() == "2026-03-30T00:00:00+00:00" diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..de9cb25 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,117 @@ +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from srht_contrib.config import Settings + + +def test_alembic_upgrade_creates_schema(tmp_path) -> None: + database_path = tmp_path / "migrated.db" + database_url = f"sqlite:///{database_path}" + config = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini")) + config.set_main_option("script_location", str(Path(__file__).resolve().parent.parent / "alembic")) + config.set_main_option("sqlalchemy.url", database_url) + + command.upgrade(config, "head") + + inspector = inspect(create_engine(database_url)) + assert sorted(inspector.get_table_names()) == [ + "actor_aliases", + "alembic_version", + "contribution_events", + "sync_states", + "tracked_repositories", + ] + + +def test_alembic_upgrade_adopts_legacy_schema(tmp_path) -> None: + database_path = tmp_path / "legacy.db" + database_url = f"sqlite:///{database_path}" + engine = create_engine(database_url) + with engine.begin() as connection: + connection.execute( + text( + """ + CREATE TABLE contribution_events ( + id INTEGER NOT NULL PRIMARY KEY, + service VARCHAR(32) NOT NULL, + event_type VARCHAR(64) NOT NULL, + actor VARCHAR(255) NOT NULL, + repo_name VARCHAR(255), + resource_id VARCHAR(255) NOT NULL, + external_uid VARCHAR(255) NOT NULL, + occurred_at DATETIME NOT NULL, + weight FLOAT NOT NULL, + raw_payload_json JSON, + CONSTRAINT uq_contribution_event_service_uid UNIQUE (service, external_uid) + ) + """ + ) + ) + connection.execute(text("CREATE INDEX ix_contribution_events_actor_occurred_at ON contribution_events (actor, occurred_at)")) + connection.execute(text("CREATE INDEX ix_contribution_events_service_occurred_at ON contribution_events (service, occurred_at)")) + connection.execute( + text( + """ + CREATE TABLE sync_states ( + id INTEGER NOT NULL PRIMARY KEY, + service VARCHAR(32) NOT NULL, + actor VARCHAR(255) NOT NULL, + cursor_value TEXT, + updated_at DATETIME NOT NULL, + CONSTRAINT uq_sync_state_service_actor UNIQUE (service, actor) + ) + """ + ) + ) + connection.execute( + text( + """ + CREATE TABLE tracked_repositories ( + id INTEGER NOT NULL PRIMARY KEY, + service VARCHAR(32) NOT NULL, + repo_name VARCHAR(255) NOT NULL, + actor VARCHAR(255), + CONSTRAINT uq_tracked_repository_service_name UNIQUE (service, repo_name) + ) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO tracked_repositories (id, service, repo_name, actor) + VALUES (1, 'git', 'Hutch', NULL) + """ + ) + ) + connection.execute( + text( + """ + CREATE TABLE actor_aliases ( + id INTEGER NOT NULL PRIMARY KEY, + canonical_actor VARCHAR(255) NOT NULL, + alias VARCHAR(255) NOT NULL, + CONSTRAINT uq_actor_alias_alias UNIQUE (alias) + ) + """ + ) + ) + + config = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini")) + config.set_main_option("script_location", str(Path(__file__).resolve().parent.parent / "alembic")) + config.set_main_option("sqlalchemy.url", database_url) + + command.upgrade(config, "head") + + inspector = inspect(create_engine(database_url)) + columns = {column["name"]: column for column in inspector.get_columns("tracked_repositories")} + unique_constraints = {constraint["name"] for constraint in inspector.get_unique_constraints("tracked_repositories")} + with create_engine(database_url).connect() as connection: + actor = connection.execute(text("SELECT actor FROM tracked_repositories WHERE id = 1")).scalar_one() + + assert columns["actor"]["nullable"] is False + assert "uq_tracked_repository_service_actor_name" in unique_constraints + assert actor == Settings().default_actor diff --git a/tests/test_polling_api.py b/tests/test_polling_api.py new file mode 100644 index 0000000..b07b877 --- /dev/null +++ b/tests/test_polling_api.py @@ -0,0 +1,71 @@ +from datetime import UTC, datetime + +from fastapi.testclient import TestClient + +from srht_contrib.config import Settings +from srht_contrib.main import create_app +from srht_contrib.models import ContributionEvent +from srht_contrib.services.srht_client import SourceHutClientError + + +class _Closable: + def close(self) -> None: + return None + + +class InsertingPoller: + def __init__(self) -> None: + service = type("Service", (), {"client": _Closable()})() + self.todo_service = service + self.git_service = service + + def poll_all(self, db, actor: str) -> int: + db.add( + ContributionEvent( + service="todo", + event_type="ticket_created", + actor=actor, + repo_name="todo", + resource_id="1", + external_uid="todo:manual:1", + occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC), + weight=1.0, + raw_payload_json=None, + ) + ) + db.commit() + return 1 + + +class FailingPoller: + def __init__(self) -> None: + service = type("Service", (), {"client": _Closable()})() + self.todo_service = service + self.git_service = service + + def poll_all(self, db, actor: str) -> int: + raise SourceHutClientError("boom") + + +def test_manual_poll_uses_same_database_session(settings: Settings, db_engine, session_factory) -> None: + app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=InsertingPoller()) + with TestClient(app) as client: + client.headers.update({"X-API-Key": settings.api_key}) + + poll_response = client.post("/api/contributions/poll?actor=~ccleberg") + calendar_response = client.get("/api/contributions/~ccleberg?from=2026-03-30&to=2026-03-30") + + assert poll_response.status_code == 200 + assert poll_response.json()["inserted_events"] == 1 + assert calendar_response.status_code == 200 + assert calendar_response.json()["days"] == [{"date": "2026-03-30", "count": 1, "score": 1.0}] + + +def test_manual_poll_maps_sourcehut_failures_to_502(settings: Settings, db_engine, session_factory) -> None: + app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=FailingPoller()) + with TestClient(app) as client: + client.headers.update({"X-API-Key": settings.api_key}) + response = client.post("/api/contributions/poll?actor=~ccleberg") + + assert response.status_code == 502 + assert "SourceHut polling failed" in response.json()["detail"] diff --git a/tests/test_repositories_api.py b/tests/test_repositories_api.py new file mode 100644 index 0000000..c2f9060 --- /dev/null +++ b/tests/test_repositories_api.py @@ -0,0 +1,77 @@ +from fastapi.testclient import TestClient + + +def test_create_list_get_update_delete_tracked_repositories(client: TestClient) -> None: + create_response = client.post( + "/api/repositories", + json={"actor": "~ccleberg", "repo_name": "Hutch"}, + ) + + assert create_response.status_code == 201 + assert create_response.json()["repo_name"] == "~ccleberg/Hutch" + + repository_id = create_response.json()["id"] + + get_response = client.get(f"/api/repositories/{repository_id}") + assert get_response.status_code == 200 + assert get_response.json()["repo_name"] == "~ccleberg/Hutch" + + update_response = client.patch( + f"/api/repositories/{repository_id}", + json={"repo_name": "~ccleberg/cleberg.net"}, + ) + assert update_response.status_code == 200 + assert update_response.json()["repo_name"] == "~ccleberg/cleberg.net" + + list_response = client.get("/api/repositories?actor=~ccleberg") + assert list_response.status_code == 200 + assert list_response.json() == [ + { + "id": repository_id, + "service": "git", + "actor": "~ccleberg", + "repo_name": "~ccleberg/cleberg.net", + } + ] + + delete_response = client.delete(f"/api/repositories/{repository_id}") + assert delete_response.status_code == 204 + + missing_response = client.get(f"/api/repositories/{repository_id}") + assert missing_response.status_code == 404 + + +def test_repository_validation_and_conflicts(client: TestClient) -> None: + invalid_response = client.post( + "/api/repositories", + json={"actor": "~ccleberg", "repo_name": " "}, + ) + assert invalid_response.status_code == 422 + + first_response = client.post( + "/api/repositories", + json={"actor": "~ccleberg", "repo_name": "Hutch"}, + ) + second_response = client.post( + "/api/repositories", + json={"actor": "~ccleberg", "repo_name": "~ccleberg/Hutch"}, + ) + + assert first_response.status_code == 201 + assert second_response.status_code == 409 + + +def test_same_repository_can_be_tracked_by_different_actors(client: TestClient) -> None: + first_response = client.post( + "/api/repositories", + json={"actor": "~ccleberg", "repo_name": "Hutch"}, + ) + second_response = client.post( + "/api/repositories", + json={"actor": "~other", "repo_name": "Hutch"}, + ) + + assert first_response.status_code == 201 + assert second_response.status_code == 201 + assert first_response.json()["repo_name"] == "~ccleberg/Hutch" + assert second_response.json()["repo_name"] == "~other/Hutch" diff --git a/tests/test_srht_client.py b/tests/test_srht_client.py new file mode 100644 index 0000000..e7ece0d --- /dev/null +++ b/tests/test_srht_client.py @@ -0,0 +1,56 @@ +import httpx +import pytest + +from srht_contrib.services.srht_client import SourceHutClientError, SourceHutGraphQLClient + + +def test_graphql_client_retries_http_5xx_and_succeeds() -> None: + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + if attempts["count"] == 1: + return httpx.Response(502, json={"error": "bad gateway"}) + return httpx.Response(200, json={"data": {"ok": True}}) + + client = SourceHutGraphQLClient( + "https://todo.sr.ht/query", + "token", + transport=httpx.MockTransport(handler), + ) + + data = client.execute("query Ping { ping }") + + assert data == {"ok": True} + assert attempts["count"] == 2 + client.close() + + +def test_graphql_client_raises_for_graphql_errors() -> None: + client = SourceHutGraphQLClient( + "https://todo.sr.ht/query", + "token", + transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"errors": [{"message": "nope"}]})), + ) + + with pytest.raises(SourceHutClientError): + client.execute("query Ping { ping }") + + client.close() + + +def test_graphql_client_raises_for_network_errors() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + client = SourceHutGraphQLClient( + "https://todo.sr.ht/query", + "token", + max_retries=0, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(SourceHutClientError): + client.execute("query Ping { ping }") + + client.close() -- cgit v1.2.3