From cefb1c3fc3031ca65c81f2bafc8f9e3279e91dd6 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Sat, 11 Jul 2026 11:36:01 +0100 Subject: [PATCH] refactor(parses): convert parses/db.py to ParsesStore(BaseCatalogue) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second PR extending the catalogue architecture beyond eq2db (after CensusStore #153): - backend/server/parses/db.py: init_db collapses into the base template method (_create_schema hosts the six table schemas, the _MIGRATIONS loop, the three named migrations and indexes; FOREIGN_KEYS=True, CREATE_META=False). The 28 conn-taking helpers become staticmethods; invalidate_is_player_cache becomes an instance method over self.path. SwingType + the swing-type constants stay module-level. Shared `store = ParsesStore()` instance. - The hand-rolled :memory: special-case is gone (BaseCatalogue dropped it in #152 for silently-broken reads); tests that used init_db(":memory:") now use tmp_path files. The unused pragma_* SQL blocks are removed (base owns the connection pragmas); the world-migration's own FK toggle blocks stay. - Consumers (admin, parses delete/ingest/list/tamper, rankings, app startup, cleanup) import the instance as parses_db — call sites read the same, and the parses_db.init_db(parses_db.DB_PATH) explicit-default calls collapse to parses_db.init_db(). - conftest + the shared parses_db_path fixture re-point store.path alongside DB_PATH; test monkeypatches move from module DB_PATH to instance path; static calls route through the store. 1524 tests green, ruff + pyright clean. Co-Authored-By: Claude Fable 5 --- backend/server/api/admin.py | 14 +- backend/server/api/parses/delete.py | 10 +- backend/server/api/parses/ingest.py | 8 +- backend/server/api/parses/list.py | 10 +- backend/server/api/parses/tamper_report.py | 2 +- backend/server/api/rankings.py | 8 +- backend/server/app.py | 2 +- backend/server/parses/cleanup.py | 6 +- backend/server/parses/db.py | 1357 +++++++++-------- backend/server/parses/db.sql | 12 +- tests/conftest.py | 3 +- tests/fixtures/parses_db.py | 16 +- tests/server/parses/test_cleanup.py | 4 +- tests/server/parses/test_db.py | 206 +-- tests/server/parses/test_db_is_player.py | 26 +- tests/server/test_admin_parses.py | 2 +- tests/server/test_admin_servers.py | 2 +- tests/server/test_admin_tamper_reports.py | 16 +- tests/server/test_parses_delete.py | 8 +- tests/server/test_parses_ingest.py | 14 +- tests/server/test_parses_ingest_classify.py | 13 +- .../test_parses_ingest_client_warnings.py | 14 +- tests/server/test_parses_list.py | 4 +- tests/server/test_parses_pet_filter.py | 8 +- tests/server/test_parses_tamper_report.py | 8 +- tests/server/test_parses_world_scoping.py | 38 +- tests/server/test_rankings.py | 16 +- tests/server/test_zones_cache_invalidation.py | 13 +- 28 files changed, 932 insertions(+), 908 deletions(-) diff --git a/backend/server/api/admin.py b/backend/server/api/admin.py index 50498f6c..0d1fdcdf 100644 --- a/backend/server/api/admin.py +++ b/backend/server/api/admin.py @@ -35,7 +35,7 @@ set_user_access, upsert_server_settings_sync, ) -from backend.server.parses import db as parses_db +from backend.server.parses.db import store as parses_db from backend.server.server_context import current_world _log = logging.getLogger(__name__) @@ -423,9 +423,9 @@ async def list_parses_admin( world = current_world() def _query() -> list[dict]: - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return [] - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: return parses_db.list_encounters_for_admin(conn, search=search, limit=limit, world=world) finally: @@ -506,9 +506,9 @@ async def list_tamper_reports_admin( world = current_world() def _query() -> tuple[list[dict], int]: - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return [], 0 - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: rows = parses_db.list_tamper_reports( conn, @@ -571,9 +571,9 @@ async def acknowledge_tamper_report( now_unix = int(datetime.now().timestamp()) def _ack() -> bool: - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return False - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: return parses_db.acknowledge_tamper_report( conn, diff --git a/backend/server/api/parses/delete.py b/backend/server/api/parses/delete.py index d96f556b..4fa63db9 100644 --- a/backend/server/api/parses/delete.py +++ b/backend/server/api/parses/delete.py @@ -26,8 +26,8 @@ from backend.server.core.executor import run_sync from backend.server.core.session_user import SessionUser from backend.server.limiter import limiter -from backend.server.parses import db as parses_db from backend.server.parses.boss import is_boss +from backend.server.parses.db import store as parses_db from backend.server.server_context import current_world _log = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def _fetch_encounter_auth_rows(ids: list[int], world: str) -> list[dict]: """Fetch the (id, guild_name, source_dsn, title, hidden_at) rows needed to authorise a delete, scoped to *world* so a cross-server id returns nothing. Runs in an executor.""" - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: conn.row_factory = sqlite3.Row placeholders = ",".join("?" * len(ids)) @@ -127,7 +127,7 @@ async def delete_parses_batch( now = int(time.time()) def _delete_many() -> int: - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: return sum(1 for enc in allowed_rows if _apply_delete(conn, enc, purge=purge, hidden_at=now)) finally: @@ -169,7 +169,7 @@ async def delete_parse( now = int(time.time()) def _delete_sync() -> bool: - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: return _apply_delete(conn, enc, purge=purge, hidden_at=now) finally: @@ -226,7 +226,7 @@ async def delete_parses_bulk( _world = current_world() def _delete_sync() -> int: - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: matches = parses_db.find_encounters_by_filter( conn, diff --git a/backend/server/api/parses/ingest.py b/backend/server/api/parses/ingest.py index ccc456d0..0c00ebe9 100644 --- a/backend/server/api/parses/ingest.py +++ b/backend/server/api/parses/ingest.py @@ -37,7 +37,7 @@ from backend.server.core.validation import sanitize_world as _sanitize_world from backend.server.core.validation import validate_character_name as _validate_character_name from backend.server.limiter import limiter -from backend.server.parses import db as parses_db +from backend.server.parses.db import store as parses_db from backend.server.parses.models import ( AttackType, Combatant, @@ -308,7 +308,7 @@ def _cached_snapshots(names: list[str], world: str | None = None) -> dict[str, C def _update_snapshots_sync(encounter_id: int, snapshots: dict[str, CombatantSnapshot]) -> None: - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: parses_db.update_combatant_snapshots(conn, encounter_id, snapshots) # Phase 3 (pet detection): re-classify because cls just changed. The @@ -354,7 +354,7 @@ async def _backfill_encounter_guild(encounter_id: int, uploader: str, world: str encounter_id, ) return - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: parses_db.set_encounter_guild_name(conn, encounter_id, result) _log.info("Background guild backfill set encounter %s guild_name=%r", encounter_id, result) @@ -672,7 +672,7 @@ def _ingest_payload_sync( damage_types = _damage_types_from_payload(payload.damage_types, enc.encid) attack_types = _attack_types_from_payload(payload.attack_types, enc.encid) - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: terminal = _check_idempotency_sync(conn, enc.encid, world) if terminal is not None: diff --git a/backend/server/api/parses/list.py b/backend/server/api/parses/list.py index dc40688c..76851a38 100644 --- a/backend/server/api/parses/list.py +++ b/backend/server/api/parses/list.py @@ -45,7 +45,7 @@ ) from backend.server.core.executor import run_sync from backend.server.limiter import limiter -from backend.server.parses import db as parses_db +from backend.server.parses.db import store as parses_db from backend.server.parses.pet_detection import classify_combatants from backend.server.server_context import current_world from backend.sql_loader import load_sql @@ -230,7 +230,7 @@ def _list_encounters_sync( ``world`` scopes results to the active server so a Varsoon viewer only sees Varsoon parses.""" - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return [] # Soft-deleted parses are hidden from the list (but still feed rankings). @@ -258,7 +258,7 @@ def _list_encounters_sync( list_sql = _SQL["list_encounters_recent"].format(where_sql=where_sql, player_count_sql=_PLAYER_COUNT_SQL) - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: conn.row_factory = sqlite3.Row return [dict(r) for r in conn.execute(list_sql, [*params, inner_cap]).fetchall()] @@ -374,7 +374,7 @@ def _encounter_detail_sync(encounter_id: int, top_attacks_per_combatant: int, wo ``world`` is used to scope the lookup so a viewer on one server can't read another server's encounter by guessing its integer id.""" - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return None conn = parses_db.init_db() try: @@ -498,7 +498,7 @@ def _list_and_group_sync() -> tuple[list[dict], list[dict], int]: rows = _list_encounters_sync(inner_cap, zone, size, active_world, search) if not rows: return rows, [], 0 - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: # Phase 4 lazy backfill: any encounter inserted before the # pet-detection pipeline shipped has is_player=NULL on its diff --git a/backend/server/api/parses/tamper_report.py b/backend/server/api/parses/tamper_report.py index f20eb8a3..86d8043c 100644 --- a/backend/server/api/parses/tamper_report.py +++ b/backend/server/api/parses/tamper_report.py @@ -43,7 +43,7 @@ from backend.server.core.validation import sanitize_world as _sanitize_world from backend.server.core.validation import validate_character_name as _validate_character_name from backend.server.limiter import limiter -from backend.server.parses import db as parses_db +from backend.server.parses.db import store as parses_db _log = logging.getLogger(__name__) diff --git a/backend/server/api/rankings.py b/backend/server/api/rankings.py index 7bdeec6a..52eb6161 100644 --- a/backend/server/api/rankings.py +++ b/backend/server/api/rankings.py @@ -26,8 +26,8 @@ from backend.server.cache import TTLCache from backend.server.core.executor import run_sync from backend.server.limiter import limiter -from backend.server.parses import db as parses_db from backend.server.parses.boss import is_boss +from backend.server.parses.db import store as parses_db from backend.server.server_context import current_server, current_world from backend.sql_loader import load_sql @@ -272,7 +272,7 @@ def invalidate_zones_cache() -> None: # from this module, and parses.db is a deeper dependency. Local # imports keep the module-load DAG cycle-free. from backend.server.api.parses.list import _classifier_cache_clear - from backend.server.parses import db as parses_db + from backend.server.parses.db import store as parses_db _classifier_cache_clear() parses_db.invalidate_is_player_cache() @@ -470,9 +470,9 @@ def _load_primary_boss_kills(world: str = "Varsoon") -> list[dict]: ``world`` scopes to the active server so each server sees only its own leaderboard data.""" - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return [] - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: conn.row_factory = sqlite3.Row rows = conn.execute( diff --git a/backend/server/app.py b/backend/server/app.py index 793e0b1b..27ab27a9 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -439,7 +439,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: # Initialise the parses DB too so the schema + migrations are in place # before the first /api/parses/ingest hits — otherwise the first # upload's request pays that cost on the request thread. - from backend.server.parses import db as parses_db + from backend.server.parses.db import store as parses_db parses_db.init_db() # Initialise zones.db on startup too so admin-curation tables diff --git a/backend/server/parses/cleanup.py b/backend/server/parses/cleanup.py index 68e56c00..1d3a11f9 100644 --- a/backend/server/parses/cleanup.py +++ b/backend/server/parses/cleanup.py @@ -28,8 +28,8 @@ import sqlite3 import time -from backend.server.parses import db as parses_db from backend.server.parses.boss import is_boss +from backend.server.parses.db import store as parses_db _log = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def run_parse_cleanup(now: int | None = None, retention_days: int | None = None) Returns ``{"trash_deleted": n, "dup_uploads_deleted": m}``. Safe to call repeatedly (idempotent once nothing is past the cutoff).""" - if not parses_db.DB_PATH.exists(): + if not parses_db.path.exists(): return {"trash_deleted": 0, "dup_uploads_deleted": 0} now = int(time.time()) if now is None else now @@ -66,7 +66,7 @@ def run_parse_cleanup(now: int | None = None, retention_days: int | None = None) trash_deleted = 0 dup_deleted = 0 - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.init_db() try: conn.row_factory = sqlite3.Row worlds = [r[0] for r in conn.execute("SELECT DISTINCT world FROM encounters").fetchall()] diff --git a/backend/server/parses/db.py b/backend/server/parses/db.py index aa24f5d4..4745bcf8 100644 --- a/backend/server/parses/db.py +++ b/backend/server/parses/db.py @@ -21,6 +21,7 @@ from enum import IntEnum from pathlib import Path +from backend.db_catalogue import BaseCatalogue from backend.db_helpers import resolve_db_path from backend.server.parses.models import AttackType, Combatant, CombatantSnapshot, DamageType, Encounter from backend.sql_loader import load_sql @@ -69,450 +70,6 @@ ] -# --------------------------------------------------------------------------- -# DB management -# --------------------------------------------------------------------------- - - -def init_db(path: Path = DB_PATH) -> sqlite3.Connection: - """Create tables/indexes if missing. Returns an open connection.""" - if str(path) == ":memory:": - conn = sqlite3.connect(":memory:") - else: - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path) - conn.execute(_SQL["pragma_journal_mode_wal"]) - conn.execute(_SQL["pragma_synchronous_normal"]) - conn.execute(_SQL["pragma_foreign_keys_on"]) - conn.execute(_SQL["schema_encounters"]) - conn.execute(_SQL["schema_combatants"]) - conn.execute(_SQL["schema_damage_types"]) - conn.execute(_SQL["schema_attack_types"]) - conn.execute(_SQL["schema_ingest_log"]) - conn.execute(_SQL["schema_tamper_reports"]) - for stmt in _MIGRATIONS: - try: - conn.execute(stmt) - except sqlite3.OperationalError: - pass - _migrate_attack_types_unique(conn) - _migrate_encounters_add_world(conn) - _migrate_ingest_log_add_world(conn) - conn.executescript(_SQL["indexes_all"]) - conn.commit() - return conn - - -def _migrate_attack_types_unique(conn: sqlite3.Connection) -> None: - """Recreate attack_types if the legacy UNIQUE(combatant_id, attack_name) - constraint is still in place. The natural key needs swing_type too — - spells like Cleanse legitimately appear under both damage and heal.""" - rows = conn.execute(_SQL["migrate_check_attack_types_indexes"]).fetchall() - target = ["combatant_id", "swing_type", "attack_name"] - for (idx_name,) in rows: - cols = [r[2] for r in conn.execute(_SQL["pragma_index_info"].format(idx_name=idx_name)).fetchall()] - if cols == target: - return # already migrated - # Commit any pending implicit transaction so `with conn:` can scope a - # fresh atomic one around the table swap. - conn.commit() - with conn: - conn.execute( - _SQL["schema_attack_types"].replace( - "CREATE TABLE IF NOT EXISTS attack_types", - "CREATE TABLE attack_types_new", - ) - ) - conn.execute(_SQL["migrate_attack_types_insert_into_new"]) - conn.execute(_SQL["migrate_attack_types_drop_old"]) - conn.execute(_SQL["migrate_attack_types_rename"]) - - -def _migrate_encounters_add_world(conn: sqlite3.Connection) -> None: - """Add the `world` column to encounters and change the uniqueness key from - the single-column UNIQUE on act_encid to UNIQUE(world, act_encid). - - SQLite can't ALTER a uniqueness constraint in place, so we use the standard - table-rebuild pattern: rename old → create new → INSERT … SELECT → drop old. - - Guard: skip if the `world` column already exists (idempotent). - FK safety: combatants.encounter_id references encounters(id). The rebuild - preserves all `id` values via an explicit column list (including the - original INTEGER PRIMARY KEY AUTOINCREMENT sequence), so child rows remain - valid after the swap. PRAGMA foreign_keys is turned OFF for the duration of - the rebuild so SQLite does not object while encounters_old is the target; - it is re-enabled immediately after.""" - cols = [r[1] for r in conn.execute(_SQL["pragma_table_info_encounters"]).fetchall()] - if "world" in cols: - return # already migrated - - conn.commit() - conn.execute(_SQL["pragma_foreign_keys_off"]) - conn.execute(_SQL["pragma_legacy_alter_table_on"]) - try: - with conn: - conn.execute(_SQL["migrate_encounters_rename_old"]) - conn.execute( - _SQL["schema_encounters"].replace("CREATE TABLE IF NOT EXISTS encounters", "CREATE TABLE encounters") - ) - # Copy all existing rows, backfilling world = 'Varsoon'. - conn.execute(_SQL["migrate_encounters_copy_from_old"]) - conn.execute(_SQL["migrate_encounters_drop_old"]) - finally: - conn.execute(_SQL["pragma_legacy_alter_table_off"]) - conn.execute(_SQL["pragma_foreign_keys_on"]) - - -def _migrate_ingest_log_add_world(conn: sqlite3.Connection) -> None: - """Add `world` to ingest_log and change PK from act_encid alone to - (world, act_encid). Same rebuild pattern as encounters; guard on 'world' - column presence.""" - cols = [r[1] for r in conn.execute(_SQL["pragma_table_info_ingest_log"]).fetchall()] - if "world" in cols: - return # already migrated - - conn.commit() - conn.execute(_SQL["pragma_foreign_keys_off"]) - conn.execute(_SQL["pragma_legacy_alter_table_on"]) - try: - with conn: - conn.execute(_SQL["migrate_ingest_log_rename_old"]) - conn.execute( - _SQL["schema_ingest_log"].replace("CREATE TABLE IF NOT EXISTS ingest_log", "CREATE TABLE ingest_log") - ) - conn.execute(_SQL["migrate_ingest_log_copy_from_old"]) - conn.execute(_SQL["migrate_ingest_log_drop_old"]) - finally: - conn.execute(_SQL["pragma_legacy_alter_table_off"]) - conn.execute(_SQL["pragma_foreign_keys_on"]) - - -# --------------------------------------------------------------------------- -# Insert helpers -# --------------------------------------------------------------------------- - - -def insert_encounter( - conn: sqlite3.Connection, - enc: Encounter, - *, - source_dsn: str, - ingested_at: int, - uploaded_by: str = "local", - guild_name: str | None = None, - world: str = "Varsoon", -) -> int: - """Insert one encounter row. The column ↔ field mapping (incl. the - ``encid → act_encid`` rename and the datetime → unix conversion) lives - on :meth:`Encounter.as_db_params`; this function just threads in the - per-call args and runs the SQL.""" - cur = conn.execute( - _SQL["insert_encounter"], - enc.as_db_params( - world=world, - source_dsn=source_dsn, - ingested_at=ingested_at, - uploaded_by=uploaded_by, - guild_name=guild_name, - ), - ) - return int(cur.lastrowid or 0) - - -def insert_combatants_bulk( - conn: sqlite3.Connection, - encounter_id: int, - combatants: list[Combatant], - snapshots: dict[str, CombatantSnapshot] | None = None, -) -> dict[str, int]: - """Insert combatant rows. ``snapshots`` (name → CombatantSnapshot) carries - the level/guild/class frozen at ingest time; missing names store NULLs.""" - snap_by_lower = {k.lower(): v for k, v in (snapshots or {}).items()} - name_to_id: dict[str, int] = {} - for c in combatants: - snap = snap_by_lower.get(c.name.lower(), _EMPTY_SNAPSHOT) - cur = conn.execute( - _SQL["insert_combatant"], - c.as_db_params(encounter_id=encounter_id, snapshot=snap), - ) - name_to_id[c.name] = int(cur.lastrowid or 0) - return name_to_id - - -def update_combatant_snapshots( - conn: sqlite3.Connection, - encounter_id: int, - snapshots: dict[str, CombatantSnapshot], -) -> int: - """Fill in level/guild/class on already-inserted combatant rows once the - (possibly slow) Census resolution finishes in the background. Matches by - combatant name within the encounter. Returns rows updated.""" - if not snapshots: - return 0 - n = 0 - with conn: - for name, snap in snapshots.items(): - cur = conn.execute( - _SQL["update_combatant_snapshot"], - (snap.level, snap.guild_name, snap.cls, snap.ilvl, encounter_id, name), - ) - n += cur.rowcount - return n - - -def update_combatant_is_player(conn: sqlite3.Connection, classification: dict[int, bool]) -> None: - """Bulk UPDATE the per-combatant is_player flag. - - Called from: - * the ingest path, after the classifier runs against newly-inserted rows - * the async snapshot fill, after cls fills in (which can flip stage 5) - * the lazy-backfill helper in web/routes/parses/list.py - - No-op when ``classification`` is empty. Caller owns the connection - and transaction scope.""" - if not classification: - return - conn.executemany( - _SQL["update_combatant_is_player"], - [(1 if v else 0, k) for k, v in classification.items()], - ) - - -def invalidate_is_player_cache_with_conn(conn: sqlite3.Connection) -> None: - """Mark every combatant row for lazy re-classification on next read. - Variant that accepts an existing connection (used by tests + by the - rankings cache-invalidation hook to share the parses.db connection).""" - conn.execute(_SQL["invalidate_is_player_cache"]) - - -def invalidate_is_player_cache(path: Path = DB_PATH) -> None: - """Mark every combatant row for lazy re-classification on next read. - Production caller (opens its own connection). - - Called by web/routes/rankings.py:invalidate_zones_cache so that a - curator zone-edit propagates to the existing parses without a - separate backfill — the next read of each encounter re-classifies - against the updated zone trees. - - Brute-force table-wide invalidation is fine at current data size - (test parses only as of 2026-05-30). If the parses corpus grows past - ~10k encounters and this becomes painful, swap for a per-zone-targeted - invalidation using an is_player_computed_at timestamp.""" - with sqlite3.connect(path) as conn: - invalidate_is_player_cache_with_conn(conn) - - -def insert_damage_types_bulk( - conn: sqlite3.Connection, - combatant_name_to_id: dict[str, int], - damage_types: list[DamageType], -) -> int: - """Bulk-insert damage_types rows. ``combatant_name_to_id`` resolves the - natural-key reference in :class:`DamageType` to the FK we store; rows - referencing an unknown combatant name are silently dropped.""" - rows = [ - dt.as_db_params(combatant_id=combatant_name_to_id[dt.combatant_name]) - for dt in damage_types - if dt.combatant_name in combatant_name_to_id - ] - conn.executemany(_SQL["insert_damage_type"], rows) - return len(rows) - - -def insert_attack_types_bulk( - conn: sqlite3.Connection, - combatant_name_to_id: dict[str, int], - attack_types: list[AttackType], -) -> int: - """Bulk-insert attack_types rows. Same shape as - :func:`insert_damage_types_bulk` — rows whose combatant_name can't be - resolved against the encounter's combatants are silently dropped.""" - rows = [ - at.as_db_params(combatant_id=combatant_name_to_id[at.combatant_name]) - for at in attack_types - if at.combatant_name in combatant_name_to_id - ] - conn.executemany(_SQL["insert_attack_type"], rows) - return len(rows) - - -def mark_ingested( - conn: sqlite3.Connection, - act_encid: str, - encounter_id: int, - *, - source_dsn: str, - ingested_at: int, - world: str = "Varsoon", -) -> None: - conn.execute( - _SQL["mark_ingested"], - (world, act_encid, encounter_id, ingested_at, source_dsn), - ) - - -# --------------------------------------------------------------------------- -# Lookup helpers -# --------------------------------------------------------------------------- - - -def is_ingested(conn: sqlite3.Connection, act_encid: str, world: str = "Varsoon") -> bool: - row = conn.execute( - _SQL["check_is_ingested"], - (world, act_encid), - ).fetchone() - return row is not None - - -def find_encounter_by_act_encid(conn: sqlite3.Connection, act_encid: str, world: str = "Varsoon") -> dict | None: - conn.row_factory = sqlite3.Row - row = conn.execute( - _SQL["find_encounter_by_act_encid"], - (world, act_encid), - ).fetchone() - return dict(row) if row else None - - -def recent_encounters( - conn: sqlite3.Connection, - limit: int = 20, - zone: str | None = None, - world: str = "Varsoon", -) -> list[dict]: - conn.row_factory = sqlite3.Row - if zone: - rows = conn.execute( - _SQL["recent_encounters_by_zone"], - (world, zone, limit), - ).fetchall() - else: - rows = conn.execute( - _SQL["recent_encounters_all"], - (world, limit), - ).fetchall() - return [dict(r) for r in rows] - - -def list_encounters_for_admin( - conn: sqlite3.Connection, - *, - search: str | None = None, - limit: int = 200, - world: str | None = None, -) -> list[dict]: - """All encounters INCLUDING hidden (soft-deleted) ones, newest first, for - the admin sanitize view. Optional case-insensitive search over - title / uploaded_by / guild_name. Includes a player_count and the hidden_at - marker so an admin can spot a bogus parse even when it's hidden but still - polluting the leaderboards. - - ``world`` scopes to a single EQ2 server; ``None`` returns all worlds - (no longer recommended — pass the active server world in all call sites).""" - conn.row_factory = sqlite3.Row - clauses: list[str] = [] - params: list = [] - if world is not None: - clauses.append("e.world = ?") - params.append(world) - if search: - like = f"%{search.lower()}%" - clauses.append( - "(LOWER(title) LIKE ? OR LOWER(IFNULL(uploaded_by, '')) LIKE ? OR LOWER(IFNULL(guild_name, '')) LIKE ?)" - ) - params += [like, like, like] - where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - sql = _SQL["list_encounters_for_admin"].format(where=where) - return [dict(r) for r in conn.execute(sql, [*params, limit]).fetchall()] - - -def delete_encounter(conn: sqlite3.Connection, encounter_id: int) -> bool: - """Delete one encounter. Returns True if a row was removed, False if not - found. ON DELETE CASCADE handles combatants / damage_types / attack_types - / ingest_log.""" - with conn: - cur = conn.execute(_SQL["delete_encounter"], (encounter_id,)) - return cur.rowcount > 0 - - -def soft_delete_encounter(conn: sqlite3.Connection, encounter_id: int, hidden_at: int) -> bool: - """Hide an encounter from the parses list without removing it, so any - leaderboard entry sourced from it survives and its link still opens. - Only acts on a currently-visible row; returns True if it flipped one.""" - with conn: - cur = conn.execute( - _SQL["soft_delete_encounter"], - (hidden_at, encounter_id), - ) - return cur.rowcount > 0 - - -def unhide_encounter(conn: sqlite3.Connection, encounter_id: int) -> bool: - """Clear a soft-delete marker so a previously-hidden parse becomes visible - again (used when its encounter is re-uploaded). Returns True if a hidden - row was un-hidden.""" - with conn: - cur = conn.execute( - _SQL["unhide_encounter"], - (encounter_id,), - ) - return cur.rowcount > 0 - - -def set_encounter_guild_name(conn: sqlite3.Connection, encounter_id: int, guild_name: str | None) -> bool: - """Set (or clear) the guild_name on an encounter row. Returns True if the row was updated.""" - with conn: - cur = conn.execute( - _SQL["set_encounter_guild_name"], - (guild_name, encounter_id), - ) - return cur.rowcount > 0 - - -def find_encounters_by_filter( - conn: sqlite3.Connection, - *, - guild_name: str, - zone: str | None = None, - date: str | None = None, - uploaded_by: str | None = None, - world: str | None = None, -) -> list[dict]: - """Return (id, title, guild_name, source_dsn) for encounters matching the - same filter `delete_encounters_by_filter` uses — so the route can decide - soft-vs-hard delete per row. `guild_name` is mandatory. - - Pass `world` to restrict results to a single server (used by the bulk- - delete route to enforce per-server isolation).""" - if not guild_name: - raise ValueError("guild_name is required") - clauses = ["guild_name = ?"] - params: list = [guild_name] - if world: - clauses.append("world = ?") - params.append(world) - if zone: - clauses.append("zone = ?") - params.append(zone) - if uploaded_by: - clauses.append("uploaded_by = ?") - params.append(uploaded_by) - if date: - clauses.append("date(started_at, 'unixepoch', 'localtime') = ?") - params.append(date) - conn.row_factory = sqlite3.Row - sql = _SQL["find_encounters_by_filter"].format(where=("WHERE " + " AND ".join(clauses))) - return [dict(r) for r in conn.execute(sql, params).fetchall()] - - -def get_combatants_for_encounter(conn: sqlite3.Connection, encounter_id: int) -> list[dict]: - conn.row_factory = sqlite3.Row - rows = conn.execute( - _SQL["get_combatants_for_encounter"], - (encounter_id,), - ).fetchall() - return [dict(r) for r in rows] - - # ACT swing_type semantics confirmed against real EQ2 data: # 1 = melee auto-attack # 2 = skill/spell damage @@ -544,244 +101,698 @@ class SwingType(IntEnum): _THREAT_SWING_TYPES = (SwingType.PROC,) # callers should additionally filter type != 'All' -def get_top_attacks_for_combatant( - conn: sqlite3.Connection, - combatant_id: int, - limit: int = 10, -) -> list[dict]: - """Top damage abilities (excludes heals and the swing_type=100 rollup).""" - conn.row_factory = sqlite3.Row - placeholders = ",".join("?" * len(_DAMAGE_SWING_TYPES)) - rows = conn.execute( - _SQL["get_top_attacks_by_swing_type"].format(placeholders=placeholders), - (combatant_id, *_DAMAGE_SWING_TYPES, limit), - ).fetchall() - return [dict(r) for r in rows] - - -def get_top_heals_for_combatant( - conn: sqlite3.Connection, - combatant_id: int, - limit: int = 10, -) -> list[dict]: - """Top heal abilities (swing_type=3). `damage` column = amount healed; - `resist` column distinguishes regular 'Hitpoints' heals from - 'Absorption' wards.""" - conn.row_factory = sqlite3.Row - placeholders = ",".join("?" * len(_HEAL_SWING_TYPES)) - rows = conn.execute( - _SQL["get_top_attacks_by_swing_type"].format(placeholders=placeholders), - (combatant_id, *_HEAL_SWING_TYPES, limit), - ).fetchall() - return [dict(r) for r in rows] - - -def get_top_cures_for_combatant( - conn: sqlite3.Connection, - combatant_id: int, - limit: int = 10, -) -> list[dict]: - """Cure events (swing_type=20). The `damage` column is the count of - detrimental effects removed; `hits` is how many times the cure was cast.""" - conn.row_factory = sqlite3.Row - placeholders = ",".join("?" * len(_CURE_SWING_TYPES)) - rows = conn.execute( - _SQL["get_top_cures"].format(placeholders=placeholders), - (combatant_id, *_CURE_SWING_TYPES, limit), - ).fetchall() - return [dict(r) for r in rows] - - -def get_top_threats_for_combatant( - conn: sqlite3.Connection, - combatant_id: int, - limit: int = 10, -) -> list[dict]: - """Threat / buff-proc rows (swing_type=100, type != 'All'). For threat - procs the `damage` column is the threat-increase value; `hits` is - proc count.""" - conn.row_factory = sqlite3.Row - placeholders = ",".join("?" * len(_THREAT_SWING_TYPES)) - rows = conn.execute( - _SQL["get_top_threats"].format(placeholders=placeholders), - (combatant_id, *_THREAT_SWING_TYPES, limit), - ).fetchall() - return [dict(r) for r in rows] - - -def get_damage_types_for_combatant( - conn: sqlite3.Connection, - combatant_id: int, -) -> list[dict]: - """All damage_types rows for a combatant, sorted by damage DESC.""" - conn.row_factory = sqlite3.Row - rows = conn.execute( - _SQL["get_damage_types_for_combatant"], - (combatant_id,), - ).fetchall() - return [dict(r) for r in rows] +class ParsesStore(BaseCatalogue): + """Read/write access to one parses.db file (uploaded ACT encounters). + The catalogue convention (see backend/db_catalogue.py): the shared + module-level ``store`` instance is the runtime entry point (consumers + alias it ``parses_db``); the conn-taking helpers are staticmethods — + callers batch several operations per connection/transaction. Tests + construct ``ParsesStore(tmp_db)``. + """ -# --------------------------------------------------------------------------- -# client_warnings (soft warnings on otherwise-successful uploads) -# --------------------------------------------------------------------------- + FOREIGN_KEYS = True + + # parses.db predates the shared _meta table; rows carry their own + # timestamps and litestream owns backup provenance. + CREATE_META = False + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + # --------------------------------------------------------------------------- + # DB management + # --------------------------------------------------------------------------- + + def _create_schema(self, conn: sqlite3.Connection) -> None: + conn.execute(_SQL["schema_encounters"]) + conn.execute(_SQL["schema_combatants"]) + conn.execute(_SQL["schema_damage_types"]) + conn.execute(_SQL["schema_attack_types"]) + conn.execute(_SQL["schema_ingest_log"]) + conn.execute(_SQL["schema_tamper_reports"]) + for stmt in _MIGRATIONS: + try: + conn.execute(stmt) + except sqlite3.OperationalError: + pass + self._migrate_attack_types_unique(conn) + self._migrate_encounters_add_world(conn) + self._migrate_ingest_log_add_world(conn) + conn.executescript(_SQL["indexes_all"]) + + @staticmethod + def _migrate_attack_types_unique(conn: sqlite3.Connection) -> None: + """Recreate attack_types if the legacy UNIQUE(combatant_id, attack_name) + constraint is still in place. The natural key needs swing_type too — + spells like Cleanse legitimately appear under both damage and heal.""" + rows = conn.execute(_SQL["migrate_check_attack_types_indexes"]).fetchall() + target = ["combatant_id", "swing_type", "attack_name"] + for (idx_name,) in rows: + cols = [r[2] for r in conn.execute(_SQL["pragma_index_info"].format(idx_name=idx_name)).fetchall()] + if cols == target: + return # already migrated + # Commit any pending implicit transaction so `with conn:` can scope a + # fresh atomic one around the table swap. + conn.commit() + with conn: + conn.execute( + _SQL["schema_attack_types"].replace( + "CREATE TABLE IF NOT EXISTS attack_types", + "CREATE TABLE attack_types_new", + ) + ) + conn.execute(_SQL["migrate_attack_types_insert_into_new"]) + conn.execute(_SQL["migrate_attack_types_drop_old"]) + conn.execute(_SQL["migrate_attack_types_rename"]) + + @staticmethod + def _migrate_encounters_add_world(conn: sqlite3.Connection) -> None: + """Add the `world` column to encounters and change the uniqueness key from + the single-column UNIQUE on act_encid to UNIQUE(world, act_encid). + + SQLite can't ALTER a uniqueness constraint in place, so we use the standard + table-rebuild pattern: rename old → create new → INSERT … SELECT → drop old. + + Guard: skip if the `world` column already exists (idempotent). + FK safety: combatants.encounter_id references encounters(id). The rebuild + preserves all `id` values via an explicit column list (including the + original INTEGER PRIMARY KEY AUTOINCREMENT sequence), so child rows remain + valid after the swap. PRAGMA foreign_keys is turned OFF for the duration of + the rebuild so SQLite does not object while encounters_old is the target; + it is re-enabled immediately after.""" + cols = [r[1] for r in conn.execute(_SQL["pragma_table_info_encounters"]).fetchall()] + if "world" in cols: + return # already migrated + conn.commit() + conn.execute(_SQL["pragma_foreign_keys_off"]) + conn.execute(_SQL["pragma_legacy_alter_table_on"]) + try: + with conn: + conn.execute(_SQL["migrate_encounters_rename_old"]) + conn.execute( + _SQL["schema_encounters"].replace( + "CREATE TABLE IF NOT EXISTS encounters", "CREATE TABLE encounters" + ) + ) + # Copy all existing rows, backfilling world = 'Varsoon'. + conn.execute(_SQL["migrate_encounters_copy_from_old"]) + conn.execute(_SQL["migrate_encounters_drop_old"]) + finally: + conn.execute(_SQL["pragma_legacy_alter_table_off"]) + conn.execute(_SQL["pragma_foreign_keys_on"]) + + @staticmethod + def _migrate_ingest_log_add_world(conn: sqlite3.Connection) -> None: + """Add `world` to ingest_log and change PK from act_encid alone to + (world, act_encid). Same rebuild pattern as encounters; guard on 'world' + column presence.""" + cols = [r[1] for r in conn.execute(_SQL["pragma_table_info_ingest_log"]).fetchall()] + if "world" in cols: + return # already migrated + + conn.commit() + conn.execute(_SQL["pragma_foreign_keys_off"]) + conn.execute(_SQL["pragma_legacy_alter_table_on"]) + try: + with conn: + conn.execute(_SQL["migrate_ingest_log_rename_old"]) + conn.execute( + _SQL["schema_ingest_log"].replace( + "CREATE TABLE IF NOT EXISTS ingest_log", "CREATE TABLE ingest_log" + ) + ) + conn.execute(_SQL["migrate_ingest_log_copy_from_old"]) + conn.execute(_SQL["migrate_ingest_log_drop_old"]) + finally: + conn.execute(_SQL["pragma_legacy_alter_table_off"]) + conn.execute(_SQL["pragma_foreign_keys_on"]) + + # --------------------------------------------------------------------------- + # Insert helpers + # --------------------------------------------------------------------------- + + @staticmethod + def insert_encounter( + conn: sqlite3.Connection, + enc: Encounter, + *, + source_dsn: str, + ingested_at: int, + uploaded_by: str = "local", + guild_name: str | None = None, + world: str = "Varsoon", + ) -> int: + """Insert one encounter row. The column ↔ field mapping (incl. the + ``encid → act_encid`` rename and the datetime → unix conversion) lives + on :meth:`Encounter.as_db_params`; this function just threads in the + per-call args and runs the SQL.""" + cur = conn.execute( + _SQL["insert_encounter"], + enc.as_db_params( + world=world, + source_dsn=source_dsn, + ingested_at=ingested_at, + uploaded_by=uploaded_by, + guild_name=guild_name, + ), + ) + return int(cur.lastrowid or 0) + + @staticmethod + def insert_combatants_bulk( + conn: sqlite3.Connection, + encounter_id: int, + combatants: list[Combatant], + snapshots: dict[str, CombatantSnapshot] | None = None, + ) -> dict[str, int]: + """Insert combatant rows. ``snapshots`` (name → CombatantSnapshot) carries + the level/guild/class frozen at ingest time; missing names store NULLs.""" + snap_by_lower = {k.lower(): v for k, v in (snapshots or {}).items()} + name_to_id: dict[str, int] = {} + for c in combatants: + snap = snap_by_lower.get(c.name.lower(), _EMPTY_SNAPSHOT) + cur = conn.execute( + _SQL["insert_combatant"], + c.as_db_params(encounter_id=encounter_id, snapshot=snap), + ) + name_to_id[c.name] = int(cur.lastrowid or 0) + return name_to_id + + @staticmethod + def update_combatant_snapshots( + conn: sqlite3.Connection, + encounter_id: int, + snapshots: dict[str, CombatantSnapshot], + ) -> int: + """Fill in level/guild/class on already-inserted combatant rows once the + (possibly slow) Census resolution finishes in the background. Matches by + combatant name within the encounter. Returns rows updated.""" + if not snapshots: + return 0 + n = 0 + with conn: + for name, snap in snapshots.items(): + cur = conn.execute( + _SQL["update_combatant_snapshot"], + (snap.level, snap.guild_name, snap.cls, snap.ilvl, encounter_id, name), + ) + n += cur.rowcount + return n + + @staticmethod + def update_combatant_is_player(conn: sqlite3.Connection, classification: dict[int, bool]) -> None: + """Bulk UPDATE the per-combatant is_player flag. + + Called from: + * the ingest path, after the classifier runs against newly-inserted rows + * the async snapshot fill, after cls fills in (which can flip stage 5) + * the lazy-backfill helper in web/routes/parses/list.py + + No-op when ``classification`` is empty. Caller owns the connection + and transaction scope.""" + if not classification: + return + conn.executemany( + _SQL["update_combatant_is_player"], + [(1 if v else 0, k) for k, v in classification.items()], + ) -def set_encounter_client_warnings( - conn: sqlite3.Connection, - encounter_id: int, - warnings_json: str | None, -) -> None: - """Set (or clear) the client_warnings JSON blob on an encounter. + @staticmethod + def invalidate_is_player_cache_with_conn(conn: sqlite3.Connection) -> None: + """Mark every combatant row for lazy re-classification on next read. + Variant that accepts an existing connection (used by tests + by the + rankings cache-invalidation hook to share the parses.db connection).""" + conn.execute(_SQL["invalidate_is_player_cache"]) + + def invalidate_is_player_cache(self) -> None: + """Mark every combatant row for lazy re-classification on next read. + Production caller (opens its own connection). + + Called by web/routes/rankings.py:invalidate_zones_cache so that a + curator zone-edit propagates to the existing parses without a + separate backfill — the next read of each encounter re-classifies + against the updated zone trees. + + Brute-force table-wide invalidation is fine at current data size + (test parses only as of 2026-05-30). If the parses corpus grows past + ~10k encounters and this becomes painful, swap for a per-zone-targeted + invalidation using an is_player_computed_at timestamp.""" + with sqlite3.connect(self.path) as conn: + self.invalidate_is_player_cache_with_conn(conn) + + @staticmethod + def insert_damage_types_bulk( + conn: sqlite3.Connection, + combatant_name_to_id: dict[str, int], + damage_types: list[DamageType], + ) -> int: + """Bulk-insert damage_types rows. ``combatant_name_to_id`` resolves the + natural-key reference in :class:`DamageType` to the FK we store; rows + referencing an unknown combatant name are silently dropped.""" + rows = [ + dt.as_db_params(combatant_id=combatant_name_to_id[dt.combatant_name]) + for dt in damage_types + if dt.combatant_name in combatant_name_to_id + ] + conn.executemany(_SQL["insert_damage_type"], rows) + return len(rows) + + @staticmethod + def insert_attack_types_bulk( + conn: sqlite3.Connection, + combatant_name_to_id: dict[str, int], + attack_types: list[AttackType], + ) -> int: + """Bulk-insert attack_types rows. Same shape as + :func:`insert_damage_types_bulk` — rows whose combatant_name can't be + resolved against the encounter's combatants are silently dropped.""" + rows = [ + at.as_db_params(combatant_id=combatant_name_to_id[at.combatant_name]) + for at in attack_types + if at.combatant_name in combatant_name_to_id + ] + conn.executemany(_SQL["insert_attack_type"], rows) + return len(rows) + + @staticmethod + def mark_ingested( + conn: sqlite3.Connection, + act_encid: str, + encounter_id: int, + *, + source_dsn: str, + ingested_at: int, + world: str = "Varsoon", + ) -> None: + conn.execute( + _SQL["mark_ingested"], + (world, act_encid, encounter_id, ingested_at, source_dsn), + ) - Pass ``None`` (or an empty string) when the plugin didn't send any - warnings — the column stays NULL, which is the "no warnings" sentinel - the admin table uses to decide whether to render the ⚠ chip. + # --------------------------------------------------------------------------- + # Lookup helpers + # --------------------------------------------------------------------------- - Caller is responsible for serialising the list of warning strings to - JSON BEFORE calling this (we keep the storage layer string-typed so a - test can drop arbitrary text in and we don't ship a JSON dependency - at the schema layer). - """ - payload = warnings_json if warnings_json else None - conn.execute( - _SQL["set_encounter_client_warnings"], - (payload, encounter_id), - ) + @staticmethod + def is_ingested(conn: sqlite3.Connection, act_encid: str, world: str = "Varsoon") -> bool: + row = conn.execute( + _SQL["check_is_ingested"], + (world, act_encid), + ).fetchone() + return row is not None + @staticmethod + def find_encounter_by_act_encid(conn: sqlite3.Connection, act_encid: str, world: str = "Varsoon") -> dict | None: + conn.row_factory = sqlite3.Row + row = conn.execute( + _SQL["find_encounter_by_act_encid"], + (world, act_encid), + ).fetchone() + return dict(row) if row else None + + @staticmethod + def recent_encounters( + conn: sqlite3.Connection, + limit: int = 20, + zone: str | None = None, + world: str = "Varsoon", + ) -> list[dict]: + conn.row_factory = sqlite3.Row + if zone: + rows = conn.execute( + _SQL["recent_encounters_by_zone"], + (world, zone, limit), + ).fetchall() + else: + rows = conn.execute( + _SQL["recent_encounters_all"], + (world, limit), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def list_encounters_for_admin( + conn: sqlite3.Connection, + *, + search: str | None = None, + limit: int = 200, + world: str | None = None, + ) -> list[dict]: + """All encounters INCLUDING hidden (soft-deleted) ones, newest first, for + the admin sanitize view. Optional case-insensitive search over + title / uploaded_by / guild_name. Includes a player_count and the hidden_at + marker so an admin can spot a bogus parse even when it's hidden but still + polluting the leaderboards. + + ``world`` scopes to a single EQ2 server; ``None`` returns all worlds + (no longer recommended — pass the active server world in all call sites).""" + conn.row_factory = sqlite3.Row + clauses: list[str] = [] + params: list = [] + if world is not None: + clauses.append("e.world = ?") + params.append(world) + if search: + like = f"%{search.lower()}%" + clauses.append( + "(LOWER(title) LIKE ? OR LOWER(IFNULL(uploaded_by, '')) LIKE ? OR LOWER(IFNULL(guild_name, '')) LIKE ?)" + ) + params += [like, like, like] + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + sql = _SQL["list_encounters_for_admin"].format(where=where) + return [dict(r) for r in conn.execute(sql, [*params, limit]).fetchall()] + + @staticmethod + def delete_encounter(conn: sqlite3.Connection, encounter_id: int) -> bool: + """Delete one encounter. Returns True if a row was removed, False if not + found. ON DELETE CASCADE handles combatants / damage_types / attack_types + / ingest_log.""" + with conn: + cur = conn.execute(_SQL["delete_encounter"], (encounter_id,)) + return cur.rowcount > 0 + + @staticmethod + def soft_delete_encounter(conn: sqlite3.Connection, encounter_id: int, hidden_at: int) -> bool: + """Hide an encounter from the parses list without removing it, so any + leaderboard entry sourced from it survives and its link still opens. + Only acts on a currently-visible row; returns True if it flipped one.""" + with conn: + cur = conn.execute( + _SQL["soft_delete_encounter"], + (hidden_at, encounter_id), + ) + return cur.rowcount > 0 -# --------------------------------------------------------------------------- -# tamper_reports (audit channel for blocked-from-leaderboard uploads) -# --------------------------------------------------------------------------- + @staticmethod + def unhide_encounter(conn: sqlite3.Connection, encounter_id: int) -> bool: + """Clear a soft-delete marker so a previously-hidden parse becomes visible + again (used when its encounter is re-uploaded). Returns True if a hidden + row was un-hidden.""" + with conn: + cur = conn.execute( + _SQL["unhide_encounter"], + (encounter_id,), + ) + return cur.rowcount > 0 + @staticmethod + def set_encounter_guild_name(conn: sqlite3.Connection, encounter_id: int, guild_name: str | None) -> bool: + """Set (or clear) the guild_name on an encounter row. Returns True if the row was updated.""" + with conn: + cur = conn.execute( + _SQL["set_encounter_guild_name"], + (guild_name, encounter_id), + ) + return cur.rowcount > 0 + + @staticmethod + def find_encounters_by_filter( + conn: sqlite3.Connection, + *, + guild_name: str, + zone: str | None = None, + date: str | None = None, + uploaded_by: str | None = None, + world: str | None = None, + ) -> list[dict]: + """Return (id, title, guild_name, source_dsn) for encounters matching the + same filter `delete_encounters_by_filter` uses — so the route can decide + soft-vs-hard delete per row. `guild_name` is mandatory. + + Pass `world` to restrict results to a single server (used by the bulk- + delete route to enforce per-server isolation).""" + if not guild_name: + raise ValueError("guild_name is required") + clauses = ["guild_name = ?"] + params: list = [guild_name] + if world: + clauses.append("world = ?") + params.append(world) + if zone: + clauses.append("zone = ?") + params.append(zone) + if uploaded_by: + clauses.append("uploaded_by = ?") + params.append(uploaded_by) + if date: + clauses.append("date(started_at, 'unixepoch', 'localtime') = ?") + params.append(date) + conn.row_factory = sqlite3.Row + sql = _SQL["find_encounters_by_filter"].format(where=("WHERE " + " AND ".join(clauses))) + return [dict(r) for r in conn.execute(sql, params).fetchall()] + + @staticmethod + def get_combatants_for_encounter(conn: sqlite3.Connection, encounter_id: int) -> list[dict]: + conn.row_factory = sqlite3.Row + rows = conn.execute( + _SQL["get_combatants_for_encounter"], + (encounter_id,), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def get_top_attacks_for_combatant( + conn: sqlite3.Connection, + combatant_id: int, + limit: int = 10, + ) -> list[dict]: + """Top damage abilities (excludes heals and the swing_type=100 rollup).""" + conn.row_factory = sqlite3.Row + placeholders = ",".join("?" * len(_DAMAGE_SWING_TYPES)) + rows = conn.execute( + _SQL["get_top_attacks_by_swing_type"].format(placeholders=placeholders), + (combatant_id, *_DAMAGE_SWING_TYPES, limit), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def get_top_heals_for_combatant( + conn: sqlite3.Connection, + combatant_id: int, + limit: int = 10, + ) -> list[dict]: + """Top heal abilities (swing_type=3). `damage` column = amount healed; + `resist` column distinguishes regular 'Hitpoints' heals from + 'Absorption' wards.""" + conn.row_factory = sqlite3.Row + placeholders = ",".join("?" * len(_HEAL_SWING_TYPES)) + rows = conn.execute( + _SQL["get_top_attacks_by_swing_type"].format(placeholders=placeholders), + (combatant_id, *_HEAL_SWING_TYPES, limit), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def get_top_cures_for_combatant( + conn: sqlite3.Connection, + combatant_id: int, + limit: int = 10, + ) -> list[dict]: + """Cure events (swing_type=20). The `damage` column is the count of + detrimental effects removed; `hits` is how many times the cure was cast.""" + conn.row_factory = sqlite3.Row + placeholders = ",".join("?" * len(_CURE_SWING_TYPES)) + rows = conn.execute( + _SQL["get_top_cures"].format(placeholders=placeholders), + (combatant_id, *_CURE_SWING_TYPES, limit), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def get_top_threats_for_combatant( + conn: sqlite3.Connection, + combatant_id: int, + limit: int = 10, + ) -> list[dict]: + """Threat / buff-proc rows (swing_type=100, type != 'All'). For threat + procs the `damage` column is the threat-increase value; `hits` is + proc count.""" + conn.row_factory = sqlite3.Row + placeholders = ",".join("?" * len(_THREAT_SWING_TYPES)) + rows = conn.execute( + _SQL["get_top_threats"].format(placeholders=placeholders), + (combatant_id, *_THREAT_SWING_TYPES, limit), + ).fetchall() + return [dict(r) for r in rows] + + @staticmethod + def get_damage_types_for_combatant( + conn: sqlite3.Connection, + combatant_id: int, + ) -> list[dict]: + """All damage_types rows for a combatant, sorted by damage DESC.""" + conn.row_factory = sqlite3.Row + rows = conn.execute( + _SQL["get_damage_types_for_combatant"], + (combatant_id,), + ).fetchall() + return [dict(r) for r in rows] + + # --------------------------------------------------------------------------- + # client_warnings (soft warnings on otherwise-successful uploads) + # --------------------------------------------------------------------------- + + @staticmethod + def set_encounter_client_warnings( + conn: sqlite3.Connection, + encounter_id: int, + warnings_json: str | None, + ) -> None: + """Set (or clear) the client_warnings JSON blob on an encounter. + + Pass ``None`` (or an empty string) when the plugin didn't send any + warnings — the column stays NULL, which is the "no warnings" sentinel + the admin table uses to decide whether to render the ⚠ chip. + + Caller is responsible for serialising the list of warning strings to + JSON BEFORE calling this (we keep the storage layer string-typed so a + test can drop arbitrary text in and we don't ship a JSON dependency + at the schema layer). + """ + payload = warnings_json if warnings_json else None + conn.execute( + _SQL["set_encounter_client_warnings"], + (payload, encounter_id), + ) -def insert_tamper_report( - conn: sqlite3.Connection, - *, - world: str, - act_encid: str, - title: str, - zone: str | None, - started_at: int, - ended_at: int, - duration_s: int, - total_damage: int, - encdps: float, - reason: str, - reported_at: int, - uploader_logger_name: str, - uploader_discord_id: str, - uploader_discord_name: str, - guild_name: str | None, - payload_json: str, -) -> int: - """Insert a tamper report. Returns the new row id. - - No idempotency — the plugin fires one tamper report per blocked - encounter, and a user retrying (e.g. via right-click → Upload after - an auto-skip) deserves a second row showing the second attempt. The - encid is preserved in the column so admins can correlate retries by - (world, act_encid) at query time. - """ - cur = conn.execute( - _SQL["insert_tamper_report"], - ( - world, - act_encid, - title, - zone, - started_at, - ended_at, - duration_s, - total_damage, - encdps, - reason, - reported_at, - uploader_logger_name, - uploader_discord_id, - uploader_discord_name, - guild_name, - payload_json, - ), - ) - return int(cur.lastrowid or 0) - - -def list_tamper_reports( - conn: sqlite3.Connection, - *, - world: str | None = None, - reason: str | None = None, - status: str = "pending", - limit: int = 200, -) -> list[dict]: - """Read tamper reports for the admin panel. - - ``status`` is one of: - * "pending" — acknowledged_at IS NULL (default — the admin's working set) - * "ack" — acknowledged_at IS NOT NULL - * "all" — both - - Returns rows newest-first. ``payload_json`` is included verbatim so the - admin UI can drill in if they want the full evidence; the listing - callers typically render a summary row and let an admin click in for - detail. - """ - clauses: list[str] = [] - params: list = [] - if world is not None: - clauses.append("world = ?") - params.append(world) - if reason is not None: - clauses.append("reason = ?") - params.append(reason) - if status == "pending": - clauses.append("acknowledged_at IS NULL") - elif status == "ack": - clauses.append("acknowledged_at IS NOT NULL") - elif status == "all": - pass # no extra filter - else: - raise ValueError(f"unknown status {status!r}") - where = ("WHERE " + " AND ".join(clauses)) if clauses else "" - conn.row_factory = sqlite3.Row - sql = _SQL["list_tamper_reports"].format(where=where) - return [dict(r) for r in conn.execute(sql, [*params, limit]).fetchall()] - - -def acknowledge_tamper_report( - conn: sqlite3.Connection, - report_id: int, - *, - acknowledged_at: int, - acknowledged_by: str, -) -> bool: - """Mark a tamper report as reviewed. Returns True if a pending row was - flipped; False if the id doesn't exist OR was already acknowledged. - - Acknowledge is one-way — there's no "unacknowledge". If an admin - wants to revisit, they can read the row via the ``status="ack"`` or - ``status="all"`` listing. - """ - with conn: + # --------------------------------------------------------------------------- + # tamper_reports (audit channel for blocked-from-leaderboard uploads) + # --------------------------------------------------------------------------- + + @staticmethod + def insert_tamper_report( + conn: sqlite3.Connection, + *, + world: str, + act_encid: str, + title: str, + zone: str | None, + started_at: int, + ended_at: int, + duration_s: int, + total_damage: int, + encdps: float, + reason: str, + reported_at: int, + uploader_logger_name: str, + uploader_discord_id: str, + uploader_discord_name: str, + guild_name: str | None, + payload_json: str, + ) -> int: + """Insert a tamper report. Returns the new row id. + + No idempotency — the plugin fires one tamper report per blocked + encounter, and a user retrying (e.g. via right-click → Upload after + an auto-skip) deserves a second row showing the second attempt. The + encid is preserved in the column so admins can correlate retries by + (world, act_encid) at query time. + """ cur = conn.execute( - _SQL["acknowledge_tamper_report"], - (acknowledged_at, acknowledged_by, report_id), + _SQL["insert_tamper_report"], + ( + world, + act_encid, + title, + zone, + started_at, + ended_at, + duration_s, + total_damage, + encdps, + reason, + reported_at, + uploader_logger_name, + uploader_discord_id, + uploader_discord_name, + guild_name, + payload_json, + ), ) - return cur.rowcount > 0 - - -def count_pending_tamper_reports( - conn: sqlite3.Connection, - world: str | None = None, -) -> int: - """Cheap count of unack'd reports — used by the admin panel badge so - the maintainer can see at a glance whether anything new needs review.""" - if world is None: - row = conn.execute(_SQL["count_pending_tamper_reports"]).fetchone() - else: - row = conn.execute( - _SQL["count_pending_tamper_reports_for_world"], - (world,), - ).fetchone() - return int(row[0]) if row else 0 + return int(cur.lastrowid or 0) + + @staticmethod + def list_tamper_reports( + conn: sqlite3.Connection, + *, + world: str | None = None, + reason: str | None = None, + status: str = "pending", + limit: int = 200, + ) -> list[dict]: + """Read tamper reports for the admin panel. + + ``status`` is one of: + * "pending" — acknowledged_at IS NULL (default — the admin's working set) + * "ack" — acknowledged_at IS NOT NULL + * "all" — both + + Returns rows newest-first. ``payload_json`` is included verbatim so the + admin UI can drill in if they want the full evidence; the listing + callers typically render a summary row and let an admin click in for + detail. + """ + clauses: list[str] = [] + params: list = [] + if world is not None: + clauses.append("world = ?") + params.append(world) + if reason is not None: + clauses.append("reason = ?") + params.append(reason) + if status == "pending": + clauses.append("acknowledged_at IS NULL") + elif status == "ack": + clauses.append("acknowledged_at IS NOT NULL") + elif status == "all": + pass # no extra filter + else: + raise ValueError(f"unknown status {status!r}") + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + conn.row_factory = sqlite3.Row + sql = _SQL["list_tamper_reports"].format(where=where) + return [dict(r) for r in conn.execute(sql, [*params, limit]).fetchall()] + + @staticmethod + def acknowledge_tamper_report( + conn: sqlite3.Connection, + report_id: int, + *, + acknowledged_at: int, + acknowledged_by: str, + ) -> bool: + """Mark a tamper report as reviewed. Returns True if a pending row was + flipped; False if the id doesn't exist OR was already acknowledged. + + Acknowledge is one-way — there's no "unacknowledge". If an admin + wants to revisit, they can read the row via the ``status="ack"`` or + ``status="all"`` listing. + """ + with conn: + cur = conn.execute( + _SQL["acknowledge_tamper_report"], + (acknowledged_at, acknowledged_by, report_id), + ) + return cur.rowcount > 0 + + @staticmethod + def count_pending_tamper_reports( + conn: sqlite3.Connection, + world: str | None = None, + ) -> int: + """Cheap count of unack'd reports — used by the admin panel badge so + the maintainer can see at a glance whether anything new needs review.""" + if world is None: + row = conn.execute(_SQL["count_pending_tamper_reports"]).fetchone() + else: + row = conn.execute( + _SQL["count_pending_tamper_reports_for_world"], + (world,), + ).fetchone() + return int(row[0]) if row else 0 + + +# The shared default instance — every runtime consumer goes through this. +store = ParsesStore() diff --git a/backend/server/parses/db.sql b/backend/server/parses/db.sql index 40144a6b..64152a58 100644 --- a/backend/server/parses/db.sql +++ b/backend/server/parses/db.sql @@ -198,21 +198,15 @@ CREATE INDEX IF NOT EXISTS idx_tamper_reports_world_reported ON tamper_reports ( -- PRAGMAs (connection setup + introspection + migration toggles) -- --------------------------------------------------------------------------- --- :name pragma_journal_mode_wal -PRAGMA journal_mode = WAL; - --- :name pragma_synchronous_normal -PRAGMA synchronous = NORMAL; - --- :name pragma_foreign_keys_on -PRAGMA foreign_keys = ON; - -- :name pragma_foreign_keys_off PRAGMA foreign_keys = OFF; -- :name pragma_legacy_alter_table_on PRAGMA legacy_alter_table = ON; +-- :name pragma_foreign_keys_on +PRAGMA foreign_keys = ON; + -- :name pragma_legacy_alter_table_off PRAGMA legacy_alter_table = OFF; diff --git a/tests/conftest.py b/tests/conftest.py index 810af265..ee94066f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,6 +112,7 @@ def pytest_configure(config: pytest.Config) -> None: # noqa: ARG001 from backend.server.parses import db as parses_db parses_db.DB_PATH = resolve_db_path("DB_PARSES_PATH", "parses", "parses.db") + parses_db.store.path = parses_db.DB_PATH users_db.DB_PATH = resolve_db_path("DB_USERS_PATH", "users.db") census_store.DB_PATH = resolve_db_path("DB_CENSUS_PATH", "census", "census.db") census_store.store.path = census_store.DB_PATH @@ -133,7 +134,7 @@ def pytest_configure(config: pytest.Config) -> None: # noqa: ARG001 # step API-token / parses tests would hit a missing-table OperationalError # the first time they read from the DB. users_db.init_db() - parses_db.init_db() + parses_db.store.init_db() from unittest.mock import AsyncMock, MagicMock # noqa: E402 diff --git a/tests/fixtures/parses_db.py b/tests/fixtures/parses_db.py index 05abf166..dfac3013 100644 --- a/tests/fixtures/parses_db.py +++ b/tests/fixtures/parses_db.py @@ -28,16 +28,20 @@ @pytest.fixture def parses_db_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Tmp-path-backed parses.db with schema initialised + DB_PATH patched.""" + """Tmp-path-backed parses.db with schema initialised + store re-pointed.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() return db_file @pytest.fixture -def parses_db_conn() -> Generator[sqlite3.Connection]: - """In-memory parses DB connection (schema already applied).""" - conn = parses_db.init_db(":memory:") # type: ignore[arg-type] +def parses_db_conn(tmp_path: Path) -> Generator[sqlite3.Connection]: + """Throwaway parses DB connection (schema already applied). + + File-backed (BaseCatalogue dropped :memory: support — per-read + connections would each see a fresh empty memory DB).""" + conn = parses_db.ParsesStore(tmp_path / "conn_parses.db").init_db() yield conn conn.close() diff --git a/tests/server/parses/test_cleanup.py b/tests/server/parses/test_cleanup.py index 7d80452c..5fca0d56 100644 --- a/tests/server/parses/test_cleanup.py +++ b/tests/server/parses/test_cleanup.py @@ -24,8 +24,8 @@ def seeded_db(tmp_path, monkeypatch): """Temp parses DB wired into both the seeding conn and the sweep (via DB_PATH). Returns the seed connection.""" db_path = tmp_path / "parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_path) - conn = parses_db.init_db(db_path) + monkeypatch.setattr(parses_db.store, "path", db_path) + conn = parses_db.ParsesStore(db_path).init_db() yield conn conn.close() diff --git a/tests/server/parses/test_db.py b/tests/server/parses/test_db.py index a88a6f9f..c12893f8 100644 --- a/tests/server/parses/test_db.py +++ b/tests/server/parses/test_db.py @@ -35,7 +35,7 @@ def test_migrations_idempotent(self, parses_db_conn): for idx in parses_db._CREATE_INDEXES: parses_db_conn.execute(idx) # Migration runner is also idempotent on an already-migrated DB. - parses_db._migrate_attack_types_unique(parses_db_conn) + parses_db.store._migrate_attack_types_unique(parses_db_conn) def test_encounters_has_hidden_at_column(self, parses_db_conn): cols = [r[1] for r in parses_db_conn.execute("PRAGMA table_info(encounters)").fetchall()] @@ -107,7 +107,7 @@ def test_migrates_legacy_attack_types_unique(self): (cid,), ) # Run the migration. - parses_db._migrate_attack_types_unique(conn) + parses_db.store._migrate_attack_types_unique(conn) # Existing row survived. assert conn.execute("SELECT damage FROM attack_types WHERE attack_name = 'Smite'").fetchone()[0] == 500 # New constraint now allows same-name across swing types. @@ -175,7 +175,7 @@ def _sample_combatant(name: str, *, ally: bool, damage: int) -> Combatant: class TestInsertHelpers: def test_insert_encounter_returns_id(self, parses_db_conn): - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -184,7 +184,7 @@ def test_insert_encounter_returns_id(self, parses_db_conn): assert eid >= 1 def test_insert_encounter_writes_uploaded_by(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -198,7 +198,7 @@ def test_insert_encounter_writes_uploaded_by(self, parses_db_conn): assert row[0] == "Menludiir" def test_insert_encounter_defaults_uploaded_by_to_local(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -211,7 +211,7 @@ def test_insert_encounter_defaults_uploaded_by_to_local(self, parses_db_conn): assert row[0] == "local" def test_insert_encounter_writes_guild_name(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -226,7 +226,7 @@ def test_insert_encounter_writes_guild_name(self, parses_db_conn): assert row[0] == "Exordium" def test_insert_encounter_defaults_guild_to_null(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -242,15 +242,15 @@ def test_insert_combatants_writes_snapshot(self, parses_db_conn): from backend.server.parses.models import CombatantSnapshot enc = _sample_encounter() - eid = parses_db.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) + eid = parses_db.store.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) combatants = [ _sample_combatant("Menludiir", ally=True, damage=500000), _sample_combatant("a krait patriarch", ally=False, damage=5716), ] snapshots = {"Menludiir": CombatantSnapshot(level=90, guild_name="Exordium", cls="Templar", ilvl=372.2)} - parses_db.insert_combatants_bulk(parses_db_conn, eid, combatants, snapshots) + parses_db.store.insert_combatants_bulk(parses_db_conn, eid, combatants, snapshots) - rows = {r["name"]: r for r in parses_db.get_combatants_for_encounter(parses_db_conn, eid)} + rows = {r["name"]: r for r in parses_db.store.get_combatants_for_encounter(parses_db_conn, eid)} assert ( rows["Menludiir"]["level"], rows["Menludiir"]["guild_name"], @@ -265,48 +265,52 @@ def test_insert_combatants_writes_snapshot(self, parses_db_conn): def test_insert_combatants_snapshot_optional(self, parses_db_conn): # Back-compat: omitting snapshots leaves the columns NULL. enc = _sample_encounter() - eid = parses_db.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) - parses_db.insert_combatants_bulk(parses_db_conn, eid, [_sample_combatant("Solo", ally=True, damage=1)]) - row = parses_db.get_combatants_for_encounter(parses_db_conn, eid)[0] + eid = parses_db.store.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) + parses_db.store.insert_combatants_bulk(parses_db_conn, eid, [_sample_combatant("Solo", ally=True, damage=1)]) + row = parses_db.store.get_combatants_for_encounter(parses_db_conn, eid)[0] assert row["level"] is None and row["cls"] is None def test_update_combatant_snapshots_fills_rows(self, parses_db_conn): from backend.server.parses.models import CombatantSnapshot enc = _sample_encounter() - eid = parses_db.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) - parses_db.insert_combatants_bulk(parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)]) - n = parses_db.update_combatant_snapshots( + eid = parses_db.store.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) + parses_db.store.insert_combatants_bulk( + parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)] + ) + n = parses_db.store.update_combatant_snapshots( parses_db_conn, eid, {"Menludiir": CombatantSnapshot(level=90, guild_name="Exordium", cls="Templar", ilvl=372.2)}, ) assert n == 1 - row = next(c for c in parses_db.get_combatants_for_encounter(parses_db_conn, eid) if c["name"] == "Menludiir") + row = next( + c for c in parses_db.store.get_combatants_for_encounter(parses_db_conn, eid) if c["name"] == "Menludiir" + ) assert (row["level"], row["guild_name"], row["cls"], row["ilvl"]) == (90, "Exordium", "Templar", 372.2) def test_soft_delete_sets_hidden_at(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) - assert parses_db.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700001111) is True - row = parses_db.find_encounter_by_act_encid(parses_db_conn, enc.encid) + eid = parses_db.store.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) + assert parses_db.store.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700001111) is True + row = parses_db.store.find_encounter_by_act_encid(parses_db_conn, enc.encid) assert row["hidden_at"] == 1700001111 # Idempotent: re-soft-deleting an already-hidden row is a no-op (returns False). - assert parses_db.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700002222) is False + assert parses_db.store.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700002222) is False def test_unhide_encounter_clears_marker(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) - parses_db.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700001111) - assert parses_db.unhide_encounter(parses_db_conn, eid) is True - row = parses_db.find_encounter_by_act_encid(parses_db_conn, enc.encid) + eid = parses_db.store.insert_encounter(parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000) + parses_db.store.soft_delete_encounter(parses_db_conn, eid, hidden_at=1700001111) + assert parses_db.store.unhide_encounter(parses_db_conn, eid) is True + row = parses_db.store.find_encounter_by_act_encid(parses_db_conn, enc.encid) assert row["hidden_at"] is None # Already-visible row → no-op, returns False. - assert parses_db.unhide_encounter(parses_db_conn, eid) is False + assert parses_db.store.unhide_encounter(parses_db_conn, eid) is False def test_full_ingest_chain(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", @@ -316,7 +320,7 @@ def test_full_ingest_chain(self, parses_db_conn): _sample_combatant("Menludiir", ally=True, damage=502718), _sample_combatant("a krait patriarch", ally=False, damage=5716), ] - name_to_id = parses_db.insert_combatants_bulk(parses_db_conn, eid, combatants) + name_to_id = parses_db.store.insert_combatants_bulk(parses_db_conn, eid, combatants) assert set(name_to_id) == {"Menludiir", "a krait patriarch"} damage_types = [ @@ -347,7 +351,7 @@ def test_full_ingest_chain(self, parses_db_conn): crit_types="0.8%L - 0.0%F - 0.0%M", ), ] - n = parses_db.insert_damage_types_bulk(parses_db_conn, name_to_id, damage_types) + n = parses_db.store.insert_damage_types_bulk(parses_db_conn, name_to_id, damage_types) assert n == 1 attacks = [ @@ -380,29 +384,33 @@ def test_full_ingest_chain(self, parses_db_conn): crit_types="0.8%L - 0.0%F - 0.0%M", ), ] - n = parses_db.insert_attack_types_bulk(parses_db_conn, name_to_id, attacks) + n = parses_db.store.insert_attack_types_bulk(parses_db_conn, name_to_id, attacks) assert n == 1 - parses_db.mark_ingested( + parses_db.store.mark_ingested( parses_db_conn, enc.encid, eid, source_dsn="eq2act", ingested_at=1700000000, ) - assert parses_db.is_ingested(parses_db_conn, enc.encid) - assert not parses_db.is_ingested(parses_db_conn, "NOTREAL") + assert parses_db.store.is_ingested(parses_db_conn, enc.encid) + assert not parses_db.store.is_ingested(parses_db_conn, "NOTREAL") def test_list_encounters_for_admin_includes_hidden_and_search(self, parses_db_conn): from dataclasses import replace a = _sample_encounter() # title "a krait patriarch", encid "18cf3eb9" b = replace(a, encid="boss01", title="Wuoshi") - aid = parses_db.insert_encounter(parses_db_conn, a, source_dsn="eq2act", ingested_at=1, guild_name="Exordium") - bid = parses_db.insert_encounter(parses_db_conn, b, source_dsn="eq2act", ingested_at=2, guild_name="Exordium") - parses_db.soft_delete_encounter(parses_db_conn, bid, hidden_at=99) # hide the boss one + aid = parses_db.store.insert_encounter( + parses_db_conn, a, source_dsn="eq2act", ingested_at=1, guild_name="Exordium" + ) + bid = parses_db.store.insert_encounter( + parses_db_conn, b, source_dsn="eq2act", ingested_at=2, guild_name="Exordium" + ) + parses_db.store.soft_delete_encounter(parses_db_conn, bid, hidden_at=99) # hide the boss one - rows = parses_db.list_encounters_for_admin(parses_db_conn) + rows = parses_db.store.list_encounters_for_admin(parses_db_conn) ids = {r["id"] for r in rows} assert aid in ids and bid in ids # hidden row still listed for admin by_id = {r["id"]: r for r in rows} @@ -410,20 +418,20 @@ def test_list_encounters_for_admin_includes_hidden_and_search(self, parses_db_co assert "player_count" in by_id[aid] # Search narrows by title. - hits = parses_db.list_encounters_for_admin(parses_db_conn, search="wuoshi") + hits = parses_db.store.list_encounters_for_admin(parses_db_conn, search="wuoshi") assert [r["id"] for r in hits] == [bid] class TestUniqueConstraints: def test_duplicate_act_encid_rejected(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, ) with pytest.raises(sqlite3.IntegrityError): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -432,28 +440,30 @@ def test_duplicate_act_encid_rejected(self, parses_db_conn): def test_duplicate_combatant_in_encounter_rejected(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, ) cs = [_sample_combatant("Menludiir", ally=True, damage=1)] - parses_db.insert_combatants_bulk(parses_db_conn, eid, cs) + parses_db.store.insert_combatants_bulk(parses_db_conn, eid, cs) with pytest.raises(sqlite3.IntegrityError): - parses_db.insert_combatants_bulk(parses_db_conn, eid, cs) + parses_db.store.insert_combatants_bulk(parses_db_conn, eid, cs) def test_same_attack_name_across_swing_types_allowed(self, parses_db_conn): """Cleanse-style spells deal damage (swing_type=2) AND heal (swing_type=3) — both rows must coexist for the same combatant. Old UNIQUE constraint of (combatant_id, attack_name) blocked this.""" - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, ) - parses_db.insert_combatants_bulk(parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)]) + parses_db.store.insert_combatants_bulk( + parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)] + ) cid = parses_db_conn.execute( "SELECT id FROM combatants WHERE encounter_id = ? AND name = ?", (eid, "Menludiir"), @@ -474,13 +484,15 @@ def test_same_attack_name_across_swing_types_allowed(self, parses_db_conn): def test_duplicate_attack_within_same_swing_type_rejected(self, parses_db_conn): """The new tuple is (combatant_id, swing_type, attack_name) — same attack twice at the same swing type still collides.""" - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, ) - parses_db.insert_combatants_bulk(parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)]) + parses_db.store.insert_combatants_bulk( + parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1)] + ) cid = parses_db_conn.execute( "SELECT id FROM combatants WHERE encounter_id = ? AND name = ?", (eid, "Menludiir"), @@ -515,9 +527,9 @@ def test_recent_encounters_orders_by_started_desc(self, parses_db_conn): kills=1, deaths=0, ) - parses_db.insert_encounter(parses_db_conn, e1, source_dsn="eq2act", ingested_at=1) - parses_db.insert_encounter(parses_db_conn, e2, source_dsn="eq2act", ingested_at=2) - rows = parses_db.recent_encounters(parses_db_conn, limit=10) + parses_db.store.insert_encounter(parses_db_conn, e1, source_dsn="eq2act", ingested_at=1) + parses_db.store.insert_encounter(parses_db_conn, e2, source_dsn="eq2act", ingested_at=2) + rows = parses_db.store.recent_encounters(parses_db_conn, limit=10) assert [r["act_encid"] for r in rows] == ["2B3C4D5E", "18cf3eb9"] def test_recent_encounters_zone_filter(self, parses_db_conn): @@ -534,24 +546,24 @@ def test_recent_encounters_zone_filter(self, parses_db_conn): kills=0, deaths=0, ) - parses_db.insert_encounter(parses_db_conn, e1, source_dsn="eq2act", ingested_at=1) - parses_db.insert_encounter(parses_db_conn, e2, source_dsn="eq2act", ingested_at=2) - rows = parses_db.recent_encounters(parses_db_conn, zone="Great Divide") + parses_db.store.insert_encounter(parses_db_conn, e1, source_dsn="eq2act", ingested_at=1) + parses_db.store.insert_encounter(parses_db_conn, e2, source_dsn="eq2act", ingested_at=2) + rows = parses_db.store.recent_encounters(parses_db_conn, zone="Great Divide") assert [r["act_encid"] for r in rows] == ["18cf3eb9"] def test_find_encounter_by_act_encid(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, ) - row = parses_db.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9") + row = parses_db.store.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9") assert row is not None assert row["title"] == "a krait patriarch" def test_find_encounter_missing_returns_none(self, parses_db_conn): - assert parses_db.find_encounter_by_act_encid(parses_db_conn, "NOPE") is None + assert parses_db.store.find_encounter_by_act_encid(parses_db_conn, "NOPE") is None class TestSwingTypeSplit: @@ -561,13 +573,13 @@ class TestSwingTypeSplit: def _seed(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, ) - name_to_id = parses_db.insert_combatants_bulk( + name_to_id = parses_db.store.insert_combatants_bulk( parses_db_conn, eid, [_sample_combatant("Menludiir", ally=True, damage=10000)], @@ -596,13 +608,13 @@ def _seed(self, parses_db_conn): def test_top_attacks_excludes_heals_and_rollups(self, parses_db_conn): cid = self._seed(parses_db_conn) - attacks = parses_db.get_top_attacks_for_combatant(parses_db_conn, cid) + attacks = parses_db.store.get_top_attacks_for_combatant(parses_db_conn, cid) names = [a["attack_name"] for a in attacks] assert names == ["crush", "Smite"] # heals + rollup absent def test_top_heals_only_swing_type_3(self, parses_db_conn): cid = self._seed(parses_db_conn) - heals = parses_db.get_top_heals_for_combatant(parses_db_conn, cid) + heals = parses_db.store.get_top_heals_for_combatant(parses_db_conn, cid) names = [h["attack_name"] for h in heals] # Sorted by damage DESC assert names == ["Reverence", "Stonewill"] @@ -613,7 +625,7 @@ def test_top_heals_only_swing_type_3(self, parses_db_conn): def test_top_cures_only_swing_type_20(self, parses_db_conn): cid = self._seed(parses_db_conn) - cures = parses_db.get_top_cures_for_combatant(parses_db_conn, cid) + cures = parses_db.store.get_top_cures_for_combatant(parses_db_conn, cid) assert [c["attack_name"] for c in cures] == ["Cure"] assert cures[0]["resist"] == "relieves" @@ -628,25 +640,25 @@ def test_top_threats_excludes_All_rollup(self, parses_db_conn): "VALUES (?, '', 100, 'All', 999999, 999, 999, 0, 0, 'All')", (cid,), ) - threats = parses_db.get_top_threats_for_combatant(parses_db_conn, cid) + threats = parses_db.store.get_top_threats_for_combatant(parses_db_conn, cid) names = [t["attack_name"] for t in threats] assert names == ["Undeniable Malice"] assert threats[0]["resist"] == "Increase" def test_no_heals_returns_empty(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, ) - name_to_id = parses_db.insert_combatants_bulk( + name_to_id = parses_db.store.insert_combatants_bulk( parses_db_conn, eid, [_sample_combatant("Sihtric", ally=True, damage=5000)], ) - assert parses_db.get_top_heals_for_combatant(parses_db_conn, name_to_id["Sihtric"]) == [] + assert parses_db.store.get_top_heals_for_combatant(parses_db_conn, name_to_id["Sihtric"]) == [] class TestDeleteHelpers: @@ -654,7 +666,7 @@ def _seed(self, conn, *, encid: str, guild_name: str, uploaded_by: str = "Menlud from dataclasses import replace enc = replace(_sample_encounter(), encid=encid) - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( conn, enc, source_dsn="eq2act", @@ -662,21 +674,21 @@ def _seed(self, conn, *, encid: str, guild_name: str, uploaded_by: str = "Menlud uploaded_by=uploaded_by, guild_name=guild_name, ) - parses_db.insert_combatants_bulk( + parses_db.store.insert_combatants_bulk( conn, eid, [_sample_combatant("Menludiir", ally=True, damage=1000)], ) - parses_db.mark_ingested(conn, encid, eid, source_dsn="eq2act", ingested_at=1700000000) + parses_db.store.mark_ingested(conn, encid, eid, source_dsn="eq2act", ingested_at=1700000000) return eid def test_delete_encounter_removes_row(self, parses_db_conn): eid = self._seed(parses_db_conn, encid="enc1", guild_name="Exordium") - assert parses_db.delete_encounter(parses_db_conn, eid) is True + assert parses_db.store.delete_encounter(parses_db_conn, eid) is True assert parses_db_conn.execute("SELECT COUNT(*) FROM encounters").fetchone()[0] == 0 def test_delete_encounter_returns_false_when_missing(self, parses_db_conn): - assert parses_db.delete_encounter(parses_db_conn, 99999) is False + assert parses_db.store.delete_encounter(parses_db_conn, 99999) is False def test_delete_encounter_cascades_children(self, parses_db_conn): eid = self._seed(parses_db_conn, encid="enc2", guild_name="Exordium") @@ -687,7 +699,7 @@ def test_delete_encounter_cascades_children(self, parses_db_conn): assert ( parses_db_conn.execute("SELECT COUNT(*) FROM ingest_log WHERE encounter_id = ?", (eid,)).fetchone()[0] == 1 ) - parses_db.delete_encounter(parses_db_conn, eid) + parses_db.store.delete_encounter(parses_db_conn, eid) # All children gone via FK cascade. assert ( parses_db_conn.execute("SELECT COUNT(*) FROM combatants WHERE encounter_id = ?", (eid,)).fetchone()[0] == 0 @@ -700,14 +712,14 @@ def test_delete_encounter_cascades_children(self, parses_db_conn): class TestFindByFilter: def test_find_encounters_by_filter_returns_id_and_title(self, parses_db_conn): enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, guild_name="Exordium", ) - rows = parses_db.find_encounters_by_filter(parses_db_conn, guild_name="Exordium") + rows = parses_db.store.find_encounters_by_filter(parses_db_conn, guild_name="Exordium") assert {"id", "title"} <= set(rows[0].keys()) assert rows[0]["id"] == eid @@ -730,7 +742,7 @@ def test_ingest_log_has_world_column(self, parses_db_conn): assert "world" in cols def test_world_stored_on_encounter(self, parses_db_conn): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -743,14 +755,14 @@ def test_world_stored_on_encounter(self, parses_db_conn): def test_same_act_encid_different_world_both_insert(self, parses_db_conn): """The UNIQUE constraint is (world, act_encid), so the same encid from two different servers must coexist without a collision.""" - eid_v = parses_db.insert_encounter( + eid_v = parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, world="Varsoon", ) - eid_w = parses_db.insert_encounter( + eid_w = parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -766,7 +778,7 @@ def test_same_act_encid_different_world_both_insert(self, parses_db_conn): def test_same_world_same_encid_still_collides(self, parses_db_conn): """Duplicate (world, act_encid) within the same server must still raise an IntegrityError.""" - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -774,7 +786,7 @@ def test_same_world_same_encid_still_collides(self, parses_db_conn): world="Varsoon", ) with pytest.raises(sqlite3.IntegrityError): - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -786,10 +798,10 @@ def test_is_ingested_world_scoped(self, parses_db_conn): """is_ingested(world='Varsoon') must NOT report True just because the same encid is ingested under 'Wuoshi'.""" enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, world="Wuoshi" ) - parses_db.mark_ingested( + parses_db.store.mark_ingested( parses_db_conn, enc.encid, eid, @@ -798,17 +810,17 @@ def test_is_ingested_world_scoped(self, parses_db_conn): world="Wuoshi", ) # On the ingest world → True. - assert parses_db.is_ingested(parses_db_conn, enc.encid, "Wuoshi") is True + assert parses_db.store.is_ingested(parses_db_conn, enc.encid, "Wuoshi") is True # On a different world → False. - assert parses_db.is_ingested(parses_db_conn, enc.encid, "Varsoon") is False + assert parses_db.store.is_ingested(parses_db_conn, enc.encid, "Varsoon") is False def test_ingest_log_world_stored(self, parses_db_conn): """mark_ingested must write the world column into ingest_log.""" enc = _sample_encounter() - eid = parses_db.insert_encounter( + eid = parses_db.store.insert_encounter( parses_db_conn, enc, source_dsn="eq2act", ingested_at=1700000000, world="Kaladim" ) - parses_db.mark_ingested( + parses_db.store.mark_ingested( parses_db_conn, enc.encid, eid, @@ -822,29 +834,29 @@ def test_ingest_log_world_stored(self, parses_db_conn): def test_find_encounter_by_act_encid_world_scoped(self, parses_db_conn): """find_encounter_by_act_encid must only return the row for the requested world.""" - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000000, world="Varsoon", ) - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", ingested_at=1700000001, world="Wuoshi", ) - v_row = parses_db.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9", "Varsoon") - w_row = parses_db.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9", "Wuoshi") + v_row = parses_db.store.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9", "Varsoon") + w_row = parses_db.store.find_encounter_by_act_encid(parses_db_conn, "18cf3eb9", "Wuoshi") assert v_row is not None and v_row["world"] == "Varsoon" assert w_row is not None and w_row["world"] == "Wuoshi" assert v_row["id"] != w_row["id"] def test_recent_encounters_world_filter(self, parses_db_conn): """recent_encounters(world='Varsoon') must exclude Wuoshi rows.""" - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -854,21 +866,21 @@ def test_recent_encounters_world_filter(self, parses_db_conn): from dataclasses import replace enc2 = replace(_sample_encounter(), encid="WUOSHI01") - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, enc2, source_dsn="eq2act", ingested_at=1700000001, world="Wuoshi", ) - v_rows = parses_db.recent_encounters(parses_db_conn, world="Varsoon") + v_rows = parses_db.store.recent_encounters(parses_db_conn, world="Varsoon") assert [r["act_encid"] for r in v_rows] == ["18cf3eb9"] - w_rows = parses_db.recent_encounters(parses_db_conn, world="Wuoshi") + w_rows = parses_db.store.recent_encounters(parses_db_conn, world="Wuoshi") assert [r["act_encid"] for r in w_rows] == ["WUOSHI01"] def test_encounters_default_world_is_varsoon(self, parses_db_conn): """insert_encounter with no explicit world stores 'Varsoon'.""" - parses_db.insert_encounter( + parses_db.store.insert_encounter( parses_db_conn, _sample_encounter(), source_dsn="eq2act", @@ -980,8 +992,8 @@ def test_legacy_db_rebuild_preserves_rows(self): ) conn.commit() # Run the migration (same logic init_db calls). - parses_db._migrate_encounters_add_world(conn) - parses_db._migrate_ingest_log_add_world(conn) + parses_db.store._migrate_encounters_add_world(conn) + parses_db.store._migrate_ingest_log_add_world(conn) conn.commit() # Encounter id preserved, world backfilled. row = conn.execute("SELECT id, world FROM encounters WHERE act_encid = 'legacy01'").fetchone() @@ -1007,7 +1019,7 @@ def test_legacy_db_rebuild_preserves_rows(self): child_count = conn.execute("SELECT COUNT(*) FROM combatants WHERE encounter_id = 42").fetchone()[0] assert child_count == 0, "cascade delete must remove child combatants" # Migrations are idempotent (re-running is a no-op). - parses_db._migrate_encounters_add_world(conn) - parses_db._migrate_ingest_log_add_world(conn) + parses_db.store._migrate_encounters_add_world(conn) + parses_db.store._migrate_ingest_log_add_world(conn) finally: conn.close() diff --git a/tests/server/parses/test_db_is_player.py b/tests/server/parses/test_db_is_player.py index 7832d808..e6312bc9 100644 --- a/tests/server/parses/test_db_is_player.py +++ b/tests/server/parses/test_db_is_player.py @@ -16,9 +16,9 @@ @pytest.fixture -def conn() -> sqlite3.Connection: - """Fresh in-memory parses DB with the full schema applied.""" - c = parses_db.init_db(Path(":memory:")) +def conn(tmp_path: Path) -> sqlite3.Connection: + """Fresh throwaway parses DB with the full schema applied.""" + c = parses_db.ParsesStore(tmp_path / "parses.db").init_db() try: yield c finally: @@ -46,8 +46,8 @@ def _insert_combatant(conn: sqlite3.Connection, encounter_id: int, name: str) -> return int(cur.lastrowid or 0) -def test_is_player_column_exists(): - conn = parses_db.init_db(Path(":memory:")) +def test_is_player_column_exists(tmp_path: Path): + conn = parses_db.ParsesStore(tmp_path / "parses.db").init_db() try: cols = {r[1]: r for r in conn.execute("PRAGMA table_info(combatants)")} assert "is_player" in cols @@ -60,8 +60,8 @@ def test_is_player_column_exists(): conn.close() -def test_is_player_index_exists(): - conn = parses_db.init_db(Path(":memory:")) +def test_is_player_index_exists(tmp_path: Path): + conn = parses_db.ParsesStore(tmp_path / "parses.db").init_db() try: names = { r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='combatants'") @@ -102,7 +102,7 @@ def test_update_combatant_is_player_round_trip(conn): a = _insert_combatant(conn, enc_id, "Alpha") b = _insert_combatant(conn, enc_id, "Bravo") c = _insert_combatant(conn, enc_id, "Charlie") - parses_db.update_combatant_is_player(conn, {a: True, b: False, c: True}) + parses_db.store.update_combatant_is_player(conn, {a: True, b: False, c: True}) rows = {r[0]: r[1] for r in conn.execute("SELECT id, is_player FROM combatants ORDER BY id")} assert rows[a] == 1 assert rows[b] == 0 @@ -112,21 +112,21 @@ def test_update_combatant_is_player_round_trip(conn): def test_update_combatant_is_player_overwrites_existing(conn): enc_id = _insert_encounter(conn) a = _insert_combatant(conn, enc_id, "Alpha") - parses_db.update_combatant_is_player(conn, {a: True}) - parses_db.update_combatant_is_player(conn, {a: False}) + parses_db.store.update_combatant_is_player(conn, {a: True}) + parses_db.store.update_combatant_is_player(conn, {a: False}) row = conn.execute("SELECT is_player FROM combatants WHERE id = ?", (a,)).fetchone() assert row[0] == 0 def test_update_combatant_is_player_empty_dict_is_noop(conn): - parses_db.update_combatant_is_player(conn, {}) # must not raise + parses_db.store.update_combatant_is_player(conn, {}) # must not raise def test_invalidate_is_player_cache_with_conn_sets_every_row_to_null(conn): enc_id = _insert_encounter(conn) a = _insert_combatant(conn, enc_id, "Alpha") b = _insert_combatant(conn, enc_id, "Bravo") - parses_db.update_combatant_is_player(conn, {a: True, b: False}) - parses_db.invalidate_is_player_cache_with_conn(conn) + parses_db.store.update_combatant_is_player(conn, {a: True, b: False}) + parses_db.store.invalidate_is_player_cache_with_conn(conn) rows = conn.execute("SELECT is_player FROM combatants").fetchall() assert all(r[0] is None for r in rows), rows diff --git a/tests/server/test_admin_parses.py b/tests/server/test_admin_parses.py index 4ee32a94..2a20f6a9 100644 --- a/tests/server/test_admin_parses.py +++ b/tests/server/test_admin_parses.py @@ -60,7 +60,7 @@ async def test_admin_parses_lists_including_hidden(app): MagicMock(return_value=rows), ), patch("backend.server.api.admin.parses_db.init_db", MagicMock(return_value=MagicMock())), - patch("backend.server.api.admin.parses_db.DB_PATH") as mock_path, + patch("backend.server.api.admin.parses_db.path") as mock_path, ): mock_path.exists.return_value = True async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: diff --git a/tests/server/test_admin_servers.py b/tests/server/test_admin_servers.py index d8694cf0..4ff60bd8 100644 --- a/tests/server/test_admin_servers.py +++ b/tests/server/test_admin_servers.py @@ -473,7 +473,7 @@ def _mock_admin_list(conn, *, search=None, limit=200, world=None): patch("backend.server.api.admin.current_world", return_value="Wuoshi"), patch("backend.server.api.admin.parses_db.list_encounters_for_admin", _mock_admin_list), patch("backend.server.api.admin.parses_db.init_db", MagicMock(return_value=MagicMock())), - patch("backend.server.api.admin.parses_db.DB_PATH") as mock_path, + patch("backend.server.api.admin.parses_db.path") as mock_path, ): mock_path.exists.return_value = True async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: diff --git a/tests/server/test_admin_tamper_reports.py b/tests/server/test_admin_tamper_reports.py index c045b9ec..61a22ef7 100644 --- a/tests/server/test_admin_tamper_reports.py +++ b/tests/server/test_admin_tamper_reports.py @@ -43,7 +43,7 @@ def _seed_tamper_reports(now: int = 1700000000) -> list[int]: # isolation) doesn't reach the no-arg call. The admin route uses # `init_db(parses_db.DB_PATH)` (explicit), so the only way to read # the same DB is to also pass explicit here. - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() ids: list[int] = [] try: # Newest first (descending reported_at) @@ -55,7 +55,7 @@ def _seed_tamper_reports(now: int = 1700000000) -> list[int]: "title_enemy_mismatch", ] ): - rid = parses_db.insert_tamper_report( + rid = parses_db.store.insert_tamper_report( conn, world="Varsoon", act_encid=f"ENC{i:04d}", @@ -82,7 +82,7 @@ def _seed_tamper_reports(now: int = 1700000000) -> list[int]: def _wipe_tamper_reports() -> None: - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() try: conn.execute("DELETE FROM tamper_reports") conn.commit() @@ -93,9 +93,9 @@ def _wipe_tamper_reports() -> None: def _acknowledge_row(report_id: int, actor: str = "admin-prev") -> None: """Mark a row acknowledged at the DB layer — used to seed the "ack" state before testing the listing filters.""" - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() try: - parses_db.acknowledge_tamper_report( + parses_db.store.acknowledge_tamper_report( conn, report_id, acknowledged_at=1700000999, @@ -222,7 +222,7 @@ async def test_acknowledge_flips_pending_row(app): assert r.json() == {"acknowledged": True} # And the DB-side flag was actually set, with the admin's id recorded. - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() try: row = conn.execute( "SELECT acknowledged_at, acknowledged_by FROM tamper_reports WHERE id = ?", @@ -251,7 +251,7 @@ async def test_acknowledge_idempotent_on_already_ack(app): assert r.json() == {"acknowledged": False} # The original acknowledged_by is preserved — second ack does not overwrite. - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() try: row = conn.execute( "SELECT acknowledged_by FROM tamper_reports WHERE id = ?", @@ -294,7 +294,7 @@ def test_tamper_reports_never_touch_encounters_table(): ids = _seed_tamper_reports() assert len(ids) > 0 # baseline: rows actually landed - conn = parses_db.init_db(parses_db.DB_PATH) + conn = parses_db.store.init_db() try: # Use the same act_encids the seed inserted into tamper_reports — # if any of them showed up in encounters that's the bug. diff --git a/tests/server/test_parses_delete.py b/tests/server/test_parses_delete.py index 7da4f176..98fe19a0 100644 --- a/tests/server/test_parses_delete.py +++ b/tests/server/test_parses_delete.py @@ -418,8 +418,8 @@ async def test_list_excludes_hidden_rows(app, tmp_path, monkeypatch): from backend.server.parses.models import Encounter db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(pdb, "DB_PATH", db_file) - conn = pdb.init_db(db_file) + monkeypatch.setattr(pdb.store, "path", db_file) + conn = pdb.ParsesStore(db_file).init_db() for encid, title in [("AAA", "Tarinax"), ("BBB", "Venekor")]: enc = Encounter( encid=encid, @@ -434,9 +434,9 @@ async def test_list_excludes_hidden_rows(app, tmp_path, monkeypatch): deaths=0, success_level=1, ) - eid = pdb.insert_encounter(conn, enc, source_dsn="eq2act", ingested_at=int(_t.time())) + eid = pdb.store.insert_encounter(conn, enc, source_dsn="eq2act", ingested_at=int(_t.time())) if encid == "BBB": - pdb.soft_delete_encounter(conn, eid, hidden_at=int(_t.time())) + pdb.store.soft_delete_encounter(conn, eid, hidden_at=int(_t.time())) conn.close() with patch("backend.server.api.parses.list._require_user", _fake_user): diff --git a/tests/server/test_parses_ingest.py b/tests/server/test_parses_ingest.py index 57f54853..95792523 100644 --- a/tests/server/test_parses_ingest.py +++ b/tests/server/test_parses_ingest.py @@ -145,9 +145,9 @@ async def test_reupload_of_soft_deleted_parse_revives_it(tmp_path, monkeypatch): from backend.server.parses import db as pdb db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(pdb, "DB_PATH", db_file) + monkeypatch.setattr(pdb.store, "path", db_file) # init the schema - pdb.init_db(db_file).close() + pdb.ParsesStore(db_file).init_db().close() payload = IngestRequest(**_minimal_payload()) @@ -156,19 +156,19 @@ async def test_reupload_of_soft_deleted_parse_revives_it(tmp_path, monkeypatch): assert status == "inserted" and eid is not None # Soft-delete it (as the delete route does for a boss kill). - conn = pdb.init_db(db_file) + conn = pdb.ParsesStore(db_file).init_db() try: - pdb.soft_delete_encounter(conn, eid, hidden_at=1700000000) - assert pdb.find_encounter_by_act_encid(conn, payload.encounter.encid)["hidden_at"] is not None + pdb.store.soft_delete_encounter(conn, eid, hidden_at=1700000000) + assert pdb.store.find_encounter_by_act_encid(conn, payload.encounter.encid)["hidden_at"] is not None finally: conn.close() # Re-upload the same encounter → revived + un-hidden. status2, eid2, *_ = _ingest_payload_sync(payload, "Menludiir", "Exordium", "plugin:123", {}) assert status2 == "revived" and eid2 == eid - conn = pdb.init_db(db_file) + conn = pdb.ParsesStore(db_file).init_db() try: - assert pdb.find_encounter_by_act_encid(conn, payload.encounter.encid)["hidden_at"] is None + assert pdb.store.find_encounter_by_act_encid(conn, payload.encounter.encid)["hidden_at"] is None finally: conn.close() diff --git a/tests/server/test_parses_ingest_classify.py b/tests/server/test_parses_ingest_classify.py index 08427f1a..c6d85038 100644 --- a/tests/server/test_parses_ingest_classify.py +++ b/tests/server/test_parses_ingest_classify.py @@ -91,7 +91,7 @@ def _encounter_id_for(conn: sqlite3.Connection, act_encid: str) -> int: class _NoCloseConn: """Thin proxy around a sqlite3.Connection that makes close() a no-op. - _update_snapshots_sync opens a connection via parses_db.init_db() and + _update_snapshots_sync opens a connection via parses_db.store.init_db() and always calls conn.close() in its finally block. By wrapping the shared in-memory connection in this proxy, we keep the underlying connection alive for assertions after the helper returns. @@ -124,11 +124,11 @@ def __exit__(self, *args): @pytest.fixture -def parses_db_in_memory(monkeypatch): +def parses_db_in_memory(monkeypatch, tmp_path): """Point parses_db.DB_PATH at an in-memory DB for the duration of the test. Both _insert_encounter_rows_sync (caller-supplied conn) and - _update_snapshots_sync (opens its own via parses_db.init_db(DB_PATH)) + _update_snapshots_sync (opens its own via parses_db.ParsesStore(DB_PATH).init_db()) need to share state, so we hand the same connection back across both calls. @@ -136,10 +136,11 @@ def parses_db_in_memory(monkeypatch): the real connection in _NoCloseConn so close() is a no-op and the shared in-memory connection stays alive for assertions after the helper returns. """ - real_conn = parses_db.init_db(Path(":memory:")) + db_file = tmp_path / "parses.db" + real_conn = parses_db.ParsesStore(db_file).init_db() conn = _NoCloseConn(real_conn) - monkeypatch.setattr(parses_db, "DB_PATH", Path(":memory:")) - monkeypatch.setattr(parses_db, "init_db", lambda *a, **k: conn) + monkeypatch.setattr(parses_db.store, "path", db_file) + monkeypatch.setattr(parses_db.store, "init_db", lambda *a, **k: conn) try: yield conn finally: diff --git a/tests/server/test_parses_ingest_client_warnings.py b/tests/server/test_parses_ingest_client_warnings.py index 1bea538f..e6da6b7b 100644 --- a/tests/server/test_parses_ingest_client_warnings.py +++ b/tests/server/test_parses_ingest_client_warnings.py @@ -35,7 +35,7 @@ def _read_client_warnings(conn, encounter_id: int) -> str | None: def _ingest_and_get(payload: dict, *, tmp_path, monkeypatch) -> tuple[int, Path]: """Drive ``_ingest_payload_sync`` against a fresh tmp DB and return (encounter_id, db_file). The db_file path MUST be passed explicitly - when reading back — ``parses_db.init_db()``'s ``path: Path = DB_PATH`` + when reading back — ``parses_db.store.init_db()``'s ``path: Path = DB_PATH`` default arg captures DB_PATH at function-definition time, so monkey-patching the module attribute doesn't affect the no-arg call. All reads in these tests therefore go through ``init_db(db_file)``. @@ -44,8 +44,8 @@ def _ingest_and_get(payload: dict, *, tmp_path, monkeypatch) -> tuple[int, Path] from backend.server.api.parses.ingest import _ingest_payload_sync db_file: Path = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() req = IngestRequest(**payload) status, eid, *_ = _ingest_payload_sync(req, "Menludiir", "Exordium", "plugin:123", {}) @@ -60,7 +60,7 @@ def test_client_warnings_persisted_as_json(tmp_path, monkeypatch): eid, db_file = _ingest_and_get(payload, tmp_path=tmp_path, monkeypatch=monkeypatch) - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: raw = _read_client_warnings(conn, eid) finally: @@ -81,7 +81,7 @@ def test_client_warnings_absent_leaves_column_null(tmp_path, monkeypatch): eid, db_file = _ingest_and_get(payload, tmp_path=tmp_path, monkeypatch=monkeypatch) - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: raw = _read_client_warnings(conn, eid) finally: @@ -99,7 +99,7 @@ def test_client_warnings_empty_list_stored_as_null(tmp_path, monkeypatch): eid, db_file = _ingest_and_get(payload, tmp_path=tmp_path, monkeypatch=monkeypatch) - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: raw = _read_client_warnings(conn, eid) finally: @@ -128,7 +128,7 @@ def test_client_warnings_sanitises_entries(tmp_path, monkeypatch): eid, db_file = _ingest_and_get(payload, tmp_path=tmp_path, monkeypatch=monkeypatch) - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: raw = _read_client_warnings(conn, eid) finally: diff --git a/tests/server/test_parses_list.py b/tests/server/test_parses_list.py index c95d6756..e18d6761 100644 --- a/tests/server/test_parses_list.py +++ b/tests/server/test_parses_list.py @@ -399,8 +399,8 @@ def test_list_encounters_search_filters_title_zone_uploader(tmp_path, monkeypatc from backend.server.parses import db as parses_db db_path = tmp_path / "parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_path) - conn = parses_db.init_db(db_path) + monkeypatch.setattr(parses_db.store, "path", db_path) + conn = parses_db.ParsesStore(db_path).init_db() try: _seed_search_db(conn) finally: diff --git a/tests/server/test_parses_pet_filter.py b/tests/server/test_parses_pet_filter.py index 74d67653..68eb95d3 100644 --- a/tests/server/test_parses_pet_filter.py +++ b/tests/server/test_parses_pet_filter.py @@ -26,7 +26,7 @@ def parses_db_in_memory(tmp_path, monkeypatch): """Point parses_db.DB_PATH at a tmp-path-backed DB. The schema is pre-initialised by opening + closing a connection; subsequent - ``parses_db.init_db()`` calls (from the route handler in another + ``parses_db.store.init_db()`` calls (from the route handler in another thread) open a fresh connection against the same file, which is safe with sqlite3 + WAL. @@ -34,12 +34,12 @@ def parses_db_in_memory(tmp_path, monkeypatch): path early-returns when ``DB_PATH.exists()`` is False, and ``Path(':memory:').exists()`` is always False.""" db_file = tmp_path / "backend.server.parses.db" - parses_db.init_db(db_file).close() - monkeypatch.setattr(parses_db, "DB_PATH", db_file) + parses_db.ParsesStore(db_file).init_db().close() + monkeypatch.setattr(parses_db.store, "path", db_file) # The test body needs an open connection to seed + assert against. # The route handler opens its OWN connection via the unpatched # init_db(path) — same file, separate handle, no thread issues. - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: yield conn finally: diff --git a/tests/server/test_parses_tamper_report.py b/tests/server/test_parses_tamper_report.py index d23d55aa..7b78f8f7 100644 --- a/tests/server/test_parses_tamper_report.py +++ b/tests/server/test_parses_tamper_report.py @@ -46,9 +46,9 @@ def _read_tamper_rows() -> list[dict]: Used by the assertions below to check the persisted side-effects rather than mocking the DB layer (which would obscure the SQL interaction with the new columns).""" - conn = parses_db.init_db() + conn = parses_db.store.init_db() try: - return parses_db.list_tamper_reports(conn, status="all") + return parses_db.store.list_tamper_reports(conn, status="all") finally: conn.close() @@ -56,7 +56,7 @@ def _read_tamper_rows() -> list[dict]: def _read_encounter_count() -> int: """Verify the tamper-report endpoint NEVER writes to encounters — the whole reason this endpoint exists separately.""" - conn = parses_db.init_db() + conn = parses_db.store.init_db() try: row = conn.execute("SELECT COUNT(*) FROM encounters").fetchone() return int(row[0]) if row else 0 @@ -68,7 +68,7 @@ def _wipe_tamper_reports() -> None: """Per-test isolation. The session-scoped tmp DB is shared across tests, but each tamper-report test starts with an empty table so assertion counts don't drift across runs.""" - conn = parses_db.init_db() + conn = parses_db.store.init_db() try: conn.execute("DELETE FROM tamper_reports") conn.commit() diff --git a/tests/server/test_parses_world_scoping.py b/tests/server/test_parses_world_scoping.py index 24b3386a..43fbe537 100644 --- a/tests/server/test_parses_world_scoping.py +++ b/tests/server/test_parses_world_scoping.py @@ -93,14 +93,14 @@ def test_logger_server_wuoshi_stores_under_wuoshi(self, tmp_path, monkeypatch): """When ingest is called with world='Wuoshi', the encounter row has world='Wuoshi'.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() payload = IngestRequest(**_minimal_payload()) status, eid, *_ = _ingest_payload_sync(payload, "Menludiir", None, "plugin:123", {}, world="Wuoshi") assert status == "inserted" - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: row = conn.execute("SELECT world FROM encounters WHERE id = ?", (eid,)).fetchone() assert row[0] == "Wuoshi" @@ -110,13 +110,13 @@ def test_logger_server_wuoshi_stores_under_wuoshi(self, tmp_path, monkeypatch): def test_ingest_log_world_matches_encounter(self, tmp_path, monkeypatch): """ingest_log.world must match the encounter's world.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() payload = IngestRequest(**_minimal_payload()) _ingest_payload_sync(payload, "Menludiir", None, "plugin:123", {}, world="Kaladim") - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: row = conn.execute( "SELECT world FROM ingest_log WHERE act_encid = ?", (payload.encounter.encid,) @@ -129,8 +129,8 @@ def test_same_encid_different_world_both_inserted(self, tmp_path, monkeypatch): """Two ingest calls with the same act_encid but different worlds must both succeed (no UNIQUE collision).""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() payload = IngestRequest(**_minimal_payload()) status_v, eid_v, *_ = _ingest_payload_sync(payload, "Menludiir", None, "plugin:123", {}, world="Varsoon") @@ -139,7 +139,7 @@ def test_same_encid_different_world_both_inserted(self, tmp_path, monkeypatch): assert status_w == "inserted" assert eid_v != eid_w - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: count = conn.execute( "SELECT COUNT(*) FROM encounters WHERE act_encid = ?", (payload.encounter.encid,) @@ -152,8 +152,8 @@ def test_idempotency_is_world_scoped(self, tmp_path, monkeypatch): """Re-uploading the same (world, act_encid) returns 'skipped'; uploading the same act_encid under a DIFFERENT world is NOT skipped.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() payload = IngestRequest(**_minimal_payload()) _ingest_payload_sync(payload, "Menludiir", None, "plugin:123", {}, world="Varsoon") @@ -177,8 +177,8 @@ def test_varsoon_list_excludes_wuoshi_encounters(self, tmp_path, monkeypatch): """_list_encounters_sync(world='Varsoon') must not return encounters stored under 'Wuoshi'.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() payload_v = IngestRequest(**_minimal_payload("VARSOON1")) payload_w = IngestRequest(**_minimal_payload("WUOSHI01")) @@ -218,8 +218,8 @@ async def test_cross_server_delete_by_id_blocked(self, tmp_path, monkeypatch, ap DELETE /api/parses/{B} under Varsoon context → 404, B still in DB. DELETE /api/parses/{A} under Varsoon context → 200, A gone.""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() # Seed two encounters in different worlds. payload_a = IngestRequest(**_minimal_payload("AAAAAA")) @@ -243,7 +243,7 @@ async def test_cross_server_delete_by_id_blocked(self, tmp_path, monkeypatch, ap assert r_same.json() == {"deleted": 1} # Verify B still exists in the DB; A is gone. - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: b_row = conn.execute("SELECT id FROM encounters WHERE id = ?", (id_b,)).fetchone() a_row = conn.execute("SELECT id FROM encounters WHERE id = ?", (id_a,)).fetchone() @@ -338,8 +338,8 @@ async def test_http_ingest_attributes_encounter_world_from_logger_server(tmp_pat introduced in the merge of feature/per-server-urls (replacing the old _resolve_parse_world + current_world() fallback).""" db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(parses_db, "DB_PATH", db_file) - parses_db.init_db(db_file).close() + monkeypatch.setattr(parses_db.store, "path", db_file) + parses_db.ParsesStore(db_file).init_db().close() token = "eq2c_e2e_test_token" @@ -379,7 +379,7 @@ async def _fake_user(request): encounter_id = r.json()["encounter_id"] assert encounter_id is not None - conn = parses_db.init_db(db_file) + conn = parses_db.ParsesStore(db_file).init_db() try: row = conn.execute("SELECT world FROM encounters WHERE id = ?", (encounter_id,)).fetchone() finally: diff --git a/tests/server/test_rankings.py b/tests/server/test_rankings.py index abf9ebab..07ad9442 100644 --- a/tests/server/test_rankings.py +++ b/tests/server/test_rankings.py @@ -397,7 +397,7 @@ def _ins(conn, encid, title, *, success, players, guild, duration): deaths=0, success_level=success, ) - eid = pdb.insert_encounter( + eid = pdb.store.insert_encounter( conn, enc, source_dsn="eq2act", ingested_at=int(_time.time()), uploaded_by="Up", guild_name=guild ) combs = [ @@ -439,7 +439,7 @@ def _ins(conn, encid, title, *, success, players, guild, duration): for i in range(players) ] snaps = {f"P{i}": CombatantSnapshot(level=95, guild_name=guild, cls="Wizard") for i in range(players)} - pdb.insert_combatants_bulk(conn, eid, combs, snaps) + pdb.store.insert_combatants_bulk(conn, eid, combs, snaps) # Phase 4 (2026-05-30): the rankings loader's player_count subquery # now filters on is_player=1, and insert_combatants_bulk doesn't # populate that column (the classifier runs in the ingest path, not @@ -453,8 +453,8 @@ def _ins(conn, encid, title, *, success, players, guild, duration): @pytest.fixture() def rankings_db(tmp_path, monkeypatch): db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(pdb, "DB_PATH", db_file) - conn = pdb.init_db(db_file) + monkeypatch.setattr(pdb.store, "path", db_file) + conn = pdb.ParsesStore(db_file).init_db() _ins(conn, "WIN", "Tarinax", success=1, players=8, guild="Exordium", duration=60) # boss, raid _ins(conn, "TRASH", "a krait", success=1, players=8, guild="Exordium", duration=30) # not boss _ins(conn, "LOSS", "Cazel", success=2, players=8, guild="Exordium", duration=90) # not a win @@ -629,8 +629,8 @@ async def test_rankings_leaderboard_is_world_scoped(app, monkeypatch, tmp_path): # Seed a Varsoon boss kill and a distinct Wuoshi boss kill. db_file = tmp_path / "backend.server.parses.db" - monkeypatch.setattr(pdb, "DB_PATH", db_file) - conn = pdb.init_db(db_file) + monkeypatch.setattr(pdb.store, "path", db_file) + conn = pdb.ParsesStore(db_file).init_db() _ins(conn, "V-WIN", "Tarinax", success=1, players=8, guild="Exordium", duration=60) # defaults to Varsoon # Wuoshi kill uses a different boss title so results are unambiguous. enc_w = Encounter( @@ -646,7 +646,7 @@ async def test_rankings_leaderboard_is_world_scoped(app, monkeypatch, tmp_path): deaths=0, success_level=1, ) - weid = pdb.insert_encounter( + weid = pdb.store.insert_encounter( conn, enc_w, source_dsn="eq2act", @@ -655,7 +655,7 @@ async def test_rankings_leaderboard_is_world_scoped(app, monkeypatch, tmp_path): guild_name="DragonSlayers", world="Wuoshi", ) - pdb.insert_combatants_bulk( + pdb.store.insert_combatants_bulk( conn, weid, [ diff --git a/tests/server/test_zones_cache_invalidation.py b/tests/server/test_zones_cache_invalidation.py index 731311bd..76d2e4e8 100644 --- a/tests/server/test_zones_cache_invalidation.py +++ b/tests/server/test_zones_cache_invalidation.py @@ -270,18 +270,19 @@ async def test_delete_mob_no_invalidation_on_404(app, editor_override) -> None: @pytest.fixture -def parses_db_in_memory(monkeypatch): +def parses_db_in_memory(monkeypatch, tmp_path): """Shared in-memory parses DB for the duration of the test. Patches invalidate_is_player_cache so it operates on the in-memory connection rather than opening a new connection to DB_PATH.""" - conn = parses_db.init_db(Path(":memory:")) - monkeypatch.setattr(parses_db, "DB_PATH", Path(":memory:")) - monkeypatch.setattr(parses_db, "init_db", lambda *a, **k: conn) + db_file = tmp_path / "parses.db" + conn = parses_db.ParsesStore(db_file).init_db() + monkeypatch.setattr(parses_db.store, "path", db_file) + monkeypatch.setattr(parses_db.store, "init_db", lambda *a, **k: conn) monkeypatch.setattr( - parses_db, + parses_db.store, "invalidate_is_player_cache", - lambda *a, **k: parses_db.invalidate_is_player_cache_with_conn(conn), + lambda *a, **k: parses_db.store.invalidate_is_player_cache_with_conn(conn), ) try: yield conn