mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
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
204 lines
7.7 KiB
Python
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() == ()
|