Skip to content

Commit 8f65950

Browse files
authored
fix(api): compute history aggregates across all matches (#95)
2 parents d02d64d + 142cae3 commit 8f65950

2 files changed

Lines changed: 194 additions & 28 deletions

File tree

packages/hermes-api/app/api/v1/endpoints/history.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -187,51 +187,52 @@ async def get_download_history(
187187
for record in history_records
188188
]
189189

190-
# Calculate statistics (for all records, not just current page)
190+
# Calculate statistics for all matching records, not just current page.
191191
stats_query = select(
192192
func.count().label("total"),
193193
func.avg(DownloadHistoryModel.duration).label("avg_duration"),
194194
func.sum(DownloadHistoryModel.file_size).label("total_size"),
195195
func.sum(
196196
func.cast(DownloadHistoryModel.status == "completed", type_=Integer)
197197
).label("successful"),
198-
)
198+
).select_from(DownloadHistoryModel)
199199
if filters:
200200
stats_query = stats_query.where(and_(*filters))
201201

202-
# Simplified statistics for now
203-
total_downloads = total_count
204-
success_rate = 0.0
205-
avg_time = 0.0
206-
total_size = 0
202+
stats_result = await db_session.execute(stats_query)
203+
stats = stats_result.one()
207204

208-
if total_downloads > 0:
209-
completed_count = sum(1 for r in history_records if r.status == "completed")
210-
success_rate = (
211-
completed_count / len(history_records) if history_records else 0
212-
)
213-
durations = [r.duration for r in history_records if r.duration]
214-
avg_time = sum(durations) / len(durations) if durations else 0
215-
sizes = [r.file_size for r in history_records if r.file_size]
216-
total_size = sum(sizes)
205+
total_downloads = stats.total or 0
206+
successful_downloads = stats.successful or 0
207+
success_rate = (
208+
successful_downloads / total_downloads if total_downloads > 0 else 0.0
209+
)
210+
avg_time = stats.avg_duration or 0.0
211+
total_size = stats.total_size or 0
212+
213+
# Get popular extractors for all matching records.
214+
extractor_query = select(
215+
DownloadHistoryModel.extractor,
216+
func.count().label("count"),
217+
).select_from(DownloadHistoryModel)
218+
if filters:
219+
extractor_query = extractor_query.where(and_(*filters))
220+
extractor_query = (
221+
extractor_query.group_by(DownloadHistoryModel.extractor)
222+
.order_by(func.count().desc())
223+
.limit(5)
224+
)
217225

