signal-bot/tests/test_store.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

216 lines
7.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""SQLite persistence, including survival across a restart."""
from __future__ import annotations
import sqlite3
from typing import TYPE_CHECKING
from signal_bot.schema import SCHEMA_VERSION
from signal_bot.stats.events import MessageEvent
from signal_bot.store import Store, open_store
if TYPE_CHECKING:
from pathlib import Path
GROUP = "group-a=="
OTHER = "group-b=="
def event(
uuid: str = "u1",
name: str = "Alice",
at: int = 1,
day: str = "2026-08-29",
group_id: str = GROUP,
) -> MessageEvent:
"""One minimal event. ``at`` also disambiguates the primary key."""
return MessageEvent(
group_id=group_id,
uuid=uuid,
display_name=name,
sent_at_ms=at,
day=day,
local_hour=12,
)
def counted(store: Store, group_id: str) -> int:
"""How many events one group holds."""
row = store.events.connection.execute(
"SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (group_id,)
).fetchone()
return int(row["n"])
def test_last_used_is_none_before_first_use(store: Store) -> None:
assert store.last_used(GROUP, "u1", "cat") is None
def test_record_use_tracks_cooldown_and_daily(store: Store) -> None:
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
assert store.last_used(GROUP, "u1", "cat") == 100.0
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1
store.record_use(GROUP, "u1", "cat", 200.0, "2026-08-29")
assert store.last_used(GROUP, "u1", "cat") == 200.0
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 2
def test_daily_counter_is_per_day(store: Store) -> None:
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
assert store.uses_today(GROUP, "u1", "cat", "2026-08-30") == 0
def test_uses_today_is_per_user_and_command(store: Store) -> None:
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
assert store.uses_today(GROUP, "u2", "cat", "2026-08-29") == 0
assert store.uses_today(GROUP, "u1", "dog", "2026-08-29") == 0
def test_votes_are_deduplicated_per_person(store: Store) -> None:
store.add_vote(GROUP, "kot", "u1", 100.0)
store.add_vote(GROUP, "kot", "u1", 101.0)
store.add_vote(GROUP, "kot", "u2", 102.0)
assert store.count_votes(GROUP, "kot", 0.0) == 2
def test_votes_outside_the_window_are_ignored(store: Store) -> None:
store.add_vote(GROUP, "cmd", "u1", 100.0)
assert store.count_votes(GROUP, "cmd", 150.0) == 0
def test_clear_votes(store: Store) -> None:
store.add_vote(GROUP, "cmd", "u1", 100.0)
store.clear_votes(GROUP, "cmd")
assert store.count_votes(GROUP, "cmd", 0.0) == 0
def test_store_is_a_context_manager(tmp_path: Path) -> None:
with Store(str(tmp_path / "x.sqlite3")) as opened:
opened.events.record(event())
assert opened.meta.__self__ is opened
def test_state_is_scoped_per_group(store: Store) -> None:
"""The whole point of the group key: two groups never share a counter."""
store.events.record(event(at=1))
store.events.record(event(at=1, group_id=OTHER))
store.events.record(event(at=2, group_id=OTHER))
assert counted(store, GROUP) == 1
assert counted(store, OTHER) == 2
def test_forget_group_keeps_the_history(store: Store) -> None:
"""The reversal 0.1.0 turns on, and why it matters.
A group listing that transiently omits members makes discovery drop the
group, which calls this. Before 0.1.0 that cost a day's counter; against
a permanent history it would delete every statistic the group has.
"""
store.events.record(event())
store.events.note_person(GROUP, "u1", "Alice", "", "2026-08-29")
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
store.add_vote(GROUP, "cmd", "u1", 100.0)
store.set_meta(GROUP, "announced_version", "0.0.7")
store.forget_group(GROUP)
assert store.last_used(GROUP, "u1", "cat") is None
assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 0
assert store.count_votes(GROUP, "cmd", 0.0) == 0
assert store.meta(GROUP, "announced_version") is None
assert counted(store, GROUP) == 1
assert [p.display_name for p in store.events.people(GROUP)] == ["Alice"]
def test_rate_limits_are_scoped_per_group(store: Store) -> None:
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
assert store.last_used(OTHER, "u1", "cat") is None
assert store.uses_today(OTHER, "u1", "cat", "2026-08-29") == 0
def test_votes_are_scoped_per_group(store: Store) -> None:
store.add_vote(GROUP, "cmd", "u1", 100.0)
store.add_vote(OTHER, "cmd", "u2", 100.0)
store.clear_votes(GROUP, "cmd")
assert store.count_votes(GROUP, "cmd", 0.0) == 0
assert store.count_votes(OTHER, "cmd", 0.0) == 1
def test_a_pre_group_database_is_rebuilt(tmp_path: Path) -> None:
"""CREATE TABLE IF NOT EXISTS is a no-op against the wrong columns.
A database written before the group key would survive startup untouched
and then fail on every INSERT: a bot that boots clean and answers
nothing. PRAGMA user_version is the only part of the file that knows.
"""
db = str(tmp_path / "old.sqlite3")
legacy = sqlite3.connect(db)
legacy.execute("CREATE TABLE command_uses (uuid TEXT PRIMARY KEY, used_at REAL NOT NULL)")
legacy.execute("INSERT INTO command_uses VALUES ('u1', 7.0)")
legacy.commit()
legacy.close()
with open_store(db) as store:
store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
assert store.last_used(GROUP, "u1", "cat") == 100.0
check = sqlite3.connect(db)
assert int(check.execute("PRAGMA user_version").fetchone()[0]) == SCHEMA_VERSION
check.close()
def test_a_schema_rebuild_never_drops_the_history(tmp_path: Path) -> None:
"""The second destructive path the table split governs.
forget_group is the obvious one. This is the other: a future session
bumping SCHEMA_VERSION for some cooldown table must not take years of
message history with it.
The use history now survives too, which it did not before command
analytics. daily_uses stopped being disposable the day it became the
only source covering the days that predate command_events, so it left
REBUILT_ON_BUMP. A bump can therefore no longer repair its shape --
_drop_reshaped is what took that job over.
"""
db = str(tmp_path / "bump.sqlite3")
with open_store(db) as first:
first.events.record(event())
first.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29")
aged = sqlite3.connect(db)
aged.execute("PRAGMA user_version = 0")
aged.commit()
aged.close()
with open_store(db) as second:
assert counted(second, GROUP) == 1
assert second.last_used(GROUP, "u1", "cat") == 100.0
assert second.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1
def test_a_bump_still_clears_a_vote_in_progress(tmp_path: Path) -> None:
"""What REBUILT_ON_BUMP still holds is still rebuilt."""
db = str(tmp_path / "votes.sqlite3")
with open_store(db) as first:
first.add_vote(GROUP, "cat", "u1", 100.0)
first.set_meta(GROUP, "last_reported_day", "2026-08-29")
aged = sqlite3.connect(db)
aged.execute("PRAGMA user_version = 0")
aged.commit()
aged.close()
with open_store(db) as second:
assert second.count_votes(GROUP, "cat", 0.0) == 0
assert second.meta(GROUP, "last_reported_day") is None
def test_a_current_database_is_left_alone(tmp_path: Path) -> None:
"""Reopening must not wipe the history on every restart."""
db = str(tmp_path / "current.sqlite3")
with open_store(db) as first:
first.events.record(event())
with open_store(db) as second:
assert counted(second, GROUP) == 1