# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Which tables survive what. EPHEMERAL_TABLES used to answer two different questions with one tuple, and this file exists because splitting it is exactly the kind of change that looks right and silently stops clearing something. Every assertion here is against either sqlite's own catalogue or the code that does the dropping -- never against a second copy of the list. """ from __future__ import annotations import sqlite3 from typing import TYPE_CHECKING from signal_bot.command_settings import CommandSetting from signal_bot.schema import ( CLEARED_ON_LEAVE, PERMANENT_TABLES, REBUILT_ON_BUMP, RETAINED_COLUMNS, SCHEMA_VERSION, TABLES, ) from signal_bot.store import Store if TYPE_CHECKING: from pathlib import Path GROUP = "group-a==" def _declared(store: Store) -> set[str]: """Every table sqlite actually holds, as opposed to every one we listed.""" rows = store.events.connection.execute( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" ).fetchall() return {str(row["name"]) for row in rows} def _seed(store: Store, group_id: str = GROUP) -> None: """One row in every table a group can own.""" store.record_use(group_id, "u1", "cat", 1.0, "2026-08-29") store.add_vote(group_id, "cat", "u1", 1.0) store.set_meta(group_id, "last_reported_day", "2026-08-29") store.suggestions.record_suggestion(group_id, "!pogoda", "na jutro", 1) store.suggestions.record_unknown(group_id, "!cta", "!cta", 2) store.command_settings.put(CommandSetting("cat", enabled=False)) # Both maps are spelled out rather than interpolated, for the reason # forget_group spells its DELETEs out: a table name in an f-string is a # SQL-injection shape even when the input is a module constant, and this # repository takes no lint suppressions. PRAGMA additionally takes no # parameters at all. test_every_table_can_be_counted keeps COUNTS complete. COLUMNS = { "command_uses": "PRAGMA table_info(command_uses)", "daily_uses": "PRAGMA table_info(daily_uses)", } COUNTS = { "command_uses": "SELECT count(*) AS n FROM command_uses WHERE group_id = ?", "daily_uses": "SELECT count(*) AS n FROM daily_uses WHERE group_id = ?", "votes": "SELECT count(*) AS n FROM votes WHERE group_id = ?", "group_meta": "SELECT count(*) AS n FROM group_meta WHERE group_id = ?", "message_events": "SELECT count(*) AS n FROM message_events WHERE group_id = ?", "group_people": "SELECT count(*) AS n FROM group_people WHERE group_id = ?", "unknown_commands": "SELECT count(*) AS n FROM unknown_commands WHERE group_id = ?", "suggestions": "SELECT count(*) AS n FROM suggestions WHERE group_id = ?", "command_events": "SELECT count(*) AS n FROM command_events WHERE group_id = ?", "upstream_calls": "SELECT count(*) AS n FROM upstream_calls WHERE group_id = ?", # Not keyed by group: a switch is thrown for the bot, not for a chat. The # placeholder is there so every entry takes the same one argument. "command_settings": "SELECT count(*) AS n FROM command_settings WHERE ? IS NOT NULL", } def _rows(store: Store, table: str, group_id: str = GROUP) -> int: """How many rows one group owns in one table.""" row = store.events.connection.execute(COUNTS[table], (group_id,)).fetchone() return int(row["n"]) def test_every_table_can_be_counted() -> None: """A new table without a count query would skip every check below it.""" assert set(COUNTS) == set(TABLES) def test_declared_columns_match_the_schema(store: Store) -> None: """RETAINED_COLUMNS is a second copy of the DDL, so it must be checked. _drop_reshaped compares against it to decide whether a retained table is still writable. A stale entry here would drop a perfectly good table on every bump -- silently, since dropping is the recovery path -- and take the use history it was moved out of REBUILT_ON_BUMP to protect. """ for table, expected in RETAINED_COLUMNS.items(): found = tuple(str(row["name"]) for row in store.events.connection.execute(COLUMNS[table])) assert found == expected def test_a_retained_table_with_the_wrong_shape_is_dropped(tmp_path: Path) -> None: """The failure _apply_schema exists to prevent, on a table it no longer rebuilds. A database written before the group key holds a two-column command_uses. CREATE TABLE IF NOT EXISTS will not touch it and the bump no longer drops it, so without _drop_reshaped the bot boots clean and fails on every INSERT. """ path = str(tmp_path / "legacy.sqlite3") with sqlite3.connect(path) as raw: raw.execute("CREATE TABLE daily_uses (uuid TEXT PRIMARY KEY, uses INTEGER NOT NULL)") raw.execute("INSERT INTO daily_uses VALUES ('u1', 7)") with Store(path) as store: store.record_use(GROUP, "u1", "cat", 1.0, "2026-08-29") assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 1 def test_the_list_matches_what_sqlite_holds(store: Store) -> None: """TABLES is the schema's index, so it may not drift from the schema.""" assert set(TABLES) == _declared(store) def test_no_table_is_both_kept_and_thrown_away() -> None: """A table in two of these tuples is a contradiction, not a configuration.""" assert not set(PERMANENT_TABLES) & set(REBUILT_ON_BUMP) assert not set(PERMANENT_TABLES) & set(CLEARED_ON_LEAVE) def test_the_history_is_permanent() -> None: """The four tables nothing can recompute, named where it is configured.""" for table in ("message_events", "command_events", "upstream_calls", "group_people"): assert table in PERMANENT_TABLES def test_the_use_counters_reset_on_leave_but_survive_a_bump() -> None: """The whole point of the split, asserted as one sentence. daily_uses feeds a rate limiter *and* is a source the analytics history is backfilled from. Clearing it when the bot leaves a group is correct; dropping it on a schema bump would throw away use history that predates command_events. """ for table in ("command_uses", "daily_uses"): assert table in CLEARED_ON_LEAVE assert table not in REBUILT_ON_BUMP def test_forget_group_clears_every_cleared_table(store: Store) -> None: """The DELETEs in forget_group are spelled out, so something must count them. Without this, adding a table to CLEARED_ON_LEAVE and forgetting the matching DELETE leaves a cooldown alive across a leave and a rejoin -- invisible until somebody is told to wait for a command they never ran. """ _seed(store) for table in CLEARED_ON_LEAVE: assert _rows(store, table) > 0 store.forget_group(GROUP) for table in CLEARED_ON_LEAVE: assert _rows(store, table) == 0 def test_forget_group_leaves_the_permanent_tables_alone(store: Store) -> None: """A transient group listing must not be able to delete years of history.""" _seed(store) store.forget_group(GROUP) assert len(store.suggestions.filed()) == 1 assert len(store.suggestions.unknown()) == 1 assert not store.command_settings.get("cat").enabled def test_a_version_bump_keeps_the_use_history(tmp_path: Path) -> None: """A bump drops REBUILT_ON_BUMP and nothing else. Written against a file rather than :memory: because the bump is only observable across two openings of the same database. """ path = str(tmp_path / "bump.sqlite3") with Store(path) as store: _seed(store) with sqlite3.connect(path) as raw: raw.execute(f"PRAGMA user_version = {SCHEMA_VERSION - 1}") with Store(path) as reopened: # Thrown away, and cheap to lose. assert _rows(reopened, "votes") == 0 assert _rows(reopened, "group_meta") == 0 # Kept, because nothing recomputes them. assert _rows(reopened, "command_uses") == 1 assert _rows(reopened, "daily_uses") == 1 assert len(reopened.suggestions.filed()) == 1 assert not reopened.command_settings.get("cat").enabled