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)]