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
+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)