# 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