summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/conftest.py58
-rw-r--r--tests/test_aggregator.py79
-rw-r--r--tests/test_contributions_api.py120
-rw-r--r--tests/test_health.py15
-rw-r--r--tests/test_ingestion.py337
-rw-r--r--tests/test_migrations.py117
-rw-r--r--tests/test_polling_api.py71
-rw-r--r--tests/test_repositories_api.py77
-rw-r--r--tests/test_srht_client.py56
9 files changed, 930 insertions, 0 deletions
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
+
+
+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=[],
+ )
+
+
+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)
+
+
+def session_factory(db_engine) -> sessionmaker[Session]:
+ return sessionmaker(bind=db_engine, autoflush=False, autocommit=False, expire_on_commit=False)
+
+
+def db_session(session_factory: sessionmaker[Session]) -> Generator[Session, None, None]:
+ session = session_factory()
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+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": ["[email protected]"]}})
+ 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/[email protected]?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": ["[email protected]", "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": "[email protected]",
+ "time": "2026-03-30T12:00:00Z",
+ },
+ "committer": {
+ "name": "Chris Cleberg",
+ "email": "[email protected]",
+ "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()