diff --git a/backend/db_catalogue.py b/backend/db_catalogue.py index 50d35762..a0d3739d 100644 --- a/backend/db_catalogue.py +++ b/backend/db_catalogue.py @@ -62,31 +62,13 @@ class of errors the read helpers degrade gracefully on (fresh volume, return "no such table" in msg or "no such column" in msg -class BaseCatalogue: - """Read (and build) access to one eq2db SQLite file.""" - - #: Enable ``PRAGMA foreign_keys`` per connection — set True when the - #: schema relies on ON DELETE CASCADE (aas, zones, raids). - FOREIGN_KEYS: ClassVar[bool] = False - - #: Create the shared ``_meta`` provenance table in init_db. Every - #: module uses it except classes.db (committed pre-populated, no - #: download provenance to track). - CREATE_META: ClassVar[bool] = True +class PathBound: + """The path-bound identity + dunder surface shared by every SQLite data + interface: sync catalogues/stores (:class:`BaseCatalogue`) and the async + users.db domain stores (:class:`AsyncStoreBase`).""" def __init__(self, path: Path) -> None: self.path = Path(path) - # Connections opened via the context-manager protocol; a stack so - # nested ``with cat as conn:`` blocks close their own connection. - self._ctx_conns: list[sqlite3.Connection] = [] - - def __init_subclass__(cls, **kwargs) -> None: - """Fail at class-definition time when a subclass forgets - ``_create_schema`` — earlier and clearer than the first - ``init_db()`` call raising NotImplementedError at runtime.""" - super().__init_subclass__(**kwargs) - if cls._create_schema is BaseCatalogue._create_schema: - raise TypeError(f"{cls.__name__} must implement _create_schema(conn)") # ── Introspection / logging ────────────────────────────────────────────── @@ -114,7 +96,7 @@ def __bool__(self) -> bool: def __eq__(self, other: object) -> bool: """Two catalogues are equal when they are the same class over the same path — handy in tests (``assert cat == RaidCatalogue(p)``).""" - if not isinstance(other, BaseCatalogue): + if not isinstance(other, PathBound): return NotImplemented return type(self) is type(other) and self.path == other.path @@ -131,6 +113,38 @@ def __fspath__(self) -> str: ``os.path.getsize(cat)`` and ``sqlite3.connect(cat)`` all work.""" return str(self.path) + def clear_caches(self) -> None: + """Reset per-instance caches — used by tests and build scripts. + + Default: no caches. Subclasses holding caches override.""" + + +class BaseCatalogue(PathBound): + """Read (and build) access to one SQLite file via synchronous sqlite3.""" + + #: Enable ``PRAGMA foreign_keys`` per connection — set True when the + #: schema relies on ON DELETE CASCADE (aas, zones, raids). + FOREIGN_KEYS: ClassVar[bool] = False + + #: Create the shared ``_meta`` provenance table in init_db. Every + #: module uses it except classes.db (committed pre-populated, no + #: download provenance to track). + CREATE_META: ClassVar[bool] = True + + def __init__(self, path: Path) -> None: + super().__init__(path) + # Connections opened via the context-manager protocol; a stack so + # nested ``with cat as conn:`` blocks close their own connection. + self._ctx_conns: list[sqlite3.Connection] = [] + + def __init_subclass__(cls, **kwargs) -> None: + """Fail at class-definition time when a subclass forgets + ``_create_schema`` — earlier and clearer than the first + ``init_db()`` call raising NotImplementedError at runtime.""" + super().__init_subclass__(**kwargs) + if cls._create_schema is BaseCatalogue._create_schema: + raise TypeError(f"{cls.__name__} must implement _create_schema(conn)") + # ── Connection lifecycle ───────────────────────────────────────────────── def __enter__(self) -> sqlite3.Connection: @@ -243,7 +257,13 @@ def _create_schema(self, conn: sqlite3.Connection) -> None: def _post_init(self, conn: sqlite3.Connection) -> None: """Optional post-commit startup work (data backfills). Default: none.""" - def clear_caches(self) -> None: - """Reset per-instance caches — used by tests and build scripts. - Default: no caches. Subclasses holding caches override.""" +class AsyncStoreBase(PathBound): + """Base for the aiosqlite-backed users.db domain stores. + + Unlike :class:`BaseCatalogue`, these stores do NOT own their schema: + the whole users.db family shares one file whose tables/migrations are + orchestrated by ``backend.server.db.init_db()`` at startup. Each domain + method opens its own aiosqlite connection against ``self.path`` — + exactly the per-call transaction shape the old free functions had, + minus the ``path: Path = DB_PATH`` threading.""" diff --git a/backend/server/api/character/views.py b/backend/server/api/character/views.py index 6eabf103..b1585e67 100644 --- a/backend/server/api/character/views.py +++ b/backend/server/api/character/views.py @@ -415,10 +415,10 @@ async def prewarm_character_cache() -> None: pre-warms at boot, not just the default server. """ from backend.server.core.executor import run_sync - from backend.server.db.servers import list_servers_sync + from backend.server.db.servers import store as _servers_db try: - servers = await run_sync(list_servers_sync) + servers = await run_sync(_servers_db.list_servers_sync) except Exception as exc: _log.warning("[startup] Could not load server registry for pre-warm: %s", exc) return diff --git a/backend/server/api/favorites.py b/backend/server/api/favorites.py index d0c66c2f..acec4092 100644 --- a/backend/server/api/favorites.py +++ b/backend/server/api/favorites.py @@ -29,8 +29,8 @@ from backend.server.core.cache_keys import char_cache_key from backend.server.core.executor import run_sync from backend.server.core.validation import validate_character_name -from backend.server.db import favorites as favorites_db from backend.server.db import get_active_claims +from backend.server.db.favorites import store as favorites_db from backend.server.limiter import limiter from backend.server.server_context import current_world diff --git a/backend/server/api/raid_schedule.py b/backend/server/api/raid_schedule.py index aed6f6f8..1af474f2 100644 --- a/backend/server/api/raid_schedule.py +++ b/backend/server/api/raid_schedule.py @@ -20,7 +20,7 @@ from backend.server.core.audit_log import audit_log from backend.server.core.text_moderation import contains_blocked_term, sanitize_text from backend.server.core.twitch import is_blocked, parse_twitch_login -from backend.server.db.raid_schedule import get_schedule, replace_schedule +from backend.server.db.raid_schedule import store as raid_schedule_db from backend.server.server_context import current_world _log = logging.getLogger(__name__) @@ -165,7 +165,7 @@ def _fmt_schedule(teams: list[dict]) -> RaidScheduleResponse: async def get_raid_schedule(guild_name: str) -> RaidScheduleResponse: """Public — anyone may view a guild's raid schedule.""" _validate_guild_name(guild_name) - teams = await get_schedule(current_world(), guild_name) + teams = await raid_schedule_db.get_schedule(current_world(), guild_name) return _fmt_schedule(teams) @@ -232,6 +232,6 @@ async def put_raid_schedule(guild_name: str, body: RaidScheduleInput, request: R ) world = current_world() - await replace_schedule(world, guild_name, db_teams, updated_by=user["id"]) + await raid_schedule_db.replace_schedule(world, guild_name, db_teams, updated_by=user["id"]) audit_log("raid_schedule_updated", actor=user["id"], guild=guild_name, teams=len(db_teams)) - return _fmt_schedule(await get_schedule(world, guild_name)) + return _fmt_schedule(await raid_schedule_db.get_schedule(world, guild_name)) diff --git a/backend/server/db/__init__.py b/backend/server/db/__init__.py index a5d8e7d9..dc14f505 100644 --- a/backend/server/db/__init__.py +++ b/backend/server/db/__init__.py @@ -48,62 +48,83 @@ def init_db(path: Path = DB_PATH) -> None: # --------------------------------------------------------------------------- -# Re-export the per-domain helpers so the existing API shape is preserved. -# Order matters: each domain only imports from web.db (this module) at the -# helper level — no inter-domain imports. +# Facade: re-export each domain store's bound methods so the existing +# `users_db.get_active_claims(...)` API shape is preserved. The domains are +# XStore(AsyncStoreBase) classes now (backend/db_catalogue.py) — the bound +# methods read the shared instance's `path` dynamically, so conftest +# re-points one attribute per store and every alias follows. # --------------------------------------------------------------------------- -from backend.server.db.claims import ( # noqa: E402,F401 - delete_claim, - delete_claims_for_user, - get_active_claims, - get_claim_by_id, - list_claims, - review_claim, - set_primary, - submit_claim, - withdraw_claim, -) -from backend.server.db.item_watch import ( # noqa: E402,F401 - add_item_watch, - list_item_watches, - remove_item_watch, - update_item_watch_check, -) -from backend.server.db.servers import ( # noqa: E402,F401 - get_server_by_subdomain_sync, - get_server_by_world_sync, - list_servers_sync, - set_default_server_sync, - upsert_server_settings_sync, -) -from backend.server.db.tokens import ( # noqa: E402,F401 - generate_token, - hash_token, - list_api_tokens, - lookup_api_token, - mint_api_token, - revoke_api_token, -) -from backend.server.db.users import ( # noqa: E402,F401 - approve_all_pending, - create_role_request, - get_display_names_for_discord_ids, - get_role_request, - get_user_access_status, - grant_role, - has_role, - list_all_users, - list_pending_users, - list_role_assignments, - list_role_requests, - list_roles_for_user, - review_and_grant_role, - review_role_request, - revoke_role, - role_has_capability, - set_user_access, - upsert_user, - user_has_capability_via_db, - withdraw_role_request, +from backend.server.db.claims import store as claims_store # noqa: E402 +from backend.server.db.favorites import store as favorites_store # noqa: E402 +from backend.server.db.item_watch import store as item_watch_store # noqa: E402 +from backend.server.db.raid_schedule import store as raid_schedule_store # noqa: E402 +from backend.server.db.servers import store as servers_store # noqa: E402 +from backend.server.db.tokens import store as tokens_store # noqa: E402 +from backend.server.db.users import store as users_store # noqa: E402 + +# claims +delete_claim = claims_store.delete_claim +delete_claims_for_user = claims_store.delete_claims_for_user +get_active_claims = claims_store.get_active_claims +get_claim_by_id = claims_store.get_claim_by_id +list_claims = claims_store.list_claims +review_claim = claims_store.review_claim +set_primary = claims_store.set_primary +submit_claim = claims_store.submit_claim +withdraw_claim = claims_store.withdraw_claim + +# item watch +add_item_watch = item_watch_store.add_item_watch +list_item_watches = item_watch_store.list_item_watches +remove_item_watch = item_watch_store.remove_item_watch +update_item_watch_check = item_watch_store.update_item_watch_check + +# servers registry +get_server_by_subdomain_sync = servers_store.get_server_by_subdomain_sync +get_server_by_world_sync = servers_store.get_server_by_world_sync +list_servers_sync = servers_store.list_servers_sync +set_default_server_sync = servers_store.set_default_server_sync +upsert_server_settings_sync = servers_store.upsert_server_settings_sync + +# api tokens +generate_token = tokens_store.generate_token +hash_token = tokens_store.hash_token +list_api_tokens = tokens_store.list_api_tokens +lookup_api_token = tokens_store.lookup_api_token +mint_api_token = tokens_store.mint_api_token +revoke_api_token = tokens_store.revoke_api_token + +# users + roles +approve_all_pending = users_store.approve_all_pending +create_role_request = users_store.create_role_request +get_display_names_for_discord_ids = users_store.get_display_names_for_discord_ids +get_role_request = users_store.get_role_request +get_user_access_status = users_store.get_user_access_status +grant_role = users_store.grant_role +has_role = users_store.has_role +list_all_users = users_store.list_all_users +list_pending_users = users_store.list_pending_users +list_role_assignments = users_store.list_role_assignments +list_role_requests = users_store.list_role_requests +list_roles_for_user = users_store.list_roles_for_user +review_and_grant_role = users_store.review_and_grant_role +review_role_request = users_store.review_role_request +revoke_role = users_store.revoke_role +role_has_capability = users_store.role_has_capability +set_user_access = users_store.set_user_access +upsert_user = users_store.upsert_user +user_has_capability_via_db = users_store.user_has_capability_via_db +withdraw_role_request = users_store.withdraw_role_request + +#: Every domain store over users.db — conftest re-points `store.path` on +#: each after re-resolving DB_PATH from the env. +ALL_STORES = ( + claims_store, + favorites_store, + item_watch_store, + raid_schedule_store, + servers_store, + tokens_store, + users_store, ) diff --git a/backend/server/db/claims.py b/backend/server/db/claims.py index 00573721..fc22438c 100644 --- a/backend/server/db/claims.py +++ b/backend/server/db/claims.py @@ -18,273 +18,278 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql _SQL = load_sql(__file__) -async def get_active_claims( - discord_id: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> dict: - """ - Return all active claims for this user on the given world as: - { 'approved': [list of approved claim dicts], 'pending': claim dict or None } - Ignores withdrawn / rejected / superseded. - Claims are scoped to (discord_id, world) — a user's Varsoon and Wuoshi - primaries are completely independent. - """ - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["list_active_claims"], - (discord_id, world), - ) as cur: - rows = await cur.fetchall() - claims = [dict(r) for r in rows] - return { - "approved": [c for c in claims if c["status"] == "approved"], - "pending": next((c for c in claims if c["status"] == "pending"), None), - } +class ClaimsStore(AsyncStoreBase): + """users.db `claims` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) -async def submit_claim( - discord_id: str, - character_name: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> dict: - """ - Cancel any current *pending* claim for this user on this world (set to - 'withdrawn'), then insert a new pending claim. Returns the new claim dict. - Already-approved claims for other characters are not affected. - Raises ValueError if this character is already claimed on this world. - """ - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - # Reject if this character name is already claimed (approved or pending) - # by anyone *on this world* (EQ2 names are unique only within a server) - async with db.execute( - _SQL["check_character_name_taken"], - (character_name, world), - ) as cur: - existing = await cur.fetchone() - if existing: - if existing["discord_id"] == discord_id: - raise ValueError(f"'{character_name}' is already claimed on your account.") - else: - raise ValueError(f"'{character_name}' has already been claimed by another player.") - # Cancel any existing pending claim on this world (one pending per world at a time) - await db.execute( - _SQL["withdraw_pending_claims_on_world"], - (discord_id, world), - ) - cur = await db.execute( - _SQL["submit_claim"], - (discord_id, character_name, world), - ) - new_id = cur.lastrowid - await db.commit() - async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: - row = await cur2.fetchone() - assert row is not None, "INSERT succeeded but SELECT returned nothing" - return dict(row) - - -async def withdraw_claim( - claim_id: int, - discord_id: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> bool: - """ - Withdraw or remove a specific claim belonging to this user. - Works on both pending and approved claims. - If the withdrawn claim was the primary, the oldest remaining approved - character on the same world is automatically promoted to primary. - Returns True if something changed. - """ - async with aiosqlite.connect(path) as db: - # Check if this claim is primary before withdrawing (and capture world from row) - async with db.execute( - _SQL["select_primary_and_world"], - (claim_id, discord_id), - ) as cur: - row = await cur.fetchone() - was_primary = row is not None and row[0] == 1 - claim_world = row[1] if row is not None else world - - cur = await db.execute( - _SQL["withdraw_claim"], - (claim_id, discord_id), - ) - changed = cur.rowcount > 0 + async def get_active_claims( + self, + discord_id: str, + world: str = "Varsoon", + ) -> dict: + """ + Return all active claims for this user on the given world as: + { 'approved': [list of approved claim dicts], 'pending': claim dict or None } + Ignores withdrawn / rejected / superseded. + Claims are scoped to (discord_id, world) — a user's Varsoon and Wuoshi + primaries are completely independent. + """ + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["list_active_claims"], + (discord_id, world), + ) as cur: + rows = await cur.fetchall() + claims = [dict(r) for r in rows] + return { + "approved": [c for c in claims if c["status"] == "approved"], + "pending": next((c for c in claims if c["status"] == "pending"), None), + } - # Promote the oldest remaining approved character on the same world to primary - if changed and was_primary: + async def submit_claim( + self, + discord_id: str, + character_name: str, + world: str = "Varsoon", + ) -> dict: + """ + Cancel any current *pending* claim for this user on this world (set to + 'withdrawn'), then insert a new pending claim. Returns the new claim dict. + Already-approved claims for other characters are not affected. + Raises ValueError if this character is already claimed on this world. + """ + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + # Reject if this character name is already claimed (approved or pending) + # by anyone *on this world* (EQ2 names are unique only within a server) + async with db.execute( + _SQL["check_character_name_taken"], + (character_name, world), + ) as cur: + existing = await cur.fetchone() + if existing: + if existing["discord_id"] == discord_id: + raise ValueError(f"'{character_name}' is already claimed on your account.") + else: + raise ValueError(f"'{character_name}' has already been claimed by another player.") + # Cancel any existing pending claim on this world (one pending per world at a time) await db.execute( - _SQL["promote_oldest_to_primary"], - (discord_id, claim_world, claim_id), + _SQL["withdraw_pending_claims_on_world"], + (discord_id, world), ) + cur = await db.execute( + _SQL["submit_claim"], + (discord_id, character_name, world), + ) + new_id = cur.lastrowid + await db.commit() + async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: + row = await cur2.fetchone() + assert row is not None, "INSERT succeeded but SELECT returned nothing" + return dict(row) - await db.commit() - return changed - - -async def set_primary( - discord_id: str, - claim_id: int, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> bool: - """ - Set a specific approved claim as the user's primary character on the given world. - Clears is_primary on all other approved claims for this user on the same world. - Claims on other worlds are not affected. - Returns True if the target claim exists and was updated. - """ - async with aiosqlite.connect(path) as db: - # Verify the claim belongs to this user, is approved, and is on the right world - async with db.execute( - _SQL["find_approved_claim_for_user_on_world"], - (claim_id, discord_id, world), - ) as cur: - if not await cur.fetchone(): - return False - - # Clear primary on all approved claims for this user on this world, then set on target - await db.execute( - _SQL["clear_primary_on_world"], - (discord_id, world), - ) - await db.execute( - _SQL["set_primary_by_id"], - (claim_id,), - ) - await db.commit() - return True + async def withdraw_claim( + self, + claim_id: int, + discord_id: str, + world: str = "Varsoon", + ) -> bool: + """ + Withdraw or remove a specific claim belonging to this user. + Works on both pending and approved claims. + If the withdrawn claim was the primary, the oldest remaining approved + character on the same world is automatically promoted to primary. + Returns True if something changed. + """ + async with aiosqlite.connect(self.path) as db: + # Check if this claim is primary before withdrawing (and capture world from row) + async with db.execute( + _SQL["select_primary_and_world"], + (claim_id, discord_id), + ) as cur: + row = await cur.fetchone() + was_primary = row is not None and row[0] == 1 + claim_world = row[1] if row is not None else world + cur = await db.execute( + _SQL["withdraw_claim"], + (claim_id, discord_id), + ) + changed = cur.rowcount > 0 -async def get_claim_by_id( - claim_id: int, - path: Path = DB_PATH, -) -> dict | None: - """Return a single claim joined with its submitting user's info.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["find_claim_with_user"], - (claim_id,), - ) as cur: - row = await cur.fetchone() - return dict(row) if row else None + # Promote the oldest remaining approved character on the same world to primary + if changed and was_primary: + await db.execute( + _SQL["promote_oldest_to_primary"], + (discord_id, claim_world, claim_id), + ) + await db.commit() + return changed -async def list_claims( - status: str | None = None, - world: str | None = None, - path: Path = DB_PATH, -) -> list[dict]: - """ - List claims joined with user info, optionally filtered by status and/or world. - world=None returns claims from all worlds (admin overview). - world='Varsoon' (or any other) scopes to that server only. - Pending claims are sorted oldest-first (queue order). - All other statuses are sorted newest-first. - """ - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - where_parts: list[str] = [] - params: list = [] - if status: - where_parts.append("c.status = ?") - params.append(status) - if world is not None: - where_parts.append("c.world = ?") - params.append(world) - where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" - order = "ASC" if status == "pending" else "DESC" - async with db.execute( - _SQL["list_claims"].format(where_sql=where_sql, order=order), - params, - ) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] + async def set_primary( + self, + discord_id: str, + claim_id: int, + world: str = "Varsoon", + ) -> bool: + """ + Set a specific approved claim as the user's primary character on the given world. + Clears is_primary on all other approved claims for this user on the same world. + Claims on other worlds are not affected. + Returns True if the target claim exists and was updated. + """ + async with aiosqlite.connect(self.path) as db: + # Verify the claim belongs to this user, is approved, and is on the right world + async with db.execute( + _SQL["find_approved_claim_for_user_on_world"], + (claim_id, discord_id, world), + ) as cur: + if not await cur.fetchone(): + return False + # Clear primary on all approved claims for this user on this world, then set on target + await db.execute( + _SQL["clear_primary_on_world"], + (discord_id, world), + ) + await db.execute( + _SQL["set_primary_by_id"], + (claim_id,), + ) + await db.commit() + return True -async def review_claim( - claim_id: int, - status: str, - admin_id: str, - note: str | None = None, - path: Path = DB_PATH, -) -> dict | None: - """ - Approve or reject a claim. - On approval, auto-assigns is_primary if this is the user's first approved - character on this world (scoped to (discord_id, world) so each server has - an independent primary), and removes the new owner's favourite of the - character if one exists — you can't favourite your own character, and this - is the single funnel every approval path (admin + officer) flows through. - Returns the updated claim (with user info) or None if not found. - """ - async with aiosqlite.connect(path) as db: - async with db.execute(_SQL["select_claim_user_and_world"], (claim_id,)) as cur: - row = await cur.fetchone() - if not row: - return None - discord_id = row[0] - claim_world = row[1] - character_name = row[2] + async def get_claim_by_id( + self, + claim_id: int, + ) -> dict | None: + """Return a single claim joined with its submitting user's info.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["find_claim_with_user"], + (claim_id,), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None - await db.execute( - _SQL["review_claim"], - (status, admin_id, note, claim_id), - ) - # Auto-assign primary if this is the user's first approved character on this world - if status == "approved": + async def list_claims( + self, + status: str | None = None, + world: str | None = None, + ) -> list[dict]: + """ + List claims joined with user info, optionally filtered by status and/or world. + world=None returns claims from all worlds (admin overview). + world='Varsoon' (or any other) scopes to that server only. + Pending claims are sorted oldest-first (queue order). + All other statuses are sorted newest-first. + """ + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + where_parts: list[str] = [] + params: list = [] + if status: + where_parts.append("c.status = ?") + params.append(status) + if world is not None: + where_parts.append("c.world = ?") + params.append(world) + where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" + order = "ASC" if status == "pending" else "DESC" async with db.execute( - _SQL["check_user_has_primary_on_world"], - (discord_id, claim_world, claim_id), + _SQL["list_claims"].format(where_sql=where_sql, order=order), + params, ) as cur: - has_primary = await cur.fetchone() is not None - if not has_primary: - await db.execute( - _SQL["set_primary_by_id"], - (claim_id,), - ) - # The character is now theirs — drop their own-favourite of it. - # (The favourite count cache self-heals via TTL; see api/favorites.) + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def review_claim( + self, + claim_id: int, + status: str, + admin_id: str, + note: str | None = None, + ) -> dict | None: + """ + Approve or reject a claim. + On approval, auto-assigns is_primary if this is the user's first approved + character on this world (scoped to (discord_id, world) so each server has + an independent primary), and removes the new owner's favourite of the + character if one exists — you can't favourite your own character, and this + is the single funnel every approval path (admin + officer) flows through. + Returns the updated claim (with user info) or None if not found. + """ + async with aiosqlite.connect(self.path) as db: + async with db.execute(_SQL["select_claim_user_and_world"], (claim_id,)) as cur: + row = await cur.fetchone() + if not row: + return None + discord_id = row[0] + claim_world = row[1] + character_name = row[2] + await db.execute( - _SQL["remove_new_owners_favorite"], - (discord_id, claim_world, character_name), + _SQL["review_claim"], + (status, admin_id, note, claim_id), ) - await db.commit() + # Auto-assign primary if this is the user's first approved character on this world + if status == "approved": + async with db.execute( + _SQL["check_user_has_primary_on_world"], + (discord_id, claim_world, claim_id), + ) as cur: + has_primary = await cur.fetchone() is not None + if not has_primary: + await db.execute( + _SQL["set_primary_by_id"], + (claim_id,), + ) + # The character is now theirs — drop their own-favourite of it. + # (The favourite count cache self-heals via TTL; see api/favorites.) + await db.execute( + _SQL["remove_new_owners_favorite"], + (discord_id, claim_world, character_name), + ) + await db.commit() - return await get_claim_by_id(claim_id, path) + return await self.get_claim_by_id(claim_id) + async def delete_claim( + self, + claim_id: int, + ) -> bool: + """Hard-delete a claim row. Returns True if a row was deleted.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute(_SQL["delete_claim"], (claim_id,)) + deleted = cur.rowcount > 0 + await db.commit() + return deleted -async def delete_claim( - claim_id: int, - path: Path = DB_PATH, -) -> bool: - """Hard-delete a claim row. Returns True if a row was deleted.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute(_SQL["delete_claim"], (claim_id,)) - deleted = cur.rowcount > 0 - await db.commit() - return deleted + async def delete_claims_for_user( + self, + discord_id: str, + ) -> int: + """Hard-delete all claim rows for a user. Returns the number of rows deleted.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute(_SQL["delete_claims_for_user"], (discord_id,)) + count = cur.rowcount + await db.commit() + return count -async def delete_claims_for_user( - discord_id: str, - path: Path = DB_PATH, -) -> int: - """Hard-delete all claim rows for a user. Returns the number of rows deleted.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute(_SQL["delete_claims_for_user"], (discord_id,)) - count = cur.rowcount - await db.commit() - return count +# The shared default instance — every runtime consumer goes through this. +store = ClaimsStore() diff --git a/backend/server/db/favorites.py b/backend/server/db/favorites.py index 1b575a5b..53e837f1 100644 --- a/backend/server/db/favorites.py +++ b/backend/server/db/favorites.py @@ -12,64 +12,72 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql _SQL = load_sql(__file__) -async def add_favorite(discord_id: str, character_name: str, world: str, cap: int, path: Path = DB_PATH) -> bool: - """Add a favourite, atomically enforcing the per-user-per-world ``cap``. - - Returns False when nothing was inserted — either the favourite already - existed (idempotent) or the cap was reached. The guard lives in the INSERT - itself so concurrent requests cannot race a check-then-insert past the cap; - callers disambiguate the False case via ``is_favorited``.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["insert_favorite_capped"], - (discord_id, character_name, world, discord_id, world, cap), - ) - await db.commit() - return cur.rowcount > 0 - - -async def remove_favorite(discord_id: str, character_name: str, world: str, path: Path = DB_PATH) -> bool: - """Remove a favourite. Returns False when there was nothing to remove.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute(_SQL["delete_favorite"], (discord_id, character_name, world)) - await db.commit() - return cur.rowcount > 0 - - -async def count_favorites_for_character(character_name: str, world: str, path: Path = DB_PATH) -> int: - """How many users favourited this character. Kept separate from the - membership lookup so the API layer can cache the count and genuinely skip - this query (and its connection) on a cache hit.""" - async with aiosqlite.connect(path) as db: - async with db.execute(_SQL["count_for_character"], (character_name, world)) as cur: - row = await cur.fetchone() - return row[0] if row else 0 - - -async def is_favorited(discord_id: str, character_name: str, world: str, path: Path = DB_PATH) -> bool: - """Point lookup on the UNIQUE index — has this user favourited this character?""" - async with aiosqlite.connect(path) as db: - async with db.execute(_SQL["select_is_favorited"], (discord_id, character_name, world)) as cur: - return await cur.fetchone() is not None - - -async def count_user_favorites(discord_id: str, world: str, path: Path = DB_PATH) -> int: - """How many characters this user has favourited on this world.""" - async with aiosqlite.connect(path) as db: - async with db.execute(_SQL["count_for_user"], (discord_id, world)) as cur: - row = await cur.fetchone() - return row[0] if row else 0 - - -async def list_favorites(discord_id: str, world: str, path: Path = DB_PATH) -> list[dict]: - """The user's favourites on this world, newest first.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["select_for_user"], (discord_id, world)) as cur: - return [dict(r) for r in await cur.fetchall()] +class FavoritesStore(AsyncStoreBase): + """users.db `favorites` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + async def add_favorite(self, discord_id: str, character_name: str, world: str, cap: int) -> bool: + """Add a favourite, atomically enforcing the per-user-per-world ``cap``. + + Returns False when nothing was inserted — either the favourite already + existed (idempotent) or the cap was reached. The guard lives in the INSERT + itself so concurrent requests cannot race a check-then-insert past the cap; + callers disambiguate the False case via ``is_favorited``.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["insert_favorite_capped"], + (discord_id, character_name, world, discord_id, world, cap), + ) + await db.commit() + return cur.rowcount > 0 + + async def remove_favorite(self, discord_id: str, character_name: str, world: str) -> bool: + """Remove a favourite. Returns False when there was nothing to remove.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute(_SQL["delete_favorite"], (discord_id, character_name, world)) + await db.commit() + return cur.rowcount > 0 + + async def count_favorites_for_character(self, character_name: str, world: str) -> int: + """How many users favourited this character. Kept separate from the + membership lookup so the API layer can cache the count and genuinely skip + this query (and its connection) on a cache hit.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute(_SQL["count_for_character"], (character_name, world)) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + async def is_favorited(self, discord_id: str, character_name: str, world: str) -> bool: + """Point lookup on the UNIQUE index — has this user favourited this character?""" + async with aiosqlite.connect(self.path) as db: + async with db.execute(_SQL["select_is_favorited"], (discord_id, character_name, world)) as cur: + return await cur.fetchone() is not None + + async def count_user_favorites(self, discord_id: str, world: str) -> int: + """How many characters this user has favourited on this world.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute(_SQL["count_for_user"], (discord_id, world)) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + async def list_favorites(self, discord_id: str, world: str) -> list[dict]: + """The user's favourites on this world, newest first.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["select_for_user"], (discord_id, world)) as cur: + return [dict(r) for r in await cur.fetchall()] + + +# The shared default instance — every runtime consumer goes through this. +store = FavoritesStore() diff --git a/backend/server/db/item_watch.py b/backend/server/db/item_watch.py index c36f6929..e06d9dc4 100644 --- a/backend/server/db/item_watch.py +++ b/backend/server/db/item_watch.py @@ -11,84 +11,94 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql _SQL = load_sql(__file__) -async def add_item_watch( - guild_name: str, - character_name: str, - item_id: int, - item_name: str, - added_by: str, - added_by_name: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> dict: - """ - Add a new item watch entry. - Raises ValueError on duplicate (same world + guild + character + item_id). - Returns the new row dict. - """ - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - try: - cur = await db.execute( - _SQL["add_watch"], - (world, guild_name, character_name, item_id, item_name, added_by, added_by_name), - ) - except Exception as exc: - if "UNIQUE" in str(exc): - raise ValueError(f"'{item_name}' is already being watched for {character_name}.") from exc - raise - new_id = cur.lastrowid - await db.commit() - async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: - row = await cur2.fetchone() - assert row is not None, "INSERT succeeded but SELECT returned nothing" - return dict(row) +class ItemWatchStore(AsyncStoreBase): + """users.db `item_watch` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) -async def list_item_watches( - guild_name: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> list[dict]: - """Return all item watch entries for a guild on a given server, ordered by added_at descending.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["list_for_guild"], (guild_name, world)) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] + async def add_item_watch( + self, + guild_name: str, + character_name: str, + item_id: int, + item_name: str, + added_by: str, + added_by_name: str, + world: str = "Varsoon", + ) -> dict: + """ + Add a new item watch entry. + Raises ValueError on duplicate (same world + guild + character + item_id). + Returns the new row dict. + """ + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + try: + cur = await db.execute( + _SQL["add_watch"], + (world, guild_name, character_name, item_id, item_name, added_by, added_by_name), + ) + except Exception as exc: + if "UNIQUE" in str(exc): + raise ValueError(f"'{item_name}' is already being watched for {character_name}.") from exc + raise + new_id = cur.lastrowid + await db.commit() + async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: + row = await cur2.fetchone() + assert row is not None, "INSERT succeeded but SELECT returned nothing" + return dict(row) + async def list_item_watches( + self, + guild_name: str, + world: str = "Varsoon", + ) -> list[dict]: + """Return all item watch entries for a guild on a given server, ordered by added_at descending.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["list_for_guild"], (guild_name, world)) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] -async def remove_item_watch( - watch_id: int, - guild_name: str, - world: str = "Varsoon", - path: Path = DB_PATH, -) -> bool: - """Delete an item watch entry. Scoped to guild_name and world for safety. Returns True if deleted.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute(_SQL["remove_watch"], (watch_id, guild_name, world)) - deleted = cur.rowcount > 0 - await db.commit() - return deleted + async def remove_item_watch( + self, + watch_id: int, + guild_name: str, + world: str = "Varsoon", + ) -> bool: + """Delete an item watch entry. Scoped to guild_name and world for safety. Returns True if deleted.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute(_SQL["remove_watch"], (watch_id, guild_name, world)) + deleted = cur.rowcount > 0 + await db.commit() + return deleted + async def update_item_watch_check( + self, + watch_id: int, + seen: bool, + ) -> None: + """ + Record the result of an equipment check. + Updates last_checked_at always. + Updates last_seen_at (and first_seen_at if not yet set) only when seen=True. + """ + sql = _SQL["update_seen"] if seen else _SQL["update_unseen"] + async with aiosqlite.connect(self.path) as db: + await db.execute(sql, (watch_id,)) + await db.commit() -async def update_item_watch_check( - watch_id: int, - seen: bool, - path: Path = DB_PATH, -) -> None: - """ - Record the result of an equipment check. - Updates last_checked_at always. - Updates last_seen_at (and first_seen_at if not yet set) only when seen=True. - """ - sql = _SQL["update_seen"] if seen else _SQL["update_unseen"] - async with aiosqlite.connect(path) as db: - await db.execute(sql, (watch_id,)) - await db.commit() + +# The shared default instance — every runtime consumer goes through this. +store = ItemWatchStore() diff --git a/backend/server/db/raid_schedule.py b/backend/server/db/raid_schedule.py index 8510c234..882737e4 100644 --- a/backend/server/db/raid_schedule.py +++ b/backend/server/db/raid_schedule.py @@ -12,84 +12,95 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql _SQL = load_sql(__file__) -async def _teams_with_slots(db: aiosqlite.Connection, teams: list[dict]) -> list[dict]: - for t in teams: - async with db.execute(_SQL["select_slots"], (t["id"],)) as cur: - t["raids"] = [dict(r) for r in await cur.fetchall()] - return teams - - -async def get_schedule(world: str, guild_name: str, path: Path = DB_PATH) -> list[dict]: - """Return this guild's raid teams (ordered) each with a ``raids`` list.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["select_teams"], (world, guild_name)) as cur: - teams = [dict(r) for r in await cur.fetchall()] - return await _teams_with_slots(db, teams) - - -async def replace_schedule( - world: str, - guild_name: str, - teams: list[dict[str, Any]], - updated_by: str, - path: Path = DB_PATH, -) -> None: - """Transactionally replace a guild's whole schedule. - - Each team dict: ``{name, primary_tz, twitch_login|None, raids: [{days, - start_min, end_min, label|None}]}``. Bounded sizes (≤4 teams, ≤4 raids) are - validated by the caller; this just persists. team_index / slot_index are - assigned from list order. - """ - async with aiosqlite.connect(path) as db: - try: - await db.execute("BEGIN") - await db.execute(_SQL["delete_slots_for_guild"], (world, guild_name)) - await db.execute(_SQL["delete_teams_for_guild"], (world, guild_name)) - for team_index, team in enumerate(teams): - cur = await db.execute( - _SQL["insert_team"], - ( - world, - guild_name, - team_index, - team["name"], - team["primary_tz"], - team.get("twitch_login"), - updated_by, - ), - ) - team_id = cur.lastrowid - for slot_index, raid in enumerate(team.get("raids", [])): - await db.execute( - _SQL["insert_slot"], +class RaidScheduleStore(AsyncStoreBase): + """users.db `raid_schedule` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + @staticmethod + async def _teams_with_slots(db: aiosqlite.Connection, teams: list[dict]) -> list[dict]: + for t in teams: + async with db.execute(_SQL["select_slots"], (t["id"],)) as cur: + t["raids"] = [dict(r) for r in await cur.fetchall()] + return teams + + async def get_schedule(self, world: str, guild_name: str) -> list[dict]: + """Return this guild's raid teams (ordered) each with a ``raids`` list.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["select_teams"], (world, guild_name)) as cur: + teams = [dict(r) for r in await cur.fetchall()] + return await RaidScheduleStore._teams_with_slots(db, teams) + + async def replace_schedule( + self, + world: str, + guild_name: str, + teams: list[dict[str, Any]], + updated_by: str, + ) -> None: + """Transactionally replace a guild's whole schedule. + + Each team dict: ``{name, primary_tz, twitch_login|None, raids: [{days, + start_min, end_min, label|None}]}``. Bounded sizes (≤4 teams, ≤4 raids) are + validated by the caller; this just persists. team_index / slot_index are + assigned from list order. + """ + async with aiosqlite.connect(self.path) as db: + try: + await db.execute("BEGIN") + await db.execute(_SQL["delete_slots_for_guild"], (world, guild_name)) + await db.execute(_SQL["delete_teams_for_guild"], (world, guild_name)) + for team_index, team in enumerate(teams): + cur = await db.execute( + _SQL["insert_team"], ( - team_id, - slot_index, - raid["days"], - raid["start_min"], - raid["end_min"], - raid.get("label"), + world, + guild_name, + team_index, + team["name"], + team["primary_tz"], + team.get("twitch_login"), + updated_by, ), ) - await db.commit() - except Exception: - await db.rollback() - raise - - -async def list_all_teams_with_twitch(path: Path = DB_PATH) -> list[dict]: - """Every team across all worlds/guilds that has a twitch_login, each with - its raids. Used by the Twitch-live poller (Part 2).""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["select_teams_with_twitch"]) as cur: - teams = [dict(r) for r in await cur.fetchall()] - return await _teams_with_slots(db, teams) + team_id = cur.lastrowid + for slot_index, raid in enumerate(team.get("raids", [])): + await db.execute( + _SQL["insert_slot"], + ( + team_id, + slot_index, + raid["days"], + raid["start_min"], + raid["end_min"], + raid.get("label"), + ), + ) + await db.commit() + except Exception: + await db.rollback() + raise + + async def list_all_teams_with_twitch(self) -> list[dict]: + """Every team across all worlds/guilds that has a twitch_login, each with + its raids. Used by the Twitch-live poller (Part 2).""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["select_teams_with_twitch"]) as cur: + teams = [dict(r) for r in await cur.fetchall()] + return await RaidScheduleStore._teams_with_slots(db, teams) + + +# The shared default instance — every runtime consumer goes through this. +store = RaidScheduleStore() diff --git a/backend/server/db/servers.py b/backend/server/db/servers.py index bd81182b..26f5b498 100644 --- a/backend/server/db/servers.py +++ b/backend/server/db/servers.py @@ -13,77 +13,86 @@ import sqlite3 from pathlib import Path +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql _SQL = load_sql(__file__) -def _server_row(row: sqlite3.Row) -> dict: - return { - "world": row["world"], - "subdomain": row["subdomain"], - "display_name": row["display_name"], - "max_level": row["max_level"], - "current_xpac": row["current_xpac"], - "launch_dt": row["launch_dt"], - "is_default": bool(row["is_default"]), - } - - -def list_servers_sync(path: Path = DB_PATH) -> list[dict]: - with sqlite3.connect(path) as conn: - conn.row_factory = sqlite3.Row - return [_server_row(r) for r in conn.execute(_SQL["list_all"])] - - -def get_server_by_subdomain_sync(subdomain: str, path: Path = DB_PATH) -> dict | None: - with sqlite3.connect(path) as conn: - conn.row_factory = sqlite3.Row - row = conn.execute(_SQL["find_by_subdomain"], (subdomain.lower(),)).fetchone() - return _server_row(row) if row else None - - -def get_server_by_world_sync(world: str, path: Path = DB_PATH) -> dict | None: - with sqlite3.connect(path) as conn: - conn.row_factory = sqlite3.Row - row = conn.execute(_SQL["find_by_world"], (world,)).fetchone() - return _server_row(row) if row else None - - -def upsert_server_settings_sync( - world: str, - *, - max_level: int, - current_xpac: str | None, - launch_dt: str | None, - path: Path = DB_PATH, -) -> None: - with sqlite3.connect(path) as conn: - conn.execute( - _SQL["upsert_server_settings"], - (max_level, current_xpac, launch_dt, world), - ) - conn.commit() - - -def set_default_server_sync(world: str, path: Path = DB_PATH) -> bool: - """Atomically set one server as the default (is_default=1) and clear all others. - - Returns True when the world was found and updated, False when ``world`` is - unknown (i.e. no row matched the second UPDATE — there are never 0 defaults - after this call succeeds). - """ - with sqlite3.connect(path) as conn: - # First clear all, then set the target. Single transaction → never 0 or 2 defaults. - conn.execute(_SQL["clear_all_defaults"]) - cur = conn.execute(_SQL["set_default_by_world"], (world,)) - if cur.rowcount == 0: - # Unknown world: roll back by re-establishing any previous default. - # Re-query to pick the alphabetically first row as a safe fallback so - # we never leave all rows at is_default=0. - conn.execute(_SQL["set_default_fallback"]) +class ServersStore(AsyncStoreBase): + """users.db `servers` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + @staticmethod + def _server_row(row: sqlite3.Row) -> dict: + return { + "world": row["world"], + "subdomain": row["subdomain"], + "display_name": row["display_name"], + "max_level": row["max_level"], + "current_xpac": row["current_xpac"], + "launch_dt": row["launch_dt"], + "is_default": bool(row["is_default"]), + } + + def list_servers_sync(self) -> list[dict]: + with sqlite3.connect(self.path) as conn: + conn.row_factory = sqlite3.Row + return [ServersStore._server_row(r) for r in conn.execute(_SQL["list_all"])] + + def get_server_by_subdomain_sync(self, subdomain: str) -> dict | None: + with sqlite3.connect(self.path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute(_SQL["find_by_subdomain"], (subdomain.lower(),)).fetchone() + return ServersStore._server_row(row) if row else None + + def get_server_by_world_sync(self, world: str) -> dict | None: + with sqlite3.connect(self.path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute(_SQL["find_by_world"], (world,)).fetchone() + return ServersStore._server_row(row) if row else None + + def upsert_server_settings_sync( + self, + world: str, + *, + max_level: int, + current_xpac: str | None, + launch_dt: str | None, + ) -> None: + with sqlite3.connect(self.path) as conn: + conn.execute( + _SQL["upsert_server_settings"], + (max_level, current_xpac, launch_dt, world), + ) conn.commit() - return False - conn.commit() - return True + + def set_default_server_sync(self, world: str) -> bool: + """Atomically set one server as the default (is_default=1) and clear all others. + + Returns True when the world was found and updated, False when ``world`` is + unknown (i.e. no row matched the second UPDATE — there are never 0 defaults + after this call succeeds). + """ + with sqlite3.connect(self.path) as conn: + # First clear all, then set the target. Single transaction → never 0 or 2 defaults. + conn.execute(_SQL["clear_all_defaults"]) + cur = conn.execute(_SQL["set_default_by_world"], (world,)) + if cur.rowcount == 0: + # Unknown world: roll back by re-establishing any previous default. + # Re-query to pick the alphabetically first row as a safe fallback so + # we never leave all rows at is_default=0. + conn.execute(_SQL["set_default_fallback"]) + conn.commit() + return False + conn.commit() + return True + + +# The shared default instance — every runtime consumer goes through this. +store = ServersStore() diff --git a/backend/server/db/tokens.py b/backend/server/db/tokens.py index e71dd25a..d0970bf0 100644 --- a/backend/server/db/tokens.py +++ b/backend/server/db/tokens.py @@ -18,6 +18,7 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.db import DB_PATH from backend.sql_loader import load_sql @@ -26,111 +27,120 @@ TOKEN_PREFIX = "eq2c_" -def generate_token() -> tuple[str, str, str]: - """Mint a new bearer token. - - Returns (raw_token, sha256_hex, prefix_for_display). - The raw_token is what the user pastes into the plugin — show it ONCE. - """ - body = secrets.token_urlsafe(24) # ~32 char url-safe base64 - raw = f"{TOKEN_PREFIX}{body}" - h = hashlib.sha256(raw.encode("utf-8")).hexdigest() - prefix = raw[:12] # eq2c_ + 7 chars — enough to disambiguate in UI - return raw, h, prefix - - -def hash_token(raw: str) -> str: - return hashlib.sha256(raw.encode("utf-8")).hexdigest() - - -async def mint_api_token( - user_id: str, - name: str, - path: Path = DB_PATH, -) -> tuple[str, dict]: - """Create a new token row. Returns (raw_token, row_dict). - - The raw_token must be returned to the caller and shown to the user - immediately — it cannot be recovered later. - """ - raw, h, prefix = generate_token() - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - cur = await db.execute( - _SQL["mint_token"], - (user_id, name, h, prefix), - ) - new_id = cur.lastrowid - await db.commit() - async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: - row = await cur2.fetchone() - assert row is not None - return raw, dict(row) - - -async def list_api_tokens(user_id: str, path: Path = DB_PATH) -> list[dict]: - """All tokens for a user, newest first. Hash is omitted — UI doesn't need it.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["list_for_user"], - (user_id,), - ) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] - - -async def revoke_api_token( - user_id: str, - token_id: int, - path: Path = DB_PATH, -) -> bool: - """Mark a token revoked. Scoped to user_id so one user can't revoke another's. - Returns True if a row was updated.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["revoke_token"], - (token_id, user_id), - ) - await db.commit() - return cur.rowcount > 0 - - -async def lookup_api_token(raw_token: str, path: Path = DB_PATH) -> dict | None: - """Look up a token by its raw value (we hash internally). Returns the - row plus the joined user info, or None if not found / revoked / expired. - Side effect: bumps last_used_at on success.""" - if not raw_token or not raw_token.startswith(TOKEN_PREFIX): - return None - h = hash_token(raw_token) - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["lookup_by_hash"], - (h,), - ) as cur: - row = await cur.fetchone() - if row is None or row["revoked_at"] is not None: - return None - # Coalesce last_used_at writes to 60s buckets. - # - # Plugin uploads fire multiple times per second during a raid; committing - # an UPDATE on every upload was a real write-storm risk (WAL mitigates - # locking but the disk write itself is the cost). Sub-minute precision - # on this column isn't useful — the UI shows "last used 5 min ago", - # not "last used 0.6 seconds ago". The existing SELECT already pulled - # the current value as part of the row fetch in lookup callers; check - # against it here. - now = int(time.time()) - last_used = row["last_used_at"] - did_write = last_used is None or (now - int(last_used)) >= 60 - if did_write: - await db.execute( - _SQL["update_last_used_at"], - (now, row["token_id"]), +class TokensStore(AsyncStoreBase): + """users.db `tokens` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + @staticmethod + def generate_token() -> tuple[str, str, str]: + """Mint a new bearer token. + + Returns (raw_token, sha256_hex, prefix_for_display). + The raw_token is what the user pastes into the plugin — show it ONCE. + """ + body = secrets.token_urlsafe(24) # ~32 char url-safe base64 + raw = f"{TOKEN_PREFIX}{body}" + h = hashlib.sha256(raw.encode("utf-8")).hexdigest() + prefix = raw[:12] # eq2c_ + 7 chars — enough to disambiguate in UI + return raw, h, prefix + + @staticmethod + def hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + async def mint_api_token( + self, + user_id: str, + name: str, + ) -> tuple[str, dict]: + """Create a new token row. Returns (raw_token, row_dict). + + The raw_token must be returned to the caller and shown to the user + immediately — it cannot be recovered later. + """ + raw, h, prefix = TokensStore.generate_token() + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + _SQL["mint_token"], + (user_id, name, h, prefix), ) + new_id = cur.lastrowid await db.commit() - result = dict(row) - if did_write: - result["last_used_at"] = now - return result + async with db.execute(_SQL["find_by_id"], (new_id,)) as cur2: + row = await cur2.fetchone() + assert row is not None + return raw, dict(row) + + async def list_api_tokens(self, user_id: str) -> list[dict]: + """All tokens for a user, newest first. Hash is omitted — UI doesn't need it.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["list_for_user"], + (user_id,), + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def revoke_api_token( + self, + user_id: str, + token_id: int, + ) -> bool: + """Mark a token revoked. Scoped to user_id so one user can't revoke another's. + Returns True if a row was updated.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["revoke_token"], + (token_id, user_id), + ) + await db.commit() + return cur.rowcount > 0 + + async def lookup_api_token(self, raw_token: str) -> dict | None: + """Look up a token by its raw value (we hash internally). Returns the + row plus the joined user info, or None if not found / revoked / expired. + Side effect: bumps last_used_at on success.""" + if not raw_token or not raw_token.startswith(TOKEN_PREFIX): + return None + h = TokensStore.hash_token(raw_token) + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["lookup_by_hash"], + (h,), + ) as cur: + row = await cur.fetchone() + if row is None or row["revoked_at"] is not None: + return None + # Coalesce last_used_at writes to 60s buckets. + # + # Plugin uploads fire multiple times per second during a raid; committing + # an UPDATE on every upload was a real write-storm risk (WAL mitigates + # locking but the disk write itself is the cost). Sub-minute precision + # on this column isn't useful — the UI shows "last used 5 min ago", + # not "last used 0.6 seconds ago". The existing SELECT already pulled + # the current value as part of the row fetch in lookup callers; check + # against it here. + now = int(time.time()) + last_used = row["last_used_at"] + did_write = last_used is None or (now - int(last_used)) >= 60 + if did_write: + await db.execute( + _SQL["update_last_used_at"], + (now, row["token_id"]), + ) + await db.commit() + result = dict(row) + if did_write: + result["last_used_at"] = now + return result + + +# The shared default instance — every runtime consumer goes through this. +store = TokensStore() diff --git a/backend/server/db/users.py b/backend/server/db/users.py index f3e54eff..718cb29b 100644 --- a/backend/server/db/users.py +++ b/backend/server/db/users.py @@ -11,6 +11,7 @@ import aiosqlite +from backend.db_catalogue import AsyncStoreBase from backend.server.core.sql_helpers import build_where from backend.server.db import DB_PATH from backend.sql_loader import load_sql @@ -18,368 +19,360 @@ _SQL = load_sql(__file__) -async def upsert_user( - discord_id: str, - discord_name: str, - discord_username: str, - avatar: str | None, - admin_ids: frozenset[str] = frozenset(), - open_signup: bool = False, - path: Path = DB_PATH, -) -> str: - """Insert a new user row or update name/username/avatar and bump last_seen. - - Returns the user's access_status after the upsert. - - Admin IDs (from ADMIN_DISCORD_IDS env var) are always forced to 'approved' - regardless of what is stored — this prevents admins from being locked out - even after a complete database wipe. - - ``open_signup`` (config.OPEN_SIGNUP) auto-approves brand-new users so they - skip the admin-approval queue. It only affects the *initial* status on - insert — re-login preserves whatever is stored (see the ON CONFLICT clause), - so flipping the flag off later never re-pends an already-approved user. - """ - is_admin = discord_id in admin_ids - new_user_status = "approved" if (is_admin or open_signup) else "pending" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - await db.execute( - _SQL["upsert_user"], - ( - discord_id, - discord_name, - discord_username, - avatar, - new_user_status, - 1 if is_admin else 0, - ), - ) - await db.commit() - async with db.execute(_SQL["select_access_status"], (discord_id,)) as cur: - row = await cur.fetchone() - return row["access_status"] if row else "pending" - - -async def get_user_access_status(discord_id: str, path: Path = DB_PATH) -> str: - """Return the access_status for a user, or 'pending' if not found.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["select_access_status"], (discord_id,)) as cur: - row = await cur.fetchone() - return row["access_status"] if row else "pending" - - -async def get_display_names_for_discord_ids(ids: list[str], path: Path = DB_PATH) -> dict[str, str]: - """Return {discord_id: discord_name} for every id present in users. - - Missing/empty input returns {}. Non-discord-id tokens (e.g. - 'eq2i_scrape', 'unknown') are silently absent from the result — - callers handle the fallback display.""" - if not ids: - return {} - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - placeholders = ",".join("?" for _ in ids) - async with db.execute( - _SQL["select_display_names_by_ids"].format(placeholders=placeholders), - ids, - ) as cur: - rows = await cur.fetchall() - return {r["discord_id"]: r["discord_name"] for r in rows} - - -async def list_pending_users(path: Path = DB_PATH) -> list[dict]: - """Return all users with access_status = 'pending', newest first.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["list_pending_users"]) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] - - -async def approve_all_pending(path: Path = DB_PATH) -> int: - """Flip every access_status='pending' user to 'approved'. Returns the number - of rows updated. Used at startup when OPEN_SIGNUP is enabled to clear the - pre-existing approval backlog. Idempotent — a no-op once nothing is pending. - """ - async with aiosqlite.connect(path) as db: - cur = await db.execute(_SQL["approve_all_pending"]) - await db.commit() - return cur.rowcount - - -async def list_all_users(path: Path = DB_PATH) -> list[dict]: - """Return all users with access_status and total claim count, newest first.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute(_SQL["list_all_users_with_claim_count"]) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] - - -async def set_user_access(discord_id: str, status: str, path: Path = DB_PATH) -> bool: - """Set access_status for a user. Returns True if a row was updated.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["update_user_access_status"], - (status, discord_id), - ) - await db.commit() - return cur.rowcount > 0 - - -# --------------------------------------------------------------------------- -# Role helpers -# --------------------------------------------------------------------------- -# -# Role strings are validated at the route layer (against the KNOWN_ROLES set in -# web/auth_deps.py) — these helpers accept any string so test fixtures and -# future roles don't need a DB-layer code change. - - -async def grant_role( - discord_id: str, - role: str, - granted_by: str, - path: Path = DB_PATH, -) -> bool: - """Grant a role to a user. Idempotent — re-granting an existing (user, - role) pair is a no-op (returns False). Returns True when a new row was - actually inserted.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["grant_role"], - (discord_id, role, granted_by), - ) - await db.commit() - return cur.rowcount > 0 - - -async def revoke_role(discord_id: str, role: str, path: Path = DB_PATH) -> bool: - """Revoke a role. Returns True if a row was deleted (i.e. the user had it).""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["revoke_role"], - (discord_id, role), - ) - await db.commit() - return cur.rowcount > 0 - - -async def list_roles_for_user(discord_id: str, path: Path = DB_PATH) -> list[str]: - """All roles assigned to a single user, sorted for stable display.""" - async with aiosqlite.connect(path) as db: - async with db.execute( - _SQL["list_roles_for_user"], - (discord_id,), - ) as cur: - rows = await cur.fetchall() - return [r[0] for r in rows] - - -async def has_role(discord_id: str, role: str, path: Path = DB_PATH) -> bool: - """Cheap (indexed) existence check — used on the hot path of the editor - auth dep, so kept as a single-row SELECT rather than reusing list_roles.""" - async with aiosqlite.connect(path) as db: - async with db.execute( - _SQL["check_has_role"], - (discord_id, role), - ) as cur: - return await cur.fetchone() is not None - - -async def create_role_request( - discord_id: str, - role: str, - user_note: str | None, - path: Path = DB_PATH, -) -> int: - """Submit a pending role request. Raises ``sqlite3.IntegrityError`` if the - user already has a pending request for this role (the partial unique index - on (discord_id, role) WHERE status='pending' enforces it). Returns the new - request's id.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["create_role_request"], - (discord_id, role, user_note), - ) - await db.commit() - return int(cur.lastrowid or 0) - - -async def list_role_requests( - *, - status: str | None = None, - discord_id: str | None = None, - path: Path = DB_PATH, -) -> list[dict]: - """List role requests, optionally filtered. Pending sorted oldest-first - (queue order); everything else newest-first (audit-trail browsing). - - Joins the user table so callers can render the requester without a - second lookup — admin queue needs the name; user-list of own history - technically doesn't but the cost is zero.""" - where: list[str] = [] - params: list = [] - if status is not None: - where.append("rr.status = ?") - params.append(status) - if discord_id is not None: - where.append("rr.discord_id = ?") - params.append(discord_id) - where_sql = build_where(where) - # Pending: oldest first (FIFO queue). Anything resolved: newest first. - order_sql = ( - "ORDER BY rr.requested_at ASC, rr.id ASC" - if status == "pending" - else "ORDER BY rr.requested_at DESC, rr.id DESC" - ) - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["list_role_requests"].format(where_sql=where_sql, order_sql=order_sql), - params, - ) as cur: - rows = await cur.fetchall() - return [dict(r) for r in rows] - - -async def get_role_request(request_id: int, path: Path = DB_PATH) -> dict | None: - """Single-row fetch, same shape as list_role_requests entries. Used by the - admin approve/reject endpoints for the 404 check + the role/discord_id - payload that the grant flow needs.""" - async with aiosqlite.connect(path) as db: - db.row_factory = aiosqlite.Row - async with db.execute( - _SQL["get_role_request"], - (request_id,), - ) as cur: - row = await cur.fetchone() - return dict(row) if row else None - - -async def review_role_request( - request_id: int, - status: str, - reviewed_by: str, - admin_note: str | None = None, - path: Path = DB_PATH, -) -> dict | None: - """Approve or reject a pending request. Returns the updated row, or None - if no pending request with that id exists (already resolved, or unknown).""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["review_role_request"], - (status, reviewed_by, admin_note, request_id), - ) - await db.commit() - if cur.rowcount == 0: - return None - return await get_role_request(request_id, path=path) - - -async def review_and_grant_role( - request_id: int, - status: str, - admin_id: str, - note: str | None = None, - path: Path = DB_PATH, -) -> dict | None: - """Atomically mark a role request approved + insert the user_roles row. - - Single transaction so a process crash between the two writes can't leave - the queue with a phantom-approved row whose grant never landed. Returns - the reviewed request dict (or None if not found / already reviewed). - - Idempotency: if the user already holds the role (e.g. admin granted it - directly between submit + approve), the INSERT OR IGNORE is a no-op and - the request still transitions to approved. - """ - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["review_role_request"], - (status, admin_id, note, request_id), - ) - if cur.rowcount == 0: - return None - # Fetch the request row so we can grant the role - async with db.execute( - _SQL["select_role_request_grant_info"], - (request_id,), - ) as sel: - row = await sel.fetchone() - if row is not None: - discord_id, role = row +class UsersStore(AsyncStoreBase): + """users.db `users` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db); methods open per-call + connections against ``self.path``.""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + async def upsert_user( + self, + discord_id: str, + discord_name: str, + discord_username: str, + avatar: str | None, + admin_ids: frozenset[str] = frozenset(), + open_signup: bool = False, + ) -> str: + """Insert a new user row or update name/username/avatar and bump last_seen. + + Returns the user's access_status after the upsert. + + Admin IDs (from ADMIN_DISCORD_IDS env var) are always forced to 'approved' + regardless of what is stored — this prevents admins from being locked out + even after a complete database wipe. + + ``open_signup`` (config.OPEN_SIGNUP) auto-approves brand-new users so they + skip the admin-approval queue. It only affects the *initial* status on + insert — re-login preserves whatever is stored (see the ON CONFLICT clause), + so flipping the flag off later never re-pends an already-approved user. + """ + is_admin = discord_id in admin_ids + new_user_status = "approved" if (is_admin or open_signup) else "pending" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row await db.execute( + _SQL["upsert_user"], + ( + discord_id, + discord_name, + discord_username, + avatar, + new_user_status, + 1 if is_admin else 0, + ), + ) + await db.commit() + async with db.execute(_SQL["select_access_status"], (discord_id,)) as cur: + row = await cur.fetchone() + return row["access_status"] if row else "pending" + + async def get_user_access_status(self, discord_id: str) -> str: + """Return the access_status for a user, or 'pending' if not found.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["select_access_status"], (discord_id,)) as cur: + row = await cur.fetchone() + return row["access_status"] if row else "pending" + + async def get_display_names_for_discord_ids(self, ids: list[str]) -> dict[str, str]: + """Return {discord_id: discord_name} for every id present in users. + + Missing/empty input returns {}. Non-discord-id tokens (e.g. + 'eq2i_scrape', 'unknown') are silently absent from the result — + callers handle the fallback display.""" + if not ids: + return {} + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + placeholders = ",".join("?" for _ in ids) + async with db.execute( + _SQL["select_display_names_by_ids"].format(placeholders=placeholders), + ids, + ) as cur: + rows = await cur.fetchall() + return {r["discord_id"]: r["discord_name"] for r in rows} + + async def list_pending_users(self) -> list[dict]: + """Return all users with access_status = 'pending', newest first.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["list_pending_users"]) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def approve_all_pending(self) -> int: + """Flip every access_status='pending' user to 'approved'. Returns the number + of rows updated. Used at startup when OPEN_SIGNUP is enabled to clear the + pre-existing approval backlog. Idempotent — a no-op once nothing is pending. + """ + async with aiosqlite.connect(self.path) as db: + cur = await db.execute(_SQL["approve_all_pending"]) + await db.commit() + return cur.rowcount + + async def list_all_users(self) -> list[dict]: + """Return all users with access_status and total claim count, newest first.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute(_SQL["list_all_users_with_claim_count"]) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def set_user_access(self, discord_id: str, status: str) -> bool: + """Set access_status for a user. Returns True if a row was updated.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["update_user_access_status"], + (status, discord_id), + ) + await db.commit() + return cur.rowcount > 0 + + # --------------------------------------------------------------------------- + # Role helpers + # --------------------------------------------------------------------------- + # + # Role strings are validated at the route layer (against the KNOWN_ROLES set in + # web/auth_deps.py) — these helpers accept any string so test fixtures and + # future roles don't need a DB-layer code change. + + async def grant_role( + self, + discord_id: str, + role: str, + granted_by: str, + ) -> bool: + """Grant a role to a user. Idempotent — re-granting an existing (user, + role) pair is a no-op (returns False). Returns True when a new row was + actually inserted.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( _SQL["grant_role"], - (discord_id, role, admin_id), + (discord_id, role, granted_by), + ) + await db.commit() + return cur.rowcount > 0 + + async def revoke_role(self, discord_id: str, role: str) -> bool: + """Revoke a role. Returns True if a row was deleted (i.e. the user had it).""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["revoke_role"], + (discord_id, role), ) - await db.commit() - return await get_role_request(request_id, path=path) - - -async def withdraw_role_request( - request_id: int, - discord_id: str, - path: Path = DB_PATH, -) -> bool: - """User-initiated cancellation. Scoped to the requester so one user can't - withdraw another's request. Only pending requests can be withdrawn — - historical rows stay immutable. Returns True if a row transitioned.""" - async with aiosqlite.connect(path) as db: - cur = await db.execute( - _SQL["withdraw_role_request"], - (request_id, discord_id), + await db.commit() + return cur.rowcount > 0 + + async def list_roles_for_user(self, discord_id: str) -> list[str]: + """All roles assigned to a single user, sorted for stable display.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute( + _SQL["list_roles_for_user"], + (discord_id,), + ) as cur: + rows = await cur.fetchall() + return [r[0] for r in rows] + + async def has_role(self, discord_id: str, role: str) -> bool: + """Cheap (indexed) existence check — used on the hot path of the editor + auth dep, so kept as a single-row SELECT rather than reusing list_roles.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute( + _SQL["check_has_role"], + (discord_id, role), + ) as cur: + return await cur.fetchone() is not None + + async def create_role_request( + self, + discord_id: str, + role: str, + user_note: str | None, + ) -> int: + """Submit a pending role request. Raises ``sqlite3.IntegrityError`` if the + user already has a pending request for this role (the partial unique index + on (discord_id, role) WHERE status='pending' enforces it). Returns the new + request's id.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["create_role_request"], + (discord_id, role, user_note), + ) + await db.commit() + return int(cur.lastrowid or 0) + + async def list_role_requests( + self, + *, + status: str | None = None, + discord_id: str | None = None, + ) -> list[dict]: + """List role requests, optionally filtered. Pending sorted oldest-first + (queue order); everything else newest-first (audit-trail browsing). + + Joins the user table so callers can render the requester without a + second lookup — admin queue needs the name; user-list of own history + technically doesn't but the cost is zero.""" + where: list[str] = [] + params: list = [] + if status is not None: + where.append("rr.status = ?") + params.append(status) + if discord_id is not None: + where.append("rr.discord_id = ?") + params.append(discord_id) + where_sql = build_where(where) + # Pending: oldest first (FIFO queue). Anything resolved: newest first. + order_sql = ( + "ORDER BY rr.requested_at ASC, rr.id ASC" + if status == "pending" + else "ORDER BY rr.requested_at DESC, rr.id DESC" ) - await db.commit() - return cur.rowcount > 0 - - -async def user_has_capability_via_db( - discord_id: str, - capability: str, - path: Path = DB_PATH, -) -> bool: - """True iff any DB-granted role for this user maps to ``capability``. - - Single JOIN'd EXISTS query — indexed on both join keys. Doesn't consider - admin (synthetic) or officer (dynamic) — those live in the auth dep on - top of this primitive.""" - async with aiosqlite.connect(path) as db: - async with db.execute( - _SQL["check_user_has_capability"], - (discord_id, capability), - ) as cur: - return await cur.fetchone() is not None - - -async def role_has_capability( - role: str, - capability: str, - path: Path = DB_PATH, -) -> bool: - """True iff ``(role, capability)`` is in role_permissions. - - Used by the auth dep to decide whether to bother running the dynamic - officer check at all — if officers don't have the capability, no point.""" - async with aiosqlite.connect(path) as db: - async with db.execute( - _SQL["check_role_has_capability"], - (role, capability), - ) as cur: - return await cur.fetchone() is not None - - -async def list_role_assignments(path: Path = DB_PATH) -> dict[str, list[str]]: - """Return ``{discord_id: [role, …]}`` for every user with at least one role. - - Used by the admin user-list endpoint to join roles in without N+1 queries - against ``list_roles_for_user``.""" - async with aiosqlite.connect(path) as db: - async with db.execute(_SQL["list_all_role_assignments"]) as cur: - rows = await cur.fetchall() - out: dict[str, list[str]] = {} - for discord_id, role in rows: - out.setdefault(discord_id, []).append(role) - return out + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["list_role_requests"].format(where_sql=where_sql, order_sql=order_sql), + params, + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + async def get_role_request(self, request_id: int) -> dict | None: + """Single-row fetch, same shape as list_role_requests entries. Used by the + admin approve/reject endpoints for the 404 check + the role/discord_id + payload that the grant flow needs.""" + async with aiosqlite.connect(self.path) as db: + db.row_factory = aiosqlite.Row + async with db.execute( + _SQL["get_role_request"], + (request_id,), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def review_role_request( + self, + request_id: int, + status: str, + reviewed_by: str, + admin_note: str | None = None, + ) -> dict | None: + """Approve or reject a pending request. Returns the updated row, or None + if no pending request with that id exists (already resolved, or unknown).""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["review_role_request"], + (status, reviewed_by, admin_note, request_id), + ) + await db.commit() + if cur.rowcount == 0: + return None + return await self.get_role_request(request_id) + + async def review_and_grant_role( + self, + request_id: int, + status: str, + admin_id: str, + note: str | None = None, + ) -> dict | None: + """Atomically mark a role request approved + insert the user_roles row. + + Single transaction so a process crash between the two writes can't leave + the queue with a phantom-approved row whose grant never landed. Returns + the reviewed request dict (or None if not found / already reviewed). + + Idempotency: if the user already holds the role (e.g. admin granted it + directly between submit + approve), the INSERT OR IGNORE is a no-op and + the request still transitions to approved. + """ + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["review_role_request"], + (status, admin_id, note, request_id), + ) + if cur.rowcount == 0: + return None + # Fetch the request row so we can grant the role + async with db.execute( + _SQL["select_role_request_grant_info"], + (request_id,), + ) as sel: + row = await sel.fetchone() + if row is not None: + discord_id, role = row + await db.execute( + _SQL["grant_role"], + (discord_id, role, admin_id), + ) + await db.commit() + return await self.get_role_request(request_id) + + async def withdraw_role_request( + self, + request_id: int, + discord_id: str, + ) -> bool: + """User-initiated cancellation. Scoped to the requester so one user can't + withdraw another's request. Only pending requests can be withdrawn — + historical rows stay immutable. Returns True if a row transitioned.""" + async with aiosqlite.connect(self.path) as db: + cur = await db.execute( + _SQL["withdraw_role_request"], + (request_id, discord_id), + ) + await db.commit() + return cur.rowcount > 0 + + async def user_has_capability_via_db( + self, + discord_id: str, + capability: str, + ) -> bool: + """True iff any DB-granted role for this user maps to ``capability``. + + Single JOIN'd EXISTS query — indexed on both join keys. Doesn't consider + admin (synthetic) or officer (dynamic) — those live in the auth dep on + top of this primitive.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute( + _SQL["check_user_has_capability"], + (discord_id, capability), + ) as cur: + return await cur.fetchone() is not None + + async def role_has_capability( + self, + role: str, + capability: str, + ) -> bool: + """True iff ``(role, capability)`` is in role_permissions. + + Used by the auth dep to decide whether to bother running the dynamic + officer check at all — if officers don't have the capability, no point.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute( + _SQL["check_role_has_capability"], + (role, capability), + ) as cur: + return await cur.fetchone() is not None + + async def list_role_assignments(self) -> dict[str, list[str]]: + """Return ``{discord_id: [role, …]}`` for every user with at least one role. + + Used by the admin user-list endpoint to join roles in without N+1 queries + against ``list_roles_for_user``.""" + async with aiosqlite.connect(self.path) as db: + async with db.execute(_SQL["list_all_role_assignments"]) as cur: + rows = await cur.fetchall() + out: dict[str, list[str]] = {} + for discord_id, role in rows: + out.setdefault(discord_id, []).append(role) + return out + + +# The shared default instance — every runtime consumer goes through this. +store = UsersStore() diff --git a/backend/server/raid_live.py b/backend/server/raid_live.py index 0e2808ad..73f69fd3 100644 --- a/backend/server/raid_live.py +++ b/backend/server/raid_live.py @@ -182,9 +182,9 @@ async def refresh() -> None: global _live_by_world if not is_configured(): return - from backend.server.db.raid_schedule import list_all_teams_with_twitch # local: avoid import cycle + from backend.server.db.raid_schedule import store as _rs_db # local: avoid import cycle - teams = await list_all_teams_with_twitch() + teams = await _rs_db.list_all_teams_with_twitch() candidates = [t for t in teams if t.get("twitch_login") and team_scheduled_now(t)] if not candidates: _live_by_world = {} diff --git a/backend/server/server_context.py b/backend/server/server_context.py index 445e689f..d41851fd 100644 --- a/backend/server/server_context.py +++ b/backend/server/server_context.py @@ -55,7 +55,7 @@ def _to_server(row: dict) -> Server: def load_registry() -> None: """(Re)load the servers registry from the DB. Call at startup + after edits.""" - rows = db.list_servers_sync(db.DB_PATH) + rows = db.list_servers_sync() _by_subdomain.clear() _by_world.clear() for row in rows: diff --git a/tests/conftest.py b/tests/conftest.py index ee94066f..2f0ffb10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -114,6 +114,8 @@ def pytest_configure(config: pytest.Config) -> None: # noqa: ARG001 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") + for _store in users_db.ALL_STORES: + _store.path = users_db.DB_PATH census_store.DB_PATH = resolve_db_path("DB_CENSUS_PATH", "census", "census.db") census_store.store.path = census_store.DB_PATH # eq2db catalogue modules: re-point both the module constant AND the diff --git a/tests/server/test_api_tokens.py b/tests/server/test_api_tokens.py index 377cfd00..bd5786d0 100644 --- a/tests/server/test_api_tokens.py +++ b/tests/server/test_api_tokens.py @@ -4,6 +4,8 @@ import pytest +from backend.server.db.tokens import TokensStore + @pytest.fixture def tmp_users_db_for_coalesce(tmp_path): @@ -31,16 +33,16 @@ async def test_lookup_api_token_coalesces_writes(tmp_users_db_for_coalesce) -> N db_path = tmp_users_db_for_coalesce # Mint a token for the user. - raw, _row = await users_db.mint_api_token("user-coalesce", "Test Token", path=db_path) + raw, _row = await TokensStore(db_path).mint_api_token("user-coalesce", "Test Token") # First lookup — should write last_used_at. - row1 = await users_db.lookup_api_token(raw, path=db_path) + row1 = await TokensStore(db_path).lookup_api_token(raw) assert row1 is not None last_after_first = row1["last_used_at"] assert last_after_first is not None # Second lookup immediately after — within 60 s, so should NOT write. - row2 = await users_db.lookup_api_token(raw, path=db_path) + row2 = await TokensStore(db_path).lookup_api_token(raw) assert row2 is not None # last_used_at returned by the second call is whatever's in the row; # since we didn't update, it equals what the first call wrote. diff --git a/tests/server/test_auth_tokens.py b/tests/server/test_auth_tokens.py index 1388313f..03dd9125 100644 --- a/tests/server/test_auth_tokens.py +++ b/tests/server/test_auth_tokens.py @@ -157,8 +157,10 @@ def tmp_users_db(tmp_path, monkeypatch): db_path = tmp_path / "users.db" from backend.server import db as users_db - # init_db creates schema; we point at the temp path. + # init_db creates schema; point every domain store at the temp path. users_db.init_db(db_path) + for _st in users_db.ALL_STORES: + monkeypatch.setattr(_st, "path", db_path) # Seed a user row so the FK on api_tokens.user_id is satisfied. import sqlite3 as _sqlite3 @@ -175,23 +177,23 @@ def tmp_users_db(tmp_path, monkeypatch): async def test_db_mint_then_lookup_then_revoke(tmp_users_db): from backend.server import db as users_db - raw, row = await users_db.mint_api_token("user-123", "First", path=tmp_users_db) + raw, row = await users_db.mint_api_token("user-123", "First") assert raw.startswith("eq2c_") assert row["name"] == "First" assert row["revoked_at"] is None # Lookup with raw → returns user info - found = await users_db.lookup_api_token(raw, path=tmp_users_db) + found = await users_db.lookup_api_token(raw) assert found is not None assert found["user_id"] == "user-123" assert found["discord_name"] == "Alice" # Revoke - ok = await users_db.revoke_api_token("user-123", row["id"], path=tmp_users_db) + ok = await users_db.revoke_api_token("user-123", row["id"]) assert ok is True # Revoked token no longer resolves - after = await users_db.lookup_api_token(raw, path=tmp_users_db) + after = await users_db.lookup_api_token(raw) assert after is None @@ -199,9 +201,9 @@ async def test_db_mint_then_lookup_then_revoke(tmp_users_db): async def test_db_lookup_rejects_garbage(tmp_users_db): from backend.server import db as users_db - assert await users_db.lookup_api_token("", path=tmp_users_db) is None - assert await users_db.lookup_api_token("not-our-prefix-foo", path=tmp_users_db) is None - assert await users_db.lookup_api_token("eq2c_nonexistent", path=tmp_users_db) is None + assert await users_db.lookup_api_token("") is None + assert await users_db.lookup_api_token("not-our-prefix-foo") is None + assert await users_db.lookup_api_token("eq2c_nonexistent") is None @pytest.mark.asyncio @@ -218,12 +220,12 @@ async def test_db_revoke_scoped_to_user(tmp_users_db): ) conn.commit() - raw_alice, row_alice = await users_db.mint_api_token("user-123", "Alice's", path=tmp_users_db) + raw_alice, row_alice = await users_db.mint_api_token("user-123", "Alice's") # Bob tries to revoke Alice's token - ok = await users_db.revoke_api_token("user-456", row_alice["id"], path=tmp_users_db) + ok = await users_db.revoke_api_token("user-456", row_alice["id"]) assert ok is False # Token still works for Alice - assert await users_db.lookup_api_token(raw_alice, path=tmp_users_db) is not None + assert await users_db.lookup_api_token(raw_alice) is not None # --------------------------------------------------------------------------- diff --git a/tests/server/test_claim_cache_per_world.py b/tests/server/test_claim_cache_per_world.py index ec6a4694..46ccc5f3 100644 --- a/tests/server/test_claim_cache_per_world.py +++ b/tests/server/test_claim_cache_per_world.py @@ -27,8 +27,8 @@ def _both_servers_registered(): the cross-world tests collapse. Re-register both worlds + reload before every test; restoring the original registry isn't strictly necessary because conftest tears the test DB down between sessions.""" - upsert_server_settings_sync("Varsoon", max_level=50, current_xpac=None, launch_dt=None, path=db.DB_PATH) - upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac=None, launch_dt=None, path=db.DB_PATH) + upsert_server_settings_sync("Varsoon", max_level=50, current_xpac=None, launch_dt=None) + upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac=None, launch_dt=None) server_context.load_registry() yield @@ -39,7 +39,6 @@ async def _seed_user(discord_id: str) -> None: discord_name=discord_id, discord_username=discord_id, avatar=None, - path=db.DB_PATH, ) @@ -92,10 +91,10 @@ async def test_varsoon_subdomain_returns_only_varsoon_claims(app, monkeypatch): uid = "csu-varsoon-only" await _seed_user(uid) - v = await submit_claim(uid, "VarcharA", world="Varsoon", path=db.DB_PATH) - await review_claim(v["id"], "approved", "admin", path=db.DB_PATH) - w = await submit_claim(uid, "WuocharA", world="Wuoshi", path=db.DB_PATH) - await review_claim(w["id"], "approved", "admin", path=db.DB_PATH) + v = await submit_claim(uid, "VarcharA", world="Varsoon") + await review_claim(v["id"], "approved", "admin") + w = await submit_claim(uid, "WuocharA", world="Wuoshi") + await review_claim(w["id"], "approved", "admin") monkeypatch.setattr(claim_mod, "_require_user", _fake_user_for(uid)) monkeypatch.setattr("backend.server.core.census_lifecycle._clients", {}) @@ -125,10 +124,10 @@ async def test_wuoshi_subdomain_after_varsoon_hit_serves_own_data(app, monkeypat uid = "csu-crossover" await _seed_user(uid) - v = await submit_claim(uid, "VarcharB", world="Varsoon", path=db.DB_PATH) - await review_claim(v["id"], "approved", "admin", path=db.DB_PATH) - w = await submit_claim(uid, "WuocharB", world="Wuoshi", path=db.DB_PATH) - await review_claim(w["id"], "approved", "admin", path=db.DB_PATH) + v = await submit_claim(uid, "VarcharB", world="Varsoon") + await review_claim(v["id"], "approved", "admin") + w = await submit_claim(uid, "WuocharB", world="Wuoshi") + await review_claim(w["id"], "approved", "admin") monkeypatch.setattr(claim_mod, "_require_user", _fake_user_for(uid)) monkeypatch.setattr("backend.server.core.census_lifecycle._clients", {}) diff --git a/tests/server/test_claim_per_server.py b/tests/server/test_claim_per_server.py index 84fe2dbb..cb402617 100644 --- a/tests/server/test_claim_per_server.py +++ b/tests/server/test_claim_per_server.py @@ -37,7 +37,6 @@ async def _seed_user(discord_id: str) -> None: discord_name=discord_id, discord_username=discord_id, avatar=None, - path=_PATH, ) @@ -67,11 +66,11 @@ async def test_approved_claim_invisible_to_different_world(): await _seed_user(uid) # Submit + manually approve on Varsoon - claim = await submit_claim(uid, "SomeVarsoonChar", world="Varsoon", path=_PATH) - await review_claim(claim["id"], "approved", "admin-1", path=_PATH) + claim = await submit_claim(uid, "SomeVarsoonChar", world="Varsoon") + await review_claim(claim["id"], "approved", "admin-1") - varsoon_data = await get_active_claims(uid, world="Varsoon", path=_PATH) - wuoshi_data = await get_active_claims(uid, world="Wuoshi", path=_PATH) + varsoon_data = await get_active_claims(uid, world="Varsoon") + wuoshi_data = await get_active_claims(uid, world="Wuoshi") assert len(varsoon_data["approved"]) == 1 assert varsoon_data["approved"][0]["character_name"] == "SomeVarsoonChar" @@ -85,10 +84,10 @@ async def test_pending_claim_invisible_to_different_world(): uid = "cross-world-user-2" await _seed_user(uid) - await submit_claim(uid, "WuoshiPendingChar", world="Wuoshi", path=_PATH) + await submit_claim(uid, "WuoshiPendingChar", world="Wuoshi") - varsoon_data = await get_active_claims(uid, world="Varsoon", path=_PATH) - wuoshi_data = await get_active_claims(uid, world="Wuoshi", path=_PATH) + varsoon_data = await get_active_claims(uid, world="Varsoon") + wuoshi_data = await get_active_claims(uid, world="Wuoshi") assert varsoon_data["pending"] is None assert wuoshi_data["pending"] is not None @@ -111,26 +110,26 @@ async def test_primary_is_independent_per_world(): await _seed_user(uid) # Approve two chars on different worlds - claim_v = await submit_claim(uid, "VarsoonPrimary", world="Varsoon", path=_PATH) - claim_v_row = await review_claim(claim_v["id"], "approved", "admin-1", path=_PATH) + claim_v = await submit_claim(uid, "VarsoonPrimary", world="Varsoon") + claim_v_row = await review_claim(claim_v["id"], "approved", "admin-1") assert claim_v_row is not None - claim_w = await submit_claim(uid, "WuoshiPrimary", world="Wuoshi", path=_PATH) - claim_w_row = await review_claim(claim_w["id"], "approved", "admin-1", path=_PATH) + claim_w = await submit_claim(uid, "WuoshiPrimary", world="Wuoshi") + claim_w_row = await review_claim(claim_w["id"], "approved", "admin-1") assert claim_w_row is not None # auto-primary should already be set (first approval per world) — verify - v_data = await get_active_claims(uid, world="Varsoon", path=_PATH) - w_data = await get_active_claims(uid, world="Wuoshi", path=_PATH) + v_data = await get_active_claims(uid, world="Varsoon") + w_data = await get_active_claims(uid, world="Wuoshi") assert v_data["approved"][0]["is_primary"] == 1 assert w_data["approved"][0]["is_primary"] == 1 # Now explicitly set the same claim again (idempotent) and verify cross-world isolation - ok = await set_primary(uid, claim_v["id"], world="Varsoon", path=_PATH) + ok = await set_primary(uid, claim_v["id"], world="Varsoon") assert ok - v_data2 = await get_active_claims(uid, world="Varsoon", path=_PATH) - w_data2 = await get_active_claims(uid, world="Wuoshi", path=_PATH) + v_data2 = await get_active_claims(uid, world="Varsoon") + w_data2 = await get_active_claims(uid, world="Wuoshi") assert v_data2["approved"][0]["is_primary"] == 1, "Varsoon primary should still be set" assert w_data2["approved"][0]["is_primary"] == 1, "Wuoshi primary must NOT have been cleared" @@ -146,26 +145,26 @@ async def test_setting_varsoon_primary_does_not_affect_wuoshi_primary(): await _seed_user(uid) # Approve two Varsoon chars - c_v1 = await submit_claim(uid, "VarsoonA", world="Varsoon", path=_PATH) - await review_claim(c_v1["id"], "approved", "admin-1", path=_PATH) + c_v1 = await submit_claim(uid, "VarsoonA", world="Varsoon") + await review_claim(c_v1["id"], "approved", "admin-1") - c_v2 = await submit_claim(uid, "VarsoonB", world="Varsoon", path=_PATH) - await review_claim(c_v2["id"], "approved", "admin-1", path=_PATH) + c_v2 = await submit_claim(uid, "VarsoonB", world="Varsoon") + await review_claim(c_v2["id"], "approved", "admin-1") # Approve one Wuoshi char - c_w = await submit_claim(uid, "WuoshiC", world="Wuoshi", path=_PATH) - await review_claim(c_w["id"], "approved", "admin-1", path=_PATH) + c_w = await submit_claim(uid, "WuoshiC", world="Wuoshi") + await review_claim(c_w["id"], "approved", "admin-1") # Confirm Wuoshi primary is set - w_before = await get_active_claims(uid, world="Wuoshi", path=_PATH) + w_before = await get_active_claims(uid, world="Wuoshi") assert w_before["approved"][0]["is_primary"] == 1 # Switch Varsoon primary to VarsoonB - ok = await set_primary(uid, c_v2["id"], world="Varsoon", path=_PATH) + ok = await set_primary(uid, c_v2["id"], world="Varsoon") assert ok # Wuoshi primary must be unchanged - w_after = await get_active_claims(uid, world="Wuoshi", path=_PATH) + w_after = await get_active_claims(uid, world="Wuoshi") wuoshi_primaries = [c for c in w_after["approved"] if c["is_primary"] == 1] assert len(wuoshi_primaries) == 1 assert wuoshi_primaries[0]["character_name"] == "WuoshiC" @@ -182,7 +181,7 @@ async def test_submit_claim_records_world(): uid = "world-record-user-1" await _seed_user(uid) - claim = await submit_claim(uid, "RecordChar", world="Wuoshi", path=_PATH) + claim = await submit_claim(uid, "RecordChar", world="Wuoshi") assert claim["world"] == "Wuoshi" @@ -197,7 +196,7 @@ async def test_submit_claim_default_world_is_varsoon(): uid = "world-record-user-2" await _seed_user(uid) - claim = await submit_claim(uid, "VarsoonDefaultChar", world="Varsoon", path=_PATH) + claim = await submit_claim(uid, "VarsoonDefaultChar", world="Varsoon") assert claim["world"] == "Varsoon" @@ -217,33 +216,33 @@ async def test_withdraw_primary_promotes_within_same_world(): await _seed_user(uid) # Two Varsoon chars — first one auto-gets primary - c_v1 = await submit_claim(uid, "VarsoonFirst", world="Varsoon", path=_PATH) - await review_claim(c_v1["id"], "approved", "admin-1", path=_PATH) + c_v1 = await submit_claim(uid, "VarsoonFirst", world="Varsoon") + await review_claim(c_v1["id"], "approved", "admin-1") - c_v2 = await submit_claim(uid, "VarsoonSecond", world="Varsoon", path=_PATH) - await review_claim(c_v2["id"], "approved", "admin-1", path=_PATH) + c_v2 = await submit_claim(uid, "VarsoonSecond", world="Varsoon") + await review_claim(c_v2["id"], "approved", "admin-1") # One Wuoshi char - c_w = await submit_claim(uid, "WuoshiFirst", world="Wuoshi", path=_PATH) - await review_claim(c_w["id"], "approved", "admin-1", path=_PATH) + c_w = await submit_claim(uid, "WuoshiFirst", world="Wuoshi") + await review_claim(c_w["id"], "approved", "admin-1") # Confirm initial state - v_before = await get_active_claims(uid, world="Varsoon", path=_PATH) + v_before = await get_active_claims(uid, world="Varsoon") assert v_before["approved"][0]["is_primary"] == 1 # VarsoonFirst is primary # Withdraw the Varsoon primary - changed = await withdraw_claim(c_v1["id"], uid, world="Varsoon", path=_PATH) + changed = await withdraw_claim(c_v1["id"], uid, world="Varsoon") assert changed # VarsoonSecond should now be primary on Varsoon - v_after = await get_active_claims(uid, world="Varsoon", path=_PATH) + v_after = await get_active_claims(uid, world="Varsoon") remaining = [c for c in v_after["approved"] if c["status"] == "approved"] assert len(remaining) == 1 assert remaining[0]["character_name"] == "VarsoonSecond" assert remaining[0]["is_primary"] == 1 # Wuoshi primary must be unchanged - w_after = await get_active_claims(uid, world="Wuoshi", path=_PATH) + w_after = await get_active_claims(uid, world="Wuoshi") assert w_after["approved"][0]["is_primary"] == 1 assert w_after["approved"][0]["character_name"] == "WuoshiFirst" @@ -263,12 +262,12 @@ async def test_list_claims_with_world_filter(): await _seed_user(uid_v) await _seed_user(uid_w) - await submit_claim(uid_v, "ListVarsoonChar", world="Varsoon", path=_PATH) - await submit_claim(uid_w, "ListWuoshiChar", world="Wuoshi", path=_PATH) + await submit_claim(uid_v, "ListVarsoonChar", world="Varsoon") + await submit_claim(uid_w, "ListWuoshiChar", world="Wuoshi") - varsoon_pending = await list_claims(status="pending", world="Varsoon", path=_PATH) - wuoshi_pending = await list_claims(status="pending", world="Wuoshi", path=_PATH) - all_pending = await list_claims(status="pending", world=None, path=_PATH) + varsoon_pending = await list_claims(status="pending", world="Varsoon") + wuoshi_pending = await list_claims(status="pending", world="Wuoshi") + all_pending = await list_claims(status="pending", world=None) varsoon_chars = {c["character_name"] for c in varsoon_pending} wuoshi_chars = {c["character_name"] for c in wuoshi_pending} @@ -299,11 +298,11 @@ async def test_same_character_name_claimable_on_different_servers(): await _seed_user("u2") # u1 claims "Sihtric" on Varsoon - await submit_claim("u1", "Sihtric", world="Varsoon", path=_PATH) + await submit_claim("u1", "Sihtric", world="Varsoon") # u2 can claim "Sihtric" on Wuoshi (different character / server) — must NOT raise - await submit_claim("u2", "Sihtric", world="Wuoshi", path=_PATH) + await submit_claim("u2", "Sihtric", world="Wuoshi") # but u2 claiming "Sihtric" on Varsoon (same server as u1's) must be rejected with pytest.raises(ValueError): - await submit_claim("u2", "Sihtric", world="Varsoon", path=_PATH) + await submit_claim("u2", "Sihtric", world="Varsoon") diff --git a/tests/server/test_db.py b/tests/server/test_db.py index fefe8bc3..8603eab6 100644 --- a/tests/server/test_db.py +++ b/tests/server/test_db.py @@ -16,7 +16,6 @@ async def _seed(discord_id: str, discord_name: str) -> None: discord_name=discord_name, discord_username=discord_id, avatar=None, - path=_PATH, ) @@ -27,28 +26,28 @@ async def _seed(discord_id: str, discord_name: str) -> None: @pytest.mark.asyncio async def test_empty_list_returns_empty_dict(): - result = await get_display_names_for_discord_ids([], path=_PATH) + result = await get_display_names_for_discord_ids([]) assert result == {} @pytest.mark.asyncio async def test_missing_id_absent_from_result(): """An id that doesn't exist in users returns no entry.""" - result = await get_display_names_for_discord_ids(["999999999999999999"], path=_PATH) + result = await get_display_names_for_discord_ids(["999999999999999999"]) assert result == {} @pytest.mark.asyncio async def test_present_id_returns_display_name(): await _seed("111111111111111111", "Sihtric Ironhide") - result = await get_display_names_for_discord_ids(["111111111111111111"], path=_PATH) + result = await get_display_names_for_discord_ids(["111111111111111111"]) assert result == {"111111111111111111": "Sihtric Ironhide"} @pytest.mark.asyncio async def test_mixed_present_and_missing(): await _seed("222222222222222222", "Wuoshi Raider") - result = await get_display_names_for_discord_ids(["222222222222222222", "000000000000000000"], path=_PATH) + result = await get_display_names_for_discord_ids(["222222222222222222", "000000000000000000"]) assert "222222222222222222" in result assert result["222222222222222222"] == "Wuoshi Raider" assert "000000000000000000" not in result @@ -58,5 +57,5 @@ async def test_mixed_present_and_missing(): async def test_multiple_present_ids(): await _seed("333333333333333333", "Alice") await _seed("444444444444444444", "Bob") - result = await get_display_names_for_discord_ids(["333333333333333333", "444444444444444444"], path=_PATH) + result = await get_display_names_for_discord_ids(["333333333333333333", "444444444444444444"]) assert result == {"333333333333333333": "Alice", "444444444444444444": "Bob"} diff --git a/tests/server/test_favorites.py b/tests/server/test_favorites.py index a71aa56c..5ef3a79c 100644 --- a/tests/server/test_favorites.py +++ b/tests/server/test_favorites.py @@ -18,9 +18,8 @@ from httpx import ASGITransport, AsyncClient from backend.server.cache import favorite_count_cache -from backend.server.db import favorites as fav -from backend.server.db import init_db -from backend.server.db.claims import review_claim, submit_claim +from backend.server.db import init_db, review_claim, submit_claim +from backend.server.db.favorites import store as fav _TEST_SECRET = "pytest-session-secret-not-real-0123456789" @@ -46,64 +45,73 @@ def users_db(tmp_path) -> Path: return db +@pytest.fixture(autouse=True) +def _stores_at_tmp(users_db: Path, monkeypatch: pytest.MonkeyPatch): + """Point every users.db domain store at this test's temp DB.""" + from backend.server import db as _db_pkg + + for st in _db_pkg.ALL_STORES: + monkeypatch.setattr(st, "path", users_db) + + async def test_add_remove_round_trip(users_db): - assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) is True - assert await fav.count_favorites_for_character("Menludiir", "Varsoon", path=users_db) == 1 - assert await fav.is_favorited("disc1", "Menludiir", "Varsoon", path=users_db) is True - assert await fav.remove_favorite("disc1", "Menludiir", "Varsoon", path=users_db) is True - assert await fav.count_favorites_for_character("Menludiir", "Varsoon", path=users_db) == 0 - assert await fav.is_favorited("disc1", "Menludiir", "Varsoon", path=users_db) is False + assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) is True + assert await fav.count_favorites_for_character("Menludiir", "Varsoon") == 1 + assert await fav.is_favorited("disc1", "Menludiir", "Varsoon") is True + assert await fav.remove_favorite("disc1", "Menludiir", "Varsoon") is True + assert await fav.count_favorites_for_character("Menludiir", "Varsoon") == 0 + assert await fav.is_favorited("disc1", "Menludiir", "Varsoon") is False async def test_add_is_idempotent(users_db): - assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) is True - assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) is False - assert await fav.count_favorites_for_character("Menludiir", "Varsoon", path=users_db) == 1 + assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) is True + assert await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) is False + assert await fav.count_favorites_for_character("Menludiir", "Varsoon") == 1 async def test_add_enforces_cap_atomically(users_db): """The cap guard lives inside the INSERT itself, so it can't be raced.""" - assert await fav.add_favorite("disc1", "Alpha", "Varsoon", cap=2, path=users_db) is True - assert await fav.add_favorite("disc1", "Bravo", "Varsoon", cap=2, path=users_db) is True - assert await fav.add_favorite("disc1", "Charlie", "Varsoon", cap=2, path=users_db) is False # cap hit - assert await fav.is_favorited("disc1", "Charlie", "Varsoon", path=users_db) is False + assert await fav.add_favorite("disc1", "Alpha", "Varsoon", cap=2) is True + assert await fav.add_favorite("disc1", "Bravo", "Varsoon", cap=2) is True + assert await fav.add_favorite("disc1", "Charlie", "Varsoon", cap=2) is False # cap hit + assert await fav.is_favorited("disc1", "Charlie", "Varsoon") is False # Cap is per-world: the same user can still favourite on another world. - assert await fav.add_favorite("disc1", "Charlie", "Wuoshi", cap=2, path=users_db) is True + assert await fav.add_favorite("disc1", "Charlie", "Wuoshi", cap=2) is True async def test_remove_missing_is_noop(users_db): - assert await fav.remove_favorite("disc1", "Ghost", "Varsoon", path=users_db) is False + assert await fav.remove_favorite("disc1", "Ghost", "Varsoon") is False async def test_count_across_users(users_db): - await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - await fav.add_favorite("disc2", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - assert await fav.count_favorites_for_character("Menludiir", "Varsoon", path=users_db) == 2 - assert await fav.is_favorited("disc3", "Menludiir", "Varsoon", path=users_db) is False + await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) + await fav.add_favorite("disc2", "Menludiir", "Varsoon", cap=_CAP) + assert await fav.count_favorites_for_character("Menludiir", "Varsoon") == 2 + assert await fav.is_favorited("disc3", "Menludiir", "Varsoon") is False async def test_world_scoping(users_db): """The same character name on two worlds is two independent favourites.""" - await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - await fav.add_favorite("disc1", "Menludiir", "Wuoshi", cap=_CAP, path=users_db) - assert await fav.count_favorites_for_character("Menludiir", "Varsoon", path=users_db) == 1 - assert await fav.count_user_favorites("disc1", "Varsoon", path=users_db) == 1 - assert await fav.count_user_favorites("disc1", "Wuoshi", path=users_db) == 1 - varsoon = await fav.list_favorites("disc1", "Varsoon", path=users_db) + await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) + await fav.add_favorite("disc1", "Menludiir", "Wuoshi", cap=_CAP) + assert await fav.count_favorites_for_character("Menludiir", "Varsoon") == 1 + assert await fav.count_user_favorites("disc1", "Varsoon") == 1 + assert await fav.count_user_favorites("disc1", "Wuoshi") == 1 + varsoon = await fav.list_favorites("disc1", "Varsoon") assert [r["character_name"] for r in varsoon] == ["Menludiir"] async def test_list_newest_first(users_db): import aiosqlite - await fav.add_favorite("disc1", "Alpha", "Varsoon", cap=_CAP, path=users_db) - await fav.add_favorite("disc1", "Bravo", "Varsoon", cap=_CAP, path=users_db) + await fav.add_favorite("disc1", "Alpha", "Varsoon", cap=_CAP) + await fav.add_favorite("disc1", "Bravo", "Varsoon", cap=_CAP) # Force distinct created_at so ordering is deterministic. async with aiosqlite.connect(users_db) as db: await db.execute("UPDATE character_favorites SET created_at = 100 WHERE character_name = 'Alpha'") await db.execute("UPDATE character_favorites SET created_at = 200 WHERE character_name = 'Bravo'") await db.commit() - rows = await fav.list_favorites("disc1", "Varsoon", path=users_db) + rows = await fav.list_favorites("disc1", "Varsoon") assert [r["character_name"] for r in rows] == ["Bravo", "Alpha"] @@ -111,19 +119,19 @@ async def test_claim_approval_removes_own_favorite(users_db): """When a claim is approved the new owner's favourite of that character is removed (case-insensitively) in the same transaction — you can't favourite your own character. Other users' favourites are untouched.""" - await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - await fav.add_favorite("disc2", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - claim = await submit_claim("disc1", "menludiir", "Varsoon", path=users_db) # different casing - await review_claim(claim["id"], "approved", "admin-1", path=users_db) - assert await fav.is_favorited("disc1", "Menludiir", "Varsoon", path=users_db) is False # removed - assert await fav.is_favorited("disc2", "Menludiir", "Varsoon", path=users_db) is True # untouched + await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) + await fav.add_favorite("disc2", "Menludiir", "Varsoon", cap=_CAP) + claim = await submit_claim("disc1", "menludiir", "Varsoon") # different casing + await review_claim(claim["id"], "approved", "admin-1") + assert await fav.is_favorited("disc1", "Menludiir", "Varsoon") is False # removed + assert await fav.is_favorited("disc2", "Menludiir", "Varsoon") is True # untouched async def test_claim_rejection_keeps_favorite(users_db): - await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP, path=users_db) - claim = await submit_claim("disc1", "Menludiir", "Varsoon", path=users_db) - await review_claim(claim["id"], "rejected", "admin-1", path=users_db) - assert await fav.is_favorited("disc1", "Menludiir", "Varsoon", path=users_db) is True + await fav.add_favorite("disc1", "Menludiir", "Varsoon", cap=_CAP) + claim = await submit_claim("disc1", "Menludiir", "Varsoon") + await review_claim(claim["id"], "rejected", "admin-1") + assert await fav.is_favorited("disc1", "Menludiir", "Varsoon") is True # --------------------------------------------------------------------------- diff --git a/tests/server/test_item_watch_db.py b/tests/server/test_item_watch_db.py index ec9f6c8e..ba6984ad 100644 --- a/tests/server/test_item_watch_db.py +++ b/tests/server/test_item_watch_db.py @@ -37,7 +37,6 @@ async def _seed_user(discord_id: str) -> None: discord_name=discord_id, discord_username=discord_id, avatar=None, - path=_PATH, ) @@ -99,7 +98,6 @@ async def test_same_watch_insertable_on_two_worlds(): added_by=uid, added_by_name="Sihtric", world="Varsoon", - path=_PATH, ) row_w = await add_item_watch( guild_name="TestGuild", @@ -109,7 +107,6 @@ async def test_same_watch_insertable_on_two_worlds(): added_by=uid, added_by_name="Sihtric", world="Wuoshi", - path=_PATH, ) assert row_v["world"] == "Varsoon" @@ -131,7 +128,6 @@ async def test_listing_watches_for_varsoon_excludes_wuoshi(): added_by=uid, added_by_name="Menludiir", world="Varsoon", - path=_PATH, ) await add_item_watch( guild_name="TestGuild2", @@ -141,11 +137,10 @@ async def test_listing_watches_for_varsoon_excludes_wuoshi(): added_by=uid, added_by_name="Menludiir", world="Wuoshi", - path=_PATH, ) - varsoon_watches = await list_item_watches("TestGuild2", world="Varsoon", path=_PATH) - wuoshi_watches = await list_item_watches("TestGuild2", world="Wuoshi", path=_PATH) + varsoon_watches = await list_item_watches("TestGuild2", world="Varsoon") + wuoshi_watches = await list_item_watches("TestGuild2", world="Wuoshi") assert all(w["world"] == "Varsoon" for w in varsoon_watches), ( "list_item_watches for Varsoon returned a non-Varsoon row" @@ -177,7 +172,6 @@ async def test_duplicate_on_same_world_raises(): added_by=uid, added_by_name="Dupchar", world="Varsoon", - path=_PATH, ) with pytest.raises(ValueError, match="already being watched"): await add_item_watch( @@ -188,7 +182,6 @@ async def test_duplicate_on_same_world_raises(): added_by=uid, added_by_name="Dupchar", world="Varsoon", - path=_PATH, ) @@ -206,7 +199,6 @@ async def test_same_watch_on_different_world_does_not_raise(): added_by=uid, added_by_name="Dupchar2", world="Varsoon", - path=_PATH, ) # Must not raise: row = await add_item_watch( @@ -217,7 +209,6 @@ async def test_same_watch_on_different_world_does_not_raise(): added_by=uid, added_by_name="Dupchar2", world="Wuoshi", - path=_PATH, ) assert row["world"] == "Wuoshi" @@ -241,7 +232,6 @@ async def test_remove_watch_scoped_to_world(): added_by=uid, added_by_name="Rmchar", world="Varsoon", - path=_PATH, ) row_w = await add_item_watch( guild_name="RmGuild", @@ -251,19 +241,18 @@ async def test_remove_watch_scoped_to_world(): added_by=uid, added_by_name="Rmchar", world="Wuoshi", - path=_PATH, ) # Remove only the Varsoon watch - removed = await remove_item_watch(row_v["id"], "RmGuild", world="Varsoon", path=_PATH) + removed = await remove_item_watch(row_v["id"], "RmGuild", world="Varsoon") assert removed # Wuoshi watch must still exist - wuoshi_watches = await list_item_watches("RmGuild", world="Wuoshi", path=_PATH) + wuoshi_watches = await list_item_watches("RmGuild", world="Wuoshi") assert any(w["id"] == row_w["id"] for w in wuoshi_watches), ( "Wuoshi watch was incorrectly deleted when removing the Varsoon watch" ) # Varsoon watch must be gone - varsoon_watches = await list_item_watches("RmGuild", world="Varsoon", path=_PATH) + varsoon_watches = await list_item_watches("RmGuild", world="Varsoon") assert not any(w["id"] == row_v["id"] for w in varsoon_watches), "Varsoon watch was not deleted" diff --git a/tests/server/test_raid_live.py b/tests/server/test_raid_live.py index 82ce3870..01d159b8 100644 --- a/tests/server/test_raid_live.py +++ b/tests/server/test_raid_live.py @@ -73,7 +73,7 @@ def test_is_configured(monkeypatch): async def test_refresh_no_ops_when_unconfigured(monkeypatch): monkeypatch.delenv("TWITCH_CLIENT_ID", raising=False) monkeypatch.delenv("TWITCH_CLIENT_SECRET", raising=False) - with patch("backend.server.db.raid_schedule.list_all_teams_with_twitch", new=AsyncMock()) as db: + with patch("backend.server.db.raid_schedule.store.list_all_teams_with_twitch", new=AsyncMock()) as db: await rl.refresh() db.assert_not_awaited() assert rl.get_live("Varsoon") == [] @@ -104,7 +104,7 @@ async def test_refresh_populates_cache_for_scheduled_live_teams(monkeypatch): ] with ( patch("backend.server.raid_live.datetime") as dt, - patch("backend.server.db.raid_schedule.list_all_teams_with_twitch", new=AsyncMock(return_value=teams)), + patch("backend.server.db.raid_schedule.store.list_all_teams_with_twitch", new=AsyncMock(return_value=teams)), patch("backend.server.raid_live._get_token", new=AsyncMock(return_value="tok")), patch("backend.server.raid_live._fetch_live", new=AsyncMock(return_value={"foo": {"viewer_count": 10}})) as fl, ): diff --git a/tests/server/test_raid_schedule.py b/tests/server/test_raid_schedule.py index ddf7f07a..d3a46491 100644 --- a/tests/server/test_raid_schedule.py +++ b/tests/server/test_raid_schedule.py @@ -18,7 +18,7 @@ from httpx import ASGITransport, AsyncClient from backend.server.db import init_db -from backend.server.db import raid_schedule as rs +from backend.server.db.raid_schedule import store as rs _TEST_SECRET = "pytest-session-secret-not-real-0123456789" @@ -46,6 +46,15 @@ def users_db(tmp_path) -> Path: return db +@pytest.fixture(autouse=True) +def _stores_at_tmp(users_db: Path, monkeypatch: pytest.MonkeyPatch): + """Point every users.db domain store at this test's temp DB.""" + from backend.server import db as _db_pkg + + for st in _db_pkg.ALL_STORES: + monkeypatch.setattr(st, "path", users_db) + + async def test_replace_then_get_round_trips(users_db): teams = [ { @@ -55,8 +64,8 @@ async def test_replace_then_get_round_trips(users_db): "raids": [{"days": "2,4", "start_min": 1200, "end_min": 1380, "label": "Prog"}], } ] - await rs.replace_schedule("Varsoon", "Exordium", teams, "disc1", path=users_db) - got = await rs.get_schedule("Varsoon", "Exordium", path=users_db) + await rs.replace_schedule("Varsoon", "Exordium", teams, "disc1") + got = await rs.get_schedule("Varsoon", "Exordium") assert len(got) == 1 assert got[0]["name"] == "Team 1" assert got[0]["twitch_login"] == "foochan" @@ -70,7 +79,6 @@ async def test_replace_is_a_full_replace_and_scoped(users_db): "Exordium", [{"name": "T", "primary_tz": "UTC", "twitch_login": None, "raids": []}], "disc1", - path=users_db, ) # A different guild is untouched by Exordium's replace. await rs.replace_schedule( @@ -78,14 +86,13 @@ async def test_replace_is_a_full_replace_and_scoped(users_db): "Other", [{"name": "O", "primary_tz": "UTC", "twitch_login": "otherchan", "raids": []}], "disc1", - path=users_db, ) # Replacing Exordium with an empty schedule clears its rows only. - await rs.replace_schedule("Varsoon", "Exordium", [], "disc1", path=users_db) - assert await rs.get_schedule("Varsoon", "Exordium", path=users_db) == [] - assert len(await rs.get_schedule("Varsoon", "Other", path=users_db)) == 1 + await rs.replace_schedule("Varsoon", "Exordium", [], "disc1") + assert await rs.get_schedule("Varsoon", "Exordium") == [] + assert len(await rs.get_schedule("Varsoon", "Other")) == 1 # list_all_teams_with_twitch spans guilds/worlds, twitch-only. - with_tw = await rs.list_all_teams_with_twitch(path=users_db) + with_tw = await rs.list_all_teams_with_twitch() assert {t["twitch_login"] for t in with_tw} == {"otherchan"} @@ -121,8 +128,8 @@ async def _put(app, body, *, officer=True, admin=False, cookies=True): with ( patch("backend.server.api.raid_schedule._officer_chars", new=AsyncMock(return_value=officer_ret)), patch("backend.server.api.raid_schedule.is_admin", return_value=admin), - patch("backend.server.api.raid_schedule.replace_schedule", new=AsyncMock()) as rep, - patch("backend.server.api.raid_schedule.get_schedule", new=AsyncMock(return_value=[])), + patch("backend.server.api.raid_schedule.raid_schedule_db.replace_schedule", new=AsyncMock()) as rep, + patch("backend.server.api.raid_schedule.raid_schedule_db.get_schedule", new=AsyncMock(return_value=[])), patch("backend.server.api.raid_schedule.audit_log") as audit, ): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: @@ -135,7 +142,7 @@ async def _put(app, body, *, officer=True, admin=False, cookies=True): async def test_get_is_public(app): - with patch("backend.server.api.raid_schedule.get_schedule", new=AsyncMock(return_value=[])): + with patch("backend.server.api.raid_schedule.raid_schedule_db.get_schedule", new=AsyncMock(return_value=[])): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: r = await client.get("/api/guild/Exordium/raid-schedule") assert r.status_code == 200 diff --git a/tests/server/test_raid_strategies.py b/tests/server/test_raid_strategies.py index dcf745ed..914cb0eb 100644 --- a/tests/server/test_raid_strategies.py +++ b/tests/server/test_raid_strategies.py @@ -537,13 +537,11 @@ async def test_get_revisions_enriches_edited_by_name_for_known_user(app): """When the editor's discord_id is in the users table, edited_by_name is populated on every RevisionEntry for that user.""" # Seed the user into the test DB so get_display_names_for_discord_ids finds them. - _path = users_db.DB_PATH await users_db.upsert_user( discord_id="admin-known", discord_name="Knowledgeable Admin", discord_username="kadmin", avatar=None, - path=_path, ) fake_revisions = [ @@ -628,13 +626,11 @@ async def test_get_revisions_edited_by_name_none_for_unknown_id(app): @pytest.mark.asyncio async def test_get_strategy_response_includes_last_edited_by_name(app): """StrategyResponse now carries last_edited_by_name resolved from users.""" - _path = users_db.DB_PATH await users_db.upsert_user( discord_id="editor-555", discord_name="Templar Guildmaster", discord_username="tguild", avatar=None, - path=_path, ) with ( patch("backend.server.api.raid_strategies.zones_db.find_by_name", return_value=_fake_zone()), diff --git a/tests/server/test_rankings.py b/tests/server/test_rankings.py index 07ad9442..a65f7997 100644 --- a/tests/server/test_rankings.py +++ b/tests/server/test_rankings.py @@ -1,6 +1,7 @@ from __future__ import annotations from backend.server.api.rankings import _apply_percentiles, _scope_for +from backend.server.db.servers import ServersStore class TestApplyPercentiles: @@ -582,7 +583,11 @@ async def test_rankings_default_xpac_per_server(app, monkeypatch, tmp_path): p = tmp_path / "users.db" db.init_db(p) monkeypatch.setattr(db, "DB_PATH", p) - db.upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac="Echoes of Faydwer", launch_dt=None, path=p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) + ServersStore(p).upsert_server_settings_sync( + "Wuoshi", max_level=70, current_xpac="Echoes of Faydwer", launch_dt=None + ) server_context.load_registry() raid_tree = [ @@ -623,8 +628,10 @@ async def test_rankings_leaderboard_is_world_scoped(app, monkeypatch, tmp_path): p = tmp_path / "users.db" db.init_db(p) monkeypatch.setattr(db, "DB_PATH", p) - db.upsert_server_settings_sync("Varsoon", max_level=50, current_xpac=None, launch_dt=None, path=p) - db.upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac=None, launch_dt=None, path=p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) + ServersStore(p).upsert_server_settings_sync("Varsoon", max_level=50, current_xpac=None, launch_dt=None) + ServersStore(p).upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac=None, launch_dt=None) server_context.load_registry() # Seed a Varsoon boss kill and a distinct Wuoshi boss kill. diff --git a/tests/server/test_server_context.py b/tests/server/test_server_context.py index 84bafb03..558fe15d 100644 --- a/tests/server/test_server_context.py +++ b/tests/server/test_server_context.py @@ -9,6 +9,8 @@ def _seed(monkeypatch, tmp_path): p = tmp_path / "users.db" db.init_db(p) monkeypatch.setattr(db, "DB_PATH", p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) sc.load_registry() return p @@ -60,10 +62,12 @@ def test_default_server_returns_is_default_server(monkeypatch, tmp_path): p = tmp_path / "users.db" db.init_db(p) + monkeypatch.setattr(db, "DB_PATH", p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) # Set Wuoshi as the default in the DB. - db.set_default_server_sync("Wuoshi", path=p) + db.set_default_server_sync("Wuoshi") - monkeypatch.setattr(db, "DB_PATH", p) sc.load_registry() assert sc.default_server().world == "Wuoshi" diff --git a/tests/server/test_server_route.py b/tests/server/test_server_route.py index 7d31efc8..2a16d2a9 100644 --- a/tests/server/test_server_route.py +++ b/tests/server/test_server_route.py @@ -11,6 +11,8 @@ async def test_server_endpoint_reflects_subdomain(app, monkeypatch, tmp_path): p = tmp_path / "users.db" db.init_db(p) monkeypatch.setattr(db, "DB_PATH", p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) server_context.load_registry() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: r = await c.get("/api/server", headers={"host": "wuoshi.eq2lexicon.com"}) @@ -29,6 +31,8 @@ async def test_server_endpoint_unknown_host_defaults(app, monkeypatch, tmp_path) p = tmp_path / "users.db" db.init_db(p) monkeypatch.setattr(db, "DB_PATH", p) + for _st in db.ALL_STORES: + monkeypatch.setattr(_st, "path", p) server_context.load_registry() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: r = await c.get("/api/server", headers={"host": "localhost"}) diff --git a/tests/server/test_servers_db.py b/tests/server/test_servers_db.py index 3d6462ba..3c22adcb 100644 --- a/tests/server/test_servers_db.py +++ b/tests/server/test_servers_db.py @@ -3,28 +3,29 @@ import sqlite3 from backend.server import db +from backend.server.db.servers import ServersStore def test_servers_seeded_and_lookups(tmp_path): p = tmp_path / "users.db" db.init_db(p) - rows = db.list_servers_sync(p) + rows = ServersStore(p).list_servers_sync() worlds = {r["world"] for r in rows} assert {"Varsoon", "Wuoshi"} <= worlds - v = db.get_server_by_subdomain_sync("varsoon", p) + v = ServersStore(p).get_server_by_subdomain_sync("varsoon") assert v is not None and v["world"] == "Varsoon" - w = db.get_server_by_world_sync("Wuoshi", p) + w = ServersStore(p).get_server_by_world_sync("Wuoshi") assert w is not None and w["subdomain"] == "wuoshi" - assert db.get_server_by_subdomain_sync("nope", p) is None + assert ServersStore(p).get_server_by_subdomain_sync("nope") is None def test_upsert_server_updates_settings(tmp_path): p = tmp_path / "users.db" db.init_db(p) - db.upsert_server_settings_sync( - "Wuoshi", max_level=70, current_xpac="Sentinel's Fate", launch_dt="2026-07-01T18:00:00Z", path=p + ServersStore(p).upsert_server_settings_sync( + "Wuoshi", max_level=70, current_xpac="Sentinel's Fate", launch_dt="2026-07-01T18:00:00Z" ) - w = db.get_server_by_world_sync("Wuoshi", p) + w = ServersStore(p).get_server_by_world_sync("Wuoshi") assert w["max_level"] == 70 assert w["current_xpac"] == "Sentinel's Fate" assert w["launch_dt"] == "2026-07-01T18:00:00Z" @@ -33,10 +34,10 @@ def test_upsert_server_updates_settings(tmp_path): def test_second_init_db_preserves_upserted_settings(tmp_path): p = tmp_path / "users.db" db.init_db(p) - db.upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac="Sentinel's Fate", launch_dt=None, path=p) + ServersStore(p).upsert_server_settings_sync("Wuoshi", max_level=70, current_xpac="Sentinel's Fate", launch_dt=None) # A second init_db (e.g. container restart) must not reset the admin edit. db.init_db(p) - w = db.get_server_by_world_sync("Wuoshi", p) + w = ServersStore(p).get_server_by_world_sync("Wuoshi") assert w["max_level"] == 70 assert w["current_xpac"] == "Sentinel's Fate" @@ -182,33 +183,33 @@ def test_set_default_server_clears_others(tmp_path): db.init_db(p) # After init the default should be Varsoon (EQ2_WORLD default). - rows = db.list_servers_sync(p) + rows = ServersStore(p).list_servers_sync() defaults = [r for r in rows if r["is_default"]] assert len(defaults) == 1 # Set Wuoshi as default — Varsoon must flip to 0. - result = db.set_default_server_sync("Wuoshi", path=p) + result = ServersStore(p).set_default_server_sync("Wuoshi") assert result is True - rows = db.list_servers_sync(p) + rows = ServersStore(p).list_servers_sync() wuoshi = next(r for r in rows if r["world"] == "Wuoshi") varsoon = next(r for r in rows if r["world"] == "Varsoon") assert wuoshi["is_default"] is True assert varsoon["is_default"] is False # Flip back to Varsoon. - result = db.set_default_server_sync("Varsoon", path=p) + result = ServersStore(p).set_default_server_sync("Varsoon") assert result is True - rows = db.list_servers_sync(p) + rows = ServersStore(p).list_servers_sync() wuoshi = next(r for r in rows if r["world"] == "Wuoshi") varsoon = next(r for r in rows if r["world"] == "Varsoon") assert varsoon["is_default"] is True assert wuoshi["is_default"] is False # Unknown world: returns False and does NOT leave zero defaults. - result = db.set_default_server_sync("Nope", path=p) + result = ServersStore(p).set_default_server_sync("Nope") assert result is False - rows = db.list_servers_sync(p) + rows = ServersStore(p).list_servers_sync() defaults = [r for r in rows if r["is_default"]] assert len(defaults) == 1, "Must always have exactly one default even after failed set" diff --git a/tests/server/test_users_db_roles.py b/tests/server/test_users_db_roles.py index f6b3cf2c..5685ca84 100644 --- a/tests/server/test_users_db_roles.py +++ b/tests/server/test_users_db_roles.py @@ -16,14 +16,14 @@ import pytest -from backend.server.db import init_db -from backend.server.db.users import ( +from backend.server.db import ( approve_all_pending, create_role_request, get_role_request, get_user_access_status, grant_role, has_role, + init_db, list_all_users, list_pending_users, list_role_assignments, @@ -47,8 +47,17 @@ def db_path(tmp_path: Path) -> Path: return p +@pytest.fixture(autouse=True) +def _stores_at_db_path(db_path: Path, monkeypatch: pytest.MonkeyPatch): + """Point every users.db domain store at this test's temp DB.""" + from backend.server import db as users_db + + for st in users_db.ALL_STORES: + monkeypatch.setattr(st, "path", db_path) + + async def _seed_user(db_path: Path, discord_id: str = "user-1", name: str = "TestUser") -> None: - await upsert_user(discord_id, name, name.lower(), None, path=db_path) + await upsert_user(discord_id, name, name.lower(), None) # --------------------------------------------------------------------------- @@ -60,44 +69,44 @@ class TestRoleHelpers: @pytest.mark.asyncio async def test_grant_role_returns_true_on_insert(self, db_path: Path): await _seed_user(db_path) - result = await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) + result = await grant_role("user-1", "contributor", granted_by="admin-1") assert result is True @pytest.mark.asyncio async def test_grant_role_idempotent_returns_false(self, db_path: Path): await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - second = await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) + await grant_role("user-1", "contributor", granted_by="admin-1") + second = await grant_role("user-1", "contributor", granted_by="admin-1") assert second is False @pytest.mark.asyncio async def test_revoke_role_returns_true_when_held(self, db_path: Path): await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - assert await revoke_role("user-1", "contributor", path=db_path) is True + await grant_role("user-1", "contributor", granted_by="admin-1") + assert await revoke_role("user-1", "contributor") is True @pytest.mark.asyncio async def test_revoke_role_returns_false_when_not_held(self, db_path: Path): await _seed_user(db_path) - assert await revoke_role("user-1", "contributor", path=db_path) is False + assert await revoke_role("user-1", "contributor") is False @pytest.mark.asyncio async def test_list_roles_for_user(self, db_path: Path): await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - roles = await list_roles_for_user("user-1", path=db_path) + await grant_role("user-1", "contributor", granted_by="admin-1") + roles = await list_roles_for_user("user-1") assert "contributor" in roles @pytest.mark.asyncio async def test_has_role_returns_true_when_granted(self, db_path: Path): await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - assert await has_role("user-1", "contributor", path=db_path) is True + await grant_role("user-1", "contributor", granted_by="admin-1") + assert await has_role("user-1", "contributor") is True @pytest.mark.asyncio async def test_has_role_returns_false_when_absent(self, db_path: Path): await _seed_user(db_path) - assert await has_role("user-1", "contributor", path=db_path) is False + assert await has_role("user-1", "contributor") is False # --------------------------------------------------------------------------- @@ -109,9 +118,9 @@ class TestRoleRequestHelpers: @pytest.mark.asyncio async def test_create_role_request_returns_int_id(self, db_path: Path): await _seed_user(db_path) - request_id = await create_role_request("user-1", "contributor", user_note="please", path=db_path) + request_id = await create_role_request("user-1", "contributor", user_note="please") assert isinstance(request_id, int) - row = await get_role_request(request_id, path=db_path) + row = await get_role_request(request_id) assert row is not None assert row["status"] == "pending" assert row["role"] == "contributor" @@ -120,20 +129,20 @@ async def test_create_role_request_returns_int_id(self, db_path: Path): async def test_list_role_requests_pending_oldest_first(self, db_path: Path): await _seed_user(db_path, discord_id="user-1") await _seed_user(db_path, discord_id="user-2", name="Other") - await create_role_request("user-1", "contributor", user_note=None, path=db_path) - await create_role_request("user-2", "contributor", user_note=None, path=db_path) - rows = await list_role_requests(status="pending", path=db_path) + await create_role_request("user-1", "contributor", user_note=None) + await create_role_request("user-2", "contributor", user_note=None) + rows = await list_role_requests(status="pending") assert rows[0]["id"] <= rows[-1]["id"] # oldest (lower id) first @pytest.mark.asyncio async def test_list_role_requests_approved_newest_first(self, db_path: Path): await _seed_user(db_path, discord_id="user-1") await _seed_user(db_path, discord_id="user-2", name="Other") - r1_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - r2_id = await create_role_request("user-2", "contributor", user_note=None, path=db_path) - await review_role_request(r1_id, "approved", "admin-1", path=db_path) - await review_role_request(r2_id, "approved", "admin-1", path=db_path) - rows = await list_role_requests(status="approved", path=db_path) + r1_id = await create_role_request("user-1", "contributor", user_note=None) + r2_id = await create_role_request("user-2", "contributor", user_note=None) + await review_role_request(r1_id, "approved", "admin-1") + await review_role_request(r2_id, "approved", "admin-1") + rows = await list_role_requests(status="approved") # newest-first means higher id comes first assert rows[0]["id"] >= rows[-1]["id"] @@ -147,29 +156,29 @@ class TestReviewAndGrantRole: @pytest.mark.asyncio async def test_atomic_approve_and_grant(self, db_path: Path): await _seed_user(db_path) - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - result = await review_and_grant_role(rr_id, "approved", "admin-1", path=db_path) + rr_id = await create_role_request("user-1", "contributor", user_note=None) + result = await review_and_grant_role(rr_id, "approved", "admin-1") assert result is not None assert result["status"] == "approved" # Role must also be granted - assert await has_role("user-1", "contributor", path=db_path) + assert await has_role("user-1", "contributor") @pytest.mark.asyncio async def test_idempotent_user_already_has_role(self, db_path: Path): """If user already has the role, approve still succeeds (INSERT OR IGNORE).""" await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - result = await review_and_grant_role(rr_id, "approved", "admin-1", path=db_path) + await grant_role("user-1", "contributor", granted_by="admin-1") + rr_id = await create_role_request("user-1", "contributor", user_note=None) + result = await review_and_grant_role(rr_id, "approved", "admin-1") assert result is not None @pytest.mark.asyncio async def test_returns_none_for_already_reviewed_request(self, db_path: Path): await _seed_user(db_path) - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - await review_role_request(rr_id, "rejected", "admin-1", path=db_path) + rr_id = await create_role_request("user-1", "contributor", user_note=None) + await review_role_request(rr_id, "rejected", "admin-1") # Try to approve now-rejected request - result = await review_and_grant_role(rr_id, "approved", "admin-1", path=db_path) + result = await review_and_grant_role(rr_id, "approved", "admin-1") assert result is None @@ -182,22 +191,22 @@ class TestWithdrawRoleRequest: @pytest.mark.asyncio async def test_withdraw_pending_request(self, db_path: Path): await _seed_user(db_path) - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - assert await withdraw_role_request(rr_id, "user-1", path=db_path) is True + rr_id = await create_role_request("user-1", "contributor", user_note=None) + assert await withdraw_role_request(rr_id, "user-1") is True @pytest.mark.asyncio async def test_withdraw_already_approved_returns_false(self, db_path: Path): await _seed_user(db_path) - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) - await review_role_request(rr_id, "approved", "admin-1", path=db_path) - assert await withdraw_role_request(rr_id, "user-1", path=db_path) is False + rr_id = await create_role_request("user-1", "contributor", user_note=None) + await review_role_request(rr_id, "approved", "admin-1") + assert await withdraw_role_request(rr_id, "user-1") is False @pytest.mark.asyncio async def test_withdraw_scoped_to_requester(self, db_path: Path): await _seed_user(db_path, discord_id="user-1") - rr_id = await create_role_request("user-1", "contributor", user_note=None, path=db_path) + rr_id = await create_role_request("user-1", "contributor", user_note=None) # Different user tries to withdraw - assert await withdraw_role_request(rr_id, "user-2", path=db_path) is False + assert await withdraw_role_request(rr_id, "user-2") is False # --------------------------------------------------------------------------- @@ -219,13 +228,13 @@ async def test_user_has_capability_via_granted_role(self, db_path: Path): ("contributor", "edit_zones"), ) await db.commit() - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - assert await user_has_capability_via_db("user-1", "edit_zones", path=db_path) is True + await grant_role("user-1", "contributor", granted_by="admin-1") + assert await user_has_capability_via_db("user-1", "edit_zones") is True @pytest.mark.asyncio async def test_user_lacks_capability_without_role(self, db_path: Path): await _seed_user(db_path) - assert await user_has_capability_via_db("user-1", "edit_zones", path=db_path) is False + assert await user_has_capability_via_db("user-1", "edit_zones") is False @pytest.mark.asyncio async def test_role_has_capability(self, db_path: Path): @@ -237,8 +246,8 @@ async def test_role_has_capability(self, db_path: Path): ("contributor", "edit_raids"), ) await db.commit() - assert await role_has_capability("contributor", "edit_raids", path=db_path) is True - assert await role_has_capability("contributor", "nonexistent", path=db_path) is False + assert await role_has_capability("contributor", "edit_raids") is True + assert await role_has_capability("contributor", "nonexistent") is False # --------------------------------------------------------------------------- @@ -250,11 +259,11 @@ class TestSetUserAccess: @pytest.mark.asyncio async def test_returns_true_on_update(self, db_path: Path): await _seed_user(db_path) - assert await set_user_access("user-1", "approved", path=db_path) is True + assert await set_user_access("user-1", "approved") is True @pytest.mark.asyncio async def test_returns_false_for_unknown_user(self, db_path: Path): - assert await set_user_access("ghost-user", "approved", path=db_path) is False + assert await set_user_access("ghost-user", "approved") is False # --------------------------------------------------------------------------- @@ -266,15 +275,15 @@ class TestListRoleAssignments: @pytest.mark.asyncio async def test_returns_mapping_of_user_to_roles(self, db_path: Path): await _seed_user(db_path) - await grant_role("user-1", "contributor", granted_by="admin-1", path=db_path) - assignments = await list_role_assignments(path=db_path) + await grant_role("user-1", "contributor", granted_by="admin-1") + assignments = await list_role_assignments() assert "user-1" in assignments assert "contributor" in assignments["user-1"] @pytest.mark.asyncio async def test_returns_empty_for_no_roles(self, db_path: Path): await _seed_user(db_path) - assignments = await list_role_assignments(path=db_path) + assignments = await list_role_assignments() assert "user-1" not in assignments @@ -286,35 +295,35 @@ async def test_returns_empty_for_no_roles(self, db_path: Path): class TestOpenSignup: @pytest.mark.asyncio async def test_default_new_user_is_pending(self, db_path: Path): - status = await upsert_user("u-new", "New", "new", None, path=db_path) + status = await upsert_user("u-new", "New", "new", None) assert status == "pending" @pytest.mark.asyncio async def test_open_signup_approves_new_user(self, db_path: Path): - status = await upsert_user("u-open", "Open", "open", None, open_signup=True, path=db_path) + status = await upsert_user("u-open", "Open", "open", None, open_signup=True) assert status == "approved" @pytest.mark.asyncio async def test_admin_always_approved_even_without_open_signup(self, db_path: Path): - status = await upsert_user("u-admin", "Admin", "admin", None, admin_ids=frozenset({"u-admin"}), path=db_path) + status = await upsert_user("u-admin", "Admin", "admin", None, admin_ids=frozenset({"u-admin"})) assert status == "approved" @pytest.mark.asyncio async def test_open_signup_does_not_reapprove_on_relogin(self, db_path: Path): # First login while signup is closed → pending. - await upsert_user("u-relog", "Re", "re", None, open_signup=False, path=db_path) + await upsert_user("u-relog", "Re", "re", None, open_signup=False) # Re-login with the flag now ON must NOT auto-approve an existing user; # ON CONFLICT preserves stored status (only approve_all_pending does that). - status = await upsert_user("u-relog", "Re", "re", None, open_signup=True, path=db_path) + status = await upsert_user("u-relog", "Re", "re", None, open_signup=True) assert status == "pending" @pytest.mark.asyncio async def test_approve_all_pending_clears_backlog_idempotently(self, db_path: Path): - await upsert_user("p1", "P1", "p1", None, path=db_path) - await upsert_user("p2", "P2", "p2", None, path=db_path) - n = await approve_all_pending(path=db_path) + await upsert_user("p1", "P1", "p1", None) + await upsert_user("p2", "P2", "p2", None) + n = await approve_all_pending() assert n == 2 - assert await get_user_access_status("p1", path=db_path) == "approved" - assert await get_user_access_status("p2", path=db_path) == "approved" + assert await get_user_access_status("p1") == "approved" + assert await get_user_access_status("p2") == "approved" # Idempotent: nothing pending now. - assert await approve_all_pending(path=db_path) == 0 + assert await approve_all_pending() == 0 diff --git a/tests/server/test_zone_overview_revisions.py b/tests/server/test_zone_overview_revisions.py index 55500077..c854cdd6 100644 --- a/tests/server/test_zone_overview_revisions.py +++ b/tests/server/test_zone_overview_revisions.py @@ -161,7 +161,6 @@ async def test_revisions_endpoint_returns_editor_display_name(app, raids_tmp): discord_name="Velious Raider", discord_username="vraider", avatar=None, - path=users_db.DB_PATH, ) from backend.server.auth_deps import require_editor