signal-bot/tests/test_routes_analytics.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

204 lines
7.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The Analytics tab's seven routes.
The empty-database cases matter as much as the populated ones: this page is
most useful exactly when something is wrong, so every panel has to render
against a bot that has never run.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.external import Result
from signal_bot.manage import routes_analytics
from signal_bot.store import Store
from tests.analytics_seed import ALA, BASIA, Shape, a_call, backfilled, ran, reconstructed
from tests.manage_fixture import FIXTURE_GROUP, client_for, deps_at
if TYPE_CHECKING:
from pathlib import Path
import pytest
from signal_bot.manage.deps import ManageDeps
DAY = "2026-08-29"
NEXT = "2026-08-30"
PANELS = ("overview", "time", "health", "demand", "behaviour", "content")
def seeded(tmp_path: Path) -> ManageDeps:
"""A management app pointed at a database with a small history in it."""
deps = deps_at(tmp_path)
with Store(str(deps.database_path)) as store:
for minute in range(3):
ran(
store,
day=DAY,
hour=19,
minute=minute,
who=ALA,
command="pope",
shape=Shape(
group_id=FIXTURE_GROUP,
item_id=f"Q{minute}",
handler_ms=300 + minute,
send_ms=200,
calls=(a_call("commons.example.test", 200, 40),),
),
)
ran(store, day=NEXT, hour=9, who=BASIA, command="cat", shape=Shape(group_id=FIXTURE_GROUP))
reconstructed(store, day=DAY, hour=8, command="dog", who=BASIA, group_id=FIXTURE_GROUP)
backfilled(store, day=DAY, command="gecko", who=ALA, group_id=FIXTURE_GROUP)
store.events.note_person(FIXTURE_GROUP, ALA, "ala", "", DAY)
store.suggestions.record_unknown(FIXTURE_GROUP, "!papiez!", "!papiez!", 5)
return deps
def test_every_panel_renders_against_a_bot_that_never_ran(tmp_path: Path) -> None:
"""A fresh install must show empty panels, never a stack trace."""
client = client_for(deps_at(tmp_path))
for panel in PANELS:
response = client.get(f"/api/analytics/{panel}")
assert response.status_code == 200, panel
body = response.json()
assert body["window"]["events"] == 0
assert client.get("/api/analytics/groups").json() == {"groups": []}
def test_every_panel_renders_against_a_file_of_nonsense(tmp_path: Path) -> None:
"""Half a database is the same answer as none of one."""
deps = deps_at(tmp_path)
deps.database_path.write_bytes(b"this is not a database")
client = client_for(deps)
for panel in PANELS:
assert client.get(f"/api/analytics/{panel}").status_code == 200
def test_the_groups_route_lists_what_has_a_history(tmp_path: Path) -> None:
client = client_for(seeded(tmp_path))
groups = client.get("/api/analytics/groups").json()["groups"]
assert [one["id"] for one in groups] == [FIXTURE_GROUP]
assert groups[0]["events"] == 6
def test_the_window_names_all_three_sources(tmp_path: Path) -> None:
"""The footnote's three numbers, so the page can say what is reconstructed."""
client = client_for(seeded(tmp_path))
window = client.get("/api/analytics/overview").json()["window"]
assert window["sources"] == {"live": 4, "journald": 1, "daily_uses": 1}
assert window["days"] == 2
assert window["enough_for_a_trend"] is False
def test_overview_names_people_rather_than_uuids(tmp_path: Path) -> None:
"""A uuid never appears on the page without a name beside it."""
client = client_for(seeded(tmp_path))
people = client.get("/api/analytics/overview").json()["people"]
named = {one["uuid"]: one["name"] for one in people}
assert named[ALA] == "ala"
assert named[BASIA] == ""
def test_time_returns_dense_buckets(tmp_path: Path) -> None:
"""Twenty-four hours and seven weekdays, empty ones included."""
body = client_for(seeded(tmp_path)).get("/api/analytics/time").json()
assert len(body["hours"]) == 24
assert len(body["weekday_hours"]) == 7
assert body["peak"]["busiest"] == 19
assert body["direction"] == "unknown"
def test_health_reports_timings_and_upstream(tmp_path: Path) -> None:
body = client_for(seeded(tmp_path)).get("/api/analytics/health").json()
assert body["latencies"][0]["command"] == "pope"
assert body["upstream"][0]["host"] == "commons.example.test"
assert body["upstream"][0]["failures"] == 0
def test_demand_groups_a_typo_by_what_it_nearly_hit(tmp_path: Path) -> None:
body = client_for(seeded(tmp_path)).get("/api/analytics/demand").json()
assert [one["target"] for one in body["near_misses"]] == ["!papiez"]
def test_behaviour_reports_sittings_and_share(tmp_path: Path) -> None:
body = client_for(seeded(tmp_path)).get("/api/analytics/behaviour").json()
assert body["sessions"]["count"] >= 1
assert "fraction" in body["share"]
def test_content_reports_pool_variety(tmp_path: Path) -> None:
body = client_for(seeded(tmp_path)).get("/api/analytics/content").json()
assert body["pools"][0] == {
"command": "pope",
"draws": 3,
"distinct": 3,
"repeat_rate": 0.0,
}
def test_a_group_filter_narrows_the_answer(tmp_path: Path) -> None:
"""Absent means the roll-up; present means one group, same SQL either way."""
client = client_for(seeded(tmp_path))
everywhere = client.get("/api/analytics/overview").json()
here = client.get("/api/analytics/overview", params={"group": FIXTURE_GROUP}).json()
nowhere = client.get("/api/analytics/overview", params={"group": "group.NOTHING"}).json()
assert everywhere["window"]["events"] == here["window"]["events"]
assert nowhere["window"]["events"] == 0
def test_the_triggers_come_from_the_alias_file() -> None:
"""A second process with no registry in it still knows every trigger."""
triggers = routes_analytics.triggers()
assert "!analityka" in triggers
assert "!kot" in triggers
def test_release_dates_come_from_the_tags_and_degrade_to_nothing() -> None:
"""The changelog says what shipped; only the tags say when.
Never raises: a checkout with no tags, or a deployment that is not a git
tree at all, comes back empty and the timeline draws unannotated.
"""
for version, day in routes_analytics.releases():
assert version[0].isdigit()
assert len(day) == len("2026-08-31")
def test_a_database_that_cannot_be_opened_is_the_empty_shape(tmp_path: Path) -> None:
"""A file that exists and refuses to open is still just "nothing to show".
Distinct from a corrupt one: sqlite opens a garbage file happily and
fails on the first query, so this covers the other half -- a permissions
problem, which raises at connect.
"""
deps = deps_at(tmp_path)
deps.database_path.write_bytes(b"")
deps.database_path.chmod(0o000)
try:
body = client_for(deps).get("/api/analytics/overview").json()
finally:
deps.database_path.chmod(0o600)
assert body["window"]["events"] == 0
def test_release_parsing_ignores_anything_that_is_not_a_version_tag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Tags are not all releases, and a failed shell-out is not a crash."""
monkeypatch.setattr(
routes_analytics,
"run",
lambda *_args, **_kwargs: Result(
ok=True, stdout="v0.2.5 2026-08-31\nnot-a-release 2026-08-30\nv0.1.0\n", stderr=""
),
)
assert routes_analytics.releases() == (("0.2.5", "2026-08-31"),)
monkeypatch.setattr(
routes_analytics, "run", lambda *_a, **_k: Result(ok=False, stdout="", stderr="no git")
)
assert routes_analytics.releases() == ()