218-
# Get popular extractors (simplified)
226+
extractor_result = await db_session.execute(extractor_query)
219227
popular_extractors = []
220-
extractor_counts = {}
221-
for record in history_records:
222-
ext = record.extractor or "unknown"
223-
extractor_counts[ext] = extractor_counts.get(ext, 0) + 1
224-
225-
for ext, count in sorted(
226-
extractor_counts.items(), key=lambda x: x[1], reverse=True
227-
)[:5]:
228+
for extractor, count in extractor_result.all():
228229
popular_extractors.append(
229230
PopularExtractor(
230-
extractor=ext,
231+
extractor=extractor or "unknown",
231232
count=count,
232233
percentage=(
233-
round((count / len(history_records)) * 100, 2)
234-
if history_records
234+
round((count / total_downloads) * 100, 2)
235+
if total_downloads
235236
else 0
236237
),
237238
)
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Tests for download history endpoints."""
2+
3+
from datetime import datetime, timedelta, timezone
4+
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock
6+
7+
import pytest
8+
from httpx import AsyncClient
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
from app.api.v1.endpoints import history as history_endpoint
12+
from app.db.models import DownloadHistory
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_history_statistics_use_all_matching_records_not_current_page(
17+
client: AsyncClient, db_session: AsyncSession
18+
):
19+
now = datetime.now(timezone.utc)
20+
records = [
21+
DownloadHistory(
22+
id="history-1",
23+
download_id="download-1",
24+
url="https://example.test/one",
25+
status="completed",
26+
duration=10,
27+
file_size=100,
28+
extractor="youtube",
29+
started_at=now - timedelta(minutes=3),
30+
completed_at=now - timedelta(minutes=2),
31+
),
32+
DownloadHistory(
33+
id="history-2",
34+
download_id="download-2",
35+
url="https://example.test/two",
36+
status="failed",
37+
duration=20,
38+
file_size=0,
39+
extractor="youtube",
40+
started_at=now - timedelta(minutes=2),
41+
completed_at=now - timedelta(minutes=1),
42+
),
43+
DownloadHistory(
44+
id="history-3",
45+
download_id="download-3",
46+
url="https://example.test/three",
47+
status="completed",
48+
duration=30,
49+
file_size=300,
50+
extractor="vimeo",
51+
started_at=now - timedelta(minutes=1),
52+
completed_at=now,
53+
),
54+
]
55+
db_session.add_all(records)
56+
await db_session.commit()
57+
58+
response = await client.get("/api/v1/history/", params={"limit": 1})
59+
60+
assert response.status_code == 200
61+
data = response.json()
62+
assert len(data["items"]) == 1
63+
assert data["totalDownloads"] == 3
64+
assert data["totalItems"] == 3
65+
assert data["successRate"] == 0.667
66+
assert data["averageDownloadTime"] == 20
67+
assert data["totalSize"] == 400
68+
assert data["popularExtractors"][0] == {
69+
"extractor": "youtube",
70+
"count": 2,
71+
"percentage": 66.67,
72+
}
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_history_statistics_are_zero_without_matching_records(
77+
client: AsyncClient, db_session: AsyncSession
78+
):
79+
db_session.add(
80+
DownloadHistory(
81+
id="history-other-extractor",
82+
download_id="download-other-extractor",
83+
url="https://example.test/other",
84+
status="completed",
85+
duration=12,
86+
file_size=120,
87+
extractor="youtube",
88+
started_at=datetime.now(timezone.utc) - timedelta(minutes=1),
89+
completed_at=datetime.now(timezone.utc),
90+
)
91+
)
92+
await db_session.commit()
93+
94+
response = await client.get(
95+
"/api/v1/history/", params={"extractor": "vimeo", "limit": 1}
96+
)
97+
98+
assert response.status_code == 200
99+
data = response.json()
100+
assert data["items"] == []
101+
assert data["totalDownloads"] == 0
102+
assert data["totalItems"] == 0
103+
assert data["successRate"] == 0
104+
assert data["averageDownloadTime"] == 0
105+
assert data["totalSize"] == 0
106+
assert data["popularExtractors"] == []
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_history_endpoint_maps_aggregate_query_results(monkeypatch):
111+
now = datetime.now(timezone.utc)
112+
history_record = SimpleNamespace(
113+
download_id="download-aggregate",
114+
url="https://example.test/aggregate",
115+
status="completed",
116+
started_at=now - timedelta(minutes=1),
117+
completed_at=now,
118+
duration=None,
119+
file_size=25,
120+
extractor=None,
121+
error_message=None,
122+
)
123+
124+
count_result = SimpleNamespace(scalar=lambda: 1)
125+
history_result = SimpleNamespace(
126+
scalars=lambda: SimpleNamespace(all=lambda: [history_record])
127+
)
128+
stats_result = SimpleNamespace(
129+
one=lambda: SimpleNamespace(
130+
total=1,
131+
successful=1,
132+
avg_duration=None,
133+
total_size=25,
134+
)
135+
)
136+
extractor_result = SimpleNamespace(all=lambda: [(None, 1)])
137+
db_session = SimpleNamespace(
138+
execute=AsyncMock(
139+
side_effect=[count_result, history_result, stats_result, extractor_result]
140+
)
141+
)
142+
monkeypatch.setattr(
143+
history_endpoint, "calculate_daily_stats", AsyncMock(return_value=[])
144+
)
145+
146+
history = await history_endpoint.get_download_history(
147+
start_date=None,
148+
end_date=None,
149+
extractor="youtube",
150+
status="completed",
151+
limit=1,
152+
offset=0,
153+
db_session=db_session,
154+
principal=SimpleNamespace(),
155+
)
156+
157+
assert history.total_downloads == 1
158+
assert history.total_items == 1
159+
assert history.success_rate == 1
160+
assert history.average_download_time == 0
161+
assert history.total_size == 25
162+
assert history.items[0].duration == 0
163+
assert history.items[0].extractor == "unknown"
164+
assert history.popular_extractors[0].extractor == "unknown"
165+
assert history.popular_extractors[0].percentage == 100

0 commit comments

Comments
 (0)