krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users.

clone: git clone https://gitbay.org/krz/hutch-stats.git

main: tests/test_contributions_api.py · raw

  1from datetime import UTC, datetime
  2
  3from fastapi.testclient import TestClient
  4from sqlalchemy import select
  5
  6from srht_contrib.main import create_app
  7from srht_contrib.models import ContributionEvent, TrackedActor
  8
  9
 10class _Closable:
 11    def close(self) -> None:
 12        return None
 13
 14
 15def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None:
 16    app = create_app(settings, engine=db_engine, session_factory=session_factory)
 17    with TestClient(app) as open_client:
 18        response = open_client.get("/health")
 19        public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
 20        public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
 21    assert response.status_code == 200
 22    assert public_contributions.status_code == 200
 23    assert public_stats.status_code == 200
 24
 25    with TestClient(app) as unauthorized:
 26        unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg")
 27    assert unauthorized_response.status_code == 401
 28
 29    with TestClient(app) as invalid:
 30        invalid.headers.update({"X-API-Key": "wrong-key"})
 31        invalid_response = invalid.get("/api/repositories")
 32    assert invalid_response.status_code == 401
 33
 34
 35def test_contributions_api_returns_zero_filled_range(client: TestClient, db_session) -> None:
 36    db_session.add(
 37        ContributionEvent(
 38            service="todo",
 39            event_type="ticket_created",
 40            actor="~ccleberg",
 41            repo_name=None,
 42            resource_id="1",
 43            external_uid="todo:ticket:1:created",
 44            occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC),
 45            weight=1.0,
 46            raw_payload_json=None,
 47        )
 48    )
 49    db_session.commit()
 50
 51    response = client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
 52
 53    assert response.status_code == 200
 54    assert response.json()["is_indexed"] is True
 55    assert response.json()["indexing_state"] == "indexed"
 56    assert response.json()["is_recent_window_backfilled"] is False
 57    assert response.json()["recent_backfill_state"] == "pending"
 58    assert response.json()["days"] == [
 59        {"date": "2026-03-28", "count": 0, "score": 0.0},
 60        {"date": "2026-03-29", "count": 0, "score": 0.0},
 61        {"date": "2026-03-30", "count": 1, "score": 1.0},
 62    ]
 63
 64
 65def test_public_read_registers_actor_for_lazy_indexing(client: TestClient, db_session) -> None:
 66    response = client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
 67
 68    tracked_actor = db_session.scalar(select(TrackedActor).where(TrackedActor.actor == "~ccleberg"))
 69
 70    assert response.status_code == 200
 71    assert response.json()["is_indexed"] is False
 72    assert response.json()["indexing_state"] == "pending"
 73    assert response.json()["is_recent_window_backfilled"] is False
 74    assert response.json()["recent_backfill_state"] == "pending"
 75    assert response.json()["recent_backfill_completed_at"] is None
 76    assert response.json()["last_polled_at"] is None
 77    assert tracked_actor is not None
 78    assert tracked_actor.is_active is True
 79    assert tracked_actor.last_requested_at is not None
 80    assert tracked_actor.priority_boosted_at is None
 81
 82
 83class RecordingPriorityPoller:
 84    def __init__(self) -> None:
 85        service = type("Service", (), {"client": _Closable()})()
 86        self.todo_service = service
 87        self.git_service = service
 88        self.calls: list[tuple[str, bool]] = []
 89
 90    def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True, prioritize: bool = False):
 91        self.calls.append((actor, prioritize))
 92
 93    def poll_all(self, db, actor: str) -> int:
 94        return 0
 95
 96    def poll_tracked_actors(self, db, default_actor: str | None = None) -> dict[str, int]:
 97        return {}
 98
 99
100def test_contribution_stats_api(client: TestClient, db_session) -> None:
101    db_session.add_all(
102        [
103            ContributionEvent(
104                service="todo",
105                event_type="ticket_created",
106                actor="~ccleberg",
107                repo_name=None,
108                resource_id="1",
109                external_uid="todo:ticket:1:created",
110                occurred_at=datetime(2026, 3, 29, 10, 0, tzinfo=UTC),
111                weight=1.0,
112                raw_payload_json=None,
113            ),
114            ContributionEvent(
115                service="todo",
116                event_type="ticket_comment",
117                actor="~ccleberg",
118                repo_name=None,
119                resource_id="1",
120                external_uid="todo:ticket:1:comment:2",
121                occurred_at=datetime(2026, 3, 30, 11, 0, tzinfo=UTC),
122                weight=0.5,
123                raw_payload_json=None,
124            ),
125        ]
126    )
127    db_session.commit()
128
129    response = client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
130
131    assert response.status_code == 200
132    assert response.json()["total_events"] == 2
133    assert response.json()["total_score"] == 1.5
134    assert response.json()["longest_streak"] == 2
135    assert response.json()["current_streak"] == 2
136    assert response.json()["is_indexed"] is True
137    assert response.json()["indexing_state"] == "indexed"
138    assert response.json()["is_recent_window_backfilled"] is False
139    assert response.json()["recent_backfill_state"] == "pending"
140
141
142def test_invalid_date_input_returns_400(client: TestClient) -> None:
143    response = client.get("/api/contributions/~ccleberg?from=2026-13-01&to=2026-03-30")
144
145    assert response.status_code == 400
146    assert "Invalid date format" in response.json()["detail"]
147
148
149def test_contribution_routes_use_settings_backed_alias_resolution(settings, db_engine, session_factory) -> None:
150    alias_settings = settings.model_copy(update={"actor_aliases_json": {"~ccleberg": ["cmc@example.com"]}})
151    app = create_app(alias_settings, engine=db_engine, session_factory=session_factory)
152    with session_factory() as session:
153        session.add(
154            ContributionEvent(
155                service="todo",
156                event_type="ticket_created",
157                actor="~ccleberg",
158                repo_name=None,
159                resource_id="1",
160                external_uid="todo:alias:1",
161                occurred_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC),
162                weight=1.0,
163                raw_payload_json=None,
164            )
165        )
166        session.commit()
167
168    with TestClient(app) as client:
169        client.headers.update({"X-API-Key": alias_settings.api_key})
170        response = client.get("/api/contributions/cmc@example.com?from=2026-03-30&to=2026-03-30")
171
172    assert response.status_code == 200
173    assert response.json()["actor"] == "~ccleberg"
174
175
176def test_contribution_route_passes_explicit_priority_signal(settings, db_engine, session_factory) -> None:
177    poller = RecordingPriorityPoller()
178    app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=poller)
179
180    with TestClient(app) as client:
181        response = client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30&prioritize_self=true")
182
183    assert response.status_code == 200
184    assert poller.calls == [("~ccleberg", True)]
185
186
187def test_contribution_stats_route_keeps_non_prioritized_registration_by_default(settings, db_engine, session_factory) -> None:
188    poller = RecordingPriorityPoller()
189    app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=poller)
190
191    with TestClient(app) as client:
192        response = client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
193
194    assert response.status_code == 200
195    assert poller.calls == [("~ccleberg", False)]