Refactored quotes module into layered architecture with typed domain objects and comprehensive tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 14s
CI / Tests (Python 3.13) (push) Successful in 13s
CI / Tests (Python 3.14) (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-04-10 10:41:37 -04:00
parent e44d7fe09e
commit 969cd0530a
7 changed files with 734 additions and 149 deletions
+30 -149
View File
@@ -17,167 +17,48 @@
Allows moderators to store quotes and anyone to recall random quotes. Allows moderators to store quotes and anyone to recall random quotes.
""" """
from datetime import UTC, datetime from owlbot.api import ModuleContext, on_setup, on_teardown
from aiohttp import web # Re-export decorated handlers so the module loader discovers them.
from .commands import (
from owlbot.api import ( addquote_command,
CommandContext, deletequote_command,
ModuleContext, listquotes_command,
RouteContext, quote_command,
on_command,
on_route,
on_setup,
) )
from .manager import QuoteManager
from .repository import QuoteRepository
from .routes import quotes_list_page
__all__ = [
"addquote_command",
"deletequote_command",
"listquotes_command",
"quote_command",
"quotes_list_page",
"setup",
"teardown",
]
@on_setup @on_setup
async def setup(ctx: ModuleContext) -> None: async def setup(ctx: ModuleContext) -> None:
"""Initialize the quotes module. """Initialize the quotes module.
Creates the database schema. Creates the database schema and instantiates the QuoteManager.
:param ctx: Module context with config, storage, and other services. :param ctx: Module context with config, storage, and other services.
""" """
await ctx.storage.execute(""" repo = QuoteRepository(ctx.storage)
CREATE TABLE IF NOT EXISTS quotes ( await repo.setup()
id INTEGER PRIMARY KEY AUTOINCREMENT, manager = QuoteManager(ctx, repo)
text TEXT NOT NULL, ctx.state["manager"] = manager
added_by TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
count = await ctx.storage.fetch_value("SELECT COUNT(*) FROM quotes")
ctx.logger.info(f"Loaded {count} quote(s) from database.")
@on_route("/list", methods=["GET"]) @on_teardown
async def quotes_list_page(ctx: RouteContext) -> web.Response: async def teardown(ctx: ModuleContext) -> None:
"""Serve an HTML page listing all quotes in a table. """Clean up the quotes module.
Columns: #, Quote, Added By, Date Added. :param ctx: Module context.
Accessible at /owlbot/quotes/list.
:param ctx: The route context.
:return: HTML response with the quotes list table.
""" """
rows = await ctx.storage.fetch_all( ctx.state["manager"] = None
"SELECT id, text, added_by, created_at FROM quotes ORDER BY id"
)
quotes = [
{
"id": row["id"],
"text": row["text"],
"added_by": row["added_by"],
"date": datetime.fromisoformat(row["created_at"]).strftime("%Y-%m-%d"),
}
for row in rows
]
page = ctx.templates.render("list.html", quotes=quotes)
return web.Response(text=page, content_type="text/html")
@on_command("quote", aliases=["q"])
async def quote_command(ctx: CommandContext) -> None:
"""Display a quote. Random if no argument, specific if an ID is given.
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
row = await ctx.storage.fetch_one(
"SELECT id, text FROM quotes ORDER BY RANDOM() LIMIT 1"
)
if not row:
await ctx.owncast_client.send_message("No quotes have been added yet.")
return
await ctx.owncast_client.send_message(f'"{row["text"]}" (#{row["id"]})')
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !quote [id]")
return
row = await ctx.storage.fetch_one(
"SELECT id, text, added_by, created_at FROM quotes WHERE id = ?", (quote_id,)
)
if not row:
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
return
created_at = datetime.fromisoformat(row["created_at"])
date_str = created_at.strftime("%Y-%m-%d")
await ctx.owncast_client.send_message(
f'Quote #{row["id"]}: "{row["text"]}" '
f"- Added by {row['added_by']} on {date_str}"
)
@on_command("addquote", requires_moderator=True)
async def addquote_command(ctx: CommandContext) -> None:
"""Add a new quote to the database. Moderator only.
:param ctx: The command context.
"""
quote_text = ctx.args.strip()
if not quote_text:
await ctx.owncast_client.send_message("Usage: !addquote <quote text>")
return
now = datetime.now(UTC).isoformat()
cursor = await ctx.storage.execute(
"INSERT INTO quotes (text, added_by, created_at) VALUES (?, ?, ?)",
(quote_text, ctx.user.display_name, now),
)
ctx.logger.info(f"Quote #{cursor.lastrowid} added by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Quote #{cursor.lastrowid} added.")
@on_command("deletequote", aliases=["delquote"], requires_moderator=True)
async def deletequote_command(ctx: CommandContext) -> None:
"""Delete a quote by ID. Moderator only.
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
existing = await ctx.storage.fetch_one(
"SELECT id FROM quotes WHERE id = ?", (quote_id,)
)
if not existing:
await ctx.owncast_client.send_message(f"Quote #{quote_id} not found.")
return
await ctx.storage.execute("DELETE FROM quotes WHERE id = ?", (quote_id,))
ctx.logger.info(f"Quote #{quote_id} deleted by {ctx.user.display_name}.")
await ctx.owncast_client.send_message(f"Quote #{quote_id} deleted.")
@on_command("listquotes", cooldown=15)
async def listquotes_command(ctx: CommandContext) -> None:
"""Send the URL to the quotes list web page.
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Quotes: {url}")
+112
View File
@@ -0,0 +1,112 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Chat commands for managing quotes."""
from owlbot.api import CommandContext, on_command
from .manager import get_manager
from .types import QuoteNotFoundError
@on_command("quote", aliases=["q"])
async def quote_command(ctx: CommandContext) -> None:
"""Display a quote. Random if no argument, specific if an ID is given.
Usage: !quote [id]
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
quote = await get_manager(ctx.module).get_random_quote()
if quote is None:
await ctx.owncast_client.send_message("No quotes have been added yet.")
return
await ctx.owncast_client.send_message(f'"{quote.text}" (#{quote.id})')
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !quote [id]")
return
try:
quote = await get_manager(ctx.module).get_quote(quote_id)
except QuoteNotFoundError as e:
await ctx.owncast_client.send_message(f"Quote #{e.quote_id} not found.")
return
await ctx.owncast_client.send_message(
f'Quote #{quote.id}: "{quote.text}" - Added by {quote.added_by} on {quote.date}'
)
@on_command("addquote", requires_moderator=True)
async def addquote_command(ctx: CommandContext) -> None:
"""Add a new quote to the database. Moderator only.
Usage: !addquote <quote text>
:param ctx: The command context.
"""
quote_text = ctx.args.strip()
if not quote_text:
await ctx.owncast_client.send_message("Usage: !addquote <quote text>")
return
quote = await get_manager(ctx.module).add_quote(quote_text, ctx.user.display_name)
await ctx.owncast_client.send_message(f"Quote #{quote.id} added.")
@on_command("deletequote", aliases=["delquote"], requires_moderator=True)
async def deletequote_command(ctx: CommandContext) -> None:
"""Delete a quote by ID. Moderator only.
Usage: !deletequote <id>
:param ctx: The command context.
"""
args = ctx.args_list
if not args:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
try:
quote_id = int(args[0])
except ValueError:
await ctx.owncast_client.send_message("Usage: !deletequote <id>")
return
try:
await get_manager(ctx.module).delete_quote(quote_id)
except QuoteNotFoundError as e:
await ctx.owncast_client.send_message(f"Quote #{e.quote_id} not found.")
return
await ctx.owncast_client.send_message(f"Quote #{quote_id} deleted.")
@on_command("listquotes", cooldown=15)
async def listquotes_command(ctx: CommandContext) -> None:
"""Send the URL to the quotes list web page.
Usage: !listquotes
:param ctx: The command context.
"""
url = ctx.routes.url_for("/list")
await ctx.owncast_client.send_message(f"Quotes: {url}")
+96
View File
@@ -0,0 +1,96 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Business logic coordinator for the quotes module."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from owlbot.api import ModuleContext
from .repository import QuoteRepository
from .types import Quote
class QuoteManager:
"""Coordinates between the repository and handlers."""
def __init__(self, ctx: ModuleContext, repo: QuoteRepository) -> None:
"""Initialize the manager.
:param ctx: The module context.
:param repo: The quote repository for persistence.
"""
self._ctx = ctx
self._repo = repo
async def add_quote(self, text: str, added_by: str) -> Quote:
"""Add a new quote.
:param text: The quote text.
:param added_by: Display name of the user who added the quote.
:return: Snapshot of the newly created quote.
"""
quote = await self._repo.create(text, added_by)
self._ctx.logger.info("Quote #%d added by %s.", quote.id, added_by)
return quote
async def delete_quote(self, quote_id: int) -> Quote:
"""Delete a quote by ID.
:param quote_id: Database ID of the quote.
:return: Snapshot of the deleted quote.
:raises QuoteNotFoundError: If no quote matches the ID.
"""
quote = await self._repo.delete(quote_id)
self._ctx.logger.info("Quote #%d deleted.", quote_id)
return quote
async def get_quote(self, quote_id: int) -> Quote:
"""Fetch a quote by ID.
:param quote_id: Database ID of the quote.
:return: The matching Quote.
:raises QuoteNotFoundError: If no quote matches the ID.
"""
return await self._repo.get(quote_id)
async def get_random_quote(self) -> Quote | None:
"""Fetch a random quote.
:return: A random Quote, or None if no quotes exist.
"""
return await self._repo.get_random()
async def list_quotes(self) -> list[Quote]:
"""Return all quotes ordered by ID.
:return: List of Quote snapshots.
"""
return await self._repo.list_all()
def get_manager(ctx: ModuleContext) -> QuoteManager:
"""Return the QuoteManager stored in the module context's state.
:param ctx: The module context.
:return: The active QuoteManager.
:raises RuntimeError: If the manager has not been initialized.
"""
manager = ctx.state.get("manager")
if not isinstance(manager, QuoteManager):
raise RuntimeError("QuoteManager is not initialized.")
return manager
+112
View File
@@ -0,0 +1,112 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Persistence layer for quote records."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from .types import Quote, QuoteNotFoundError
if TYPE_CHECKING:
from owlbot.api.storage import ModuleStorage
class QuoteRepository:
"""Handles all database operations for quote records."""
def __init__(self, storage: ModuleStorage) -> None:
"""Initialize with a module storage instance.
:param storage: The module's storage backend.
"""
self._storage = storage
async def setup(self) -> None:
"""Create the quotes table if it does not exist."""
await self._storage.execute("""
CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
added_by TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
async def create(self, text: str, added_by: str) -> Quote:
"""Insert a new quote and return its snapshot.
:param text: The quote text.
:param added_by: Display name of the user who added the quote.
:return: Snapshot of the newly created quote.
"""
now = datetime.now(UTC).isoformat()
row = await self._storage.fetch_one(
"INSERT INTO quotes (text, added_by, created_at) "
"VALUES (?, ?, ?) RETURNING *",
(text, added_by, now),
)
if row is None:
raise RuntimeError("INSERT RETURNING did not produce a row")
return Quote.from_row(row)
async def get(self, quote_id: int) -> Quote:
"""Fetch a quote by its ID.
:param quote_id: Database ID of the quote.
:return: The matching Quote.
:raises QuoteNotFoundError: If no quote matches the ID.
"""
row = await self._storage.fetch_one(
"SELECT * FROM quotes WHERE id = ?", (quote_id,)
)
if row is None:
raise QuoteNotFoundError(quote_id)
return Quote.from_row(row)
async def get_random(self) -> Quote | None:
"""Fetch a random quote.
:return: A random Quote, or None if the table is empty.
"""
row = await self._storage.fetch_one(
"SELECT * FROM quotes ORDER BY RANDOM() LIMIT 1"
)
if row is None:
return None
return Quote.from_row(row)
async def delete(self, quote_id: int) -> Quote:
"""Delete a quote and return its snapshot.
:param quote_id: Database ID of the quote.
:return: Snapshot of the deleted quote.
:raises QuoteNotFoundError: If no quote matches the ID.
"""
row = await self._storage.fetch_one(
"DELETE FROM quotes WHERE id = ? RETURNING *", (quote_id,)
)
if row is None:
raise QuoteNotFoundError(quote_id)
return Quote.from_row(row)
async def list_all(self) -> list[Quote]:
"""Return all quotes ordered by ID.
:return: List of Quote snapshots.
"""
rows = await self._storage.fetch_all("SELECT * FROM quotes ORDER BY id")
return [Quote.from_row(row) for row in rows]
+33
View File
@@ -0,0 +1,33 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""HTTP route handlers for the quotes module."""
from aiohttp import web
from owlbot.api import RouteContext, on_route
from .manager import get_manager
@on_route("/list", methods=["GET"])
async def quotes_list_page(ctx: RouteContext) -> web.Response:
"""Serve an HTML page listing all quotes in a table.
:param ctx: The route context.
:return: HTML response with the quotes list table.
"""
quotes = await get_manager(ctx.module).list_quotes()
page = ctx.templates.render("list.html", quotes=quotes)
return web.Response(text=page, content_type="text/html")
+69
View File
@@ -0,0 +1,69 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Data containers and domain errors for the quotes module."""
from __future__ import annotations
import dataclasses
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import aiosqlite
@dataclasses.dataclass(frozen=True, slots=True)
class Quote:
"""Immutable snapshot of a quote record."""
id: int
text: str
added_by: str
created_at: str
@property
def date(self) -> str:
"""Return the creation date formatted as YYYY-MM-DD."""
return datetime.fromisoformat(self.created_at).strftime("%Y-%m-%d")
@classmethod
def from_row(cls, row: aiosqlite.Row) -> Quote:
"""Build a Quote from a database row.
:param row: A row from the quotes table (must include all columns).
:return: A Quote snapshot.
"""
return cls(
id=row["id"],
text=row["text"],
added_by=row["added_by"],
created_at=row["created_at"],
)
class QuoteError(Exception):
"""Base class for quote domain errors."""
class QuoteNotFoundError(QuoteError):
"""No quote matches the given ID."""
def __init__(self, quote_id: int) -> None:
"""Initialize with the quote ID that was not found.
:param quote_id: The ID that was not found.
"""
self.quote_id = quote_id
super().__init__(f"quote not found: {quote_id}")
+282
View File
@@ -0,0 +1,282 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the quotes module: types, repository, and manager."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from owlbot.api.storage import ModuleStorage
from owlbot.builtin_modules.quotes.manager import QuoteManager, get_manager
from owlbot.builtin_modules.quotes.repository import QuoteRepository
from owlbot.builtin_modules.quotes.types import (
Quote,
QuoteError,
QuoteNotFoundError,
)
from tests.conftest import make_module_context
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from owlbot.api.context import ModuleContext
@pytest.fixture
async def quote_storage() -> AsyncIterator[ModuleStorage]:
"""Yield an open in-memory ModuleStorage."""
async with ModuleStorage(None, "quotes") as storage:
yield storage
@pytest.fixture
async def repo(quote_storage: ModuleStorage) -> QuoteRepository:
"""QuoteRepository backed by quote_storage, with schema initialized."""
r = QuoteRepository(quote_storage)
await r.setup()
return r
@pytest.fixture
def quote_ctx(quote_storage: ModuleStorage) -> ModuleContext:
"""ModuleContext backed by quote_storage."""
return make_module_context(storage=quote_storage, module_name="quotes")
@pytest.fixture
def manager(quote_ctx: ModuleContext, repo: QuoteRepository) -> QuoteManager:
"""QuoteManager backed by real repo."""
return QuoteManager(quote_ctx, repo)
class TestQuoteFromRow:
"""Quote.from_row() database row conversion."""
async def test_builds_from_row(self) -> None:
"""from_row constructs a Quote with correct field values."""
row = {
"id": 5,
"text": "Test quote",
"added_by": "someone",
"created_at": "2026-03-15T10:00:00+00:00",
}
quote = Quote.from_row(row) # type: ignore[arg-type]
assert quote.id == 5
assert quote.text == "Test quote"
assert quote.added_by == "someone"
assert quote.created_at == "2026-03-15T10:00:00+00:00"
class TestQuoteErrors:
"""Quote domain error classes."""
def test_base_error_hierarchy(self) -> None:
"""All quote errors inherit from QuoteError."""
assert issubclass(QuoteNotFoundError, QuoteError)
def test_not_found_stores_quote_id(self) -> None:
"""QuoteNotFoundError stores the quote ID."""
err = QuoteNotFoundError(42)
assert err.quote_id == 42
assert "42" in str(err)
class TestRepositoryCreate:
"""QuoteRepository.create() inserts new quotes."""
async def test_create_returns_quote(self, repo: QuoteRepository) -> None:
"""Creating a quote returns a Quote snapshot with correct fields."""
quote = await repo.create("Hello, world!", "tester")
assert quote.id == 1
assert quote.text == "Hello, world!"
assert quote.added_by == "tester"
assert quote.created_at is not None
async def test_create_auto_increments(self, repo: QuoteRepository) -> None:
"""Each created quote gets a sequential ID."""
q1 = await repo.create("First", "tester")
q2 = await repo.create("Second", "tester")
assert q2.id == q1.id + 1
class TestRepositoryGet:
"""QuoteRepository.get() fetches by ID."""
async def test_get_existing(self, repo: QuoteRepository) -> None:
"""Fetches a quote by its ID."""
created = await repo.create("Test", "tester")
fetched = await repo.get(created.id)
assert fetched.id == created.id
assert fetched.text == "Test"
async def test_get_not_found(self, repo: QuoteRepository) -> None:
"""Raises QuoteNotFoundError for an unknown ID."""
with pytest.raises(QuoteNotFoundError) as exc_info:
await repo.get(999)
assert exc_info.value.quote_id == 999
class TestRepositoryGetRandom:
"""QuoteRepository.get_random() fetches a random quote."""
async def test_get_random_returns_quote(self, repo: QuoteRepository) -> None:
"""Returns a quote when the table is not empty."""
await repo.create("Only quote", "tester")
quote = await repo.get_random()
assert quote is not None
assert quote.text == "Only quote"
async def test_get_random_empty_table(self, repo: QuoteRepository) -> None:
"""Returns None when no quotes exist."""
result = await repo.get_random()
assert result is None
class TestRepositoryDelete:
"""QuoteRepository.delete() removes records."""
async def test_delete_returns_quote(self, repo: QuoteRepository) -> None:
"""Deleting a quote returns its snapshot."""
created = await repo.create("Doomed", "tester")
deleted = await repo.delete(created.id)
assert deleted.id == created.id
assert deleted.text == "Doomed"
async def test_delete_removes_from_db(self, repo: QuoteRepository) -> None:
"""Deleted quote is no longer retrievable."""
created = await repo.create("Doomed", "tester")
await repo.delete(created.id)
with pytest.raises(QuoteNotFoundError):
await repo.get(created.id)
async def test_delete_not_found(self, repo: QuoteRepository) -> None:
"""Raises QuoteNotFoundError for an unknown ID."""
with pytest.raises(QuoteNotFoundError) as exc_info:
await repo.delete(999)
assert exc_info.value.quote_id == 999
class TestRepositoryListAll:
"""QuoteRepository.list_all() returns all quotes."""
async def test_list_all(self, repo: QuoteRepository) -> None:
"""Returns all quotes ordered by ID."""
await repo.create("First", "alice")
await repo.create("Second", "bob")
result = await repo.list_all()
assert len(result) == 2
assert result[0].text == "First"
assert result[1].text == "Second"
async def test_list_all_empty(self, repo: QuoteRepository) -> None:
"""Returns an empty list when no quotes exist."""
assert await repo.list_all() == []
class TestQuoteManagerAddQuote:
"""QuoteManager.add_quote() creates quotes."""
async def test_add_quote(self, manager: QuoteManager) -> None:
"""add_quote creates a quote and returns its snapshot."""
quote = await manager.add_quote("Test quote", "tester")
assert quote.id == 1
assert quote.text == "Test quote"
assert quote.added_by == "tester"
class TestQuoteManagerDeleteQuote:
"""QuoteManager.delete_quote() removes quotes."""
async def test_delete_quote(self, manager: QuoteManager) -> None:
"""delete_quote removes the quote and returns its snapshot."""
created = await manager.add_quote("Doomed", "tester")
deleted = await manager.delete_quote(created.id)
assert deleted.id == created.id
assert deleted.text == "Doomed"
assert await manager.list_quotes() == []
async def test_delete_quote_not_found(self, manager: QuoteManager) -> None:
"""delete_quote raises QuoteNotFoundError for unknown ID."""
with pytest.raises(QuoteNotFoundError):
await manager.delete_quote(999)
class TestQuoteManagerGetQuote:
"""QuoteManager.get_quote() fetches by ID."""
async def test_get_quote(self, manager: QuoteManager) -> None:
"""get_quote returns the matching quote."""
created = await manager.add_quote("Test", "tester")
fetched = await manager.get_quote(created.id)
assert fetched.id == created.id
assert fetched.text == "Test"
async def test_get_quote_not_found(self, manager: QuoteManager) -> None:
"""get_quote raises QuoteNotFoundError for unknown ID."""
with pytest.raises(QuoteNotFoundError):
await manager.get_quote(999)
class TestQuoteManagerGetRandomQuote:
"""QuoteManager.get_random_quote() fetches random quotes."""
async def test_get_random_quote(self, manager: QuoteManager) -> None:
"""get_random_quote returns a quote when quotes exist."""
await manager.add_quote("Only one", "tester")
quote = await manager.get_random_quote()
assert quote is not None
assert quote.text == "Only one"
async def test_get_random_quote_empty(self, manager: QuoteManager) -> None:
"""get_random_quote returns None when no quotes exist."""
result = await manager.get_random_quote()
assert result is None
class TestQuoteManagerListQuotes:
"""QuoteManager.list_quotes() returns all quotes."""
async def test_list_quotes(self, manager: QuoteManager) -> None:
"""list_quotes returns all quotes ordered by ID."""
await manager.add_quote("First", "alice")
await manager.add_quote("Second", "bob")
result = await manager.list_quotes()
assert len(result) == 2
assert result[0].text == "First"
assert result[1].text == "Second"
async def test_list_quotes_empty(self, manager: QuoteManager) -> None:
"""list_quotes returns an empty list when no quotes exist."""
assert await manager.list_quotes() == []
class TestGetManager:
"""get_manager() helper."""
def test_returns_manager(self) -> None:
"""get_manager returns the QuoteManager from ctx.state."""
ctx = make_module_context()
repo_stub = QuoteRepository(ctx.storage)
mgr = QuoteManager(ctx, repo_stub)
ctx.state["manager"] = mgr
assert get_manager(ctx) is mgr
def test_raises_if_not_initialized(self) -> None:
"""get_manager raises RuntimeError when manager is missing."""
ctx = make_module_context()
with pytest.raises(RuntimeError, match="QuoteManager is not initialized"):
get_manager(ctx)