Replaced MockStorage with real ModuleStorage and added storage test suite.
Format / ruff (push) Successful in 11s
Lint / ruff (push) Successful in 11s
Unit Tests / pytest (push) Successful in 19s
Type Check / mypy (push) Successful in 20s

This commit is contained in:
2026-02-17 13:59:21 -05:00
parent 6044a94aec
commit 8eaa1959df
5 changed files with 822 additions and 178 deletions
+19 -8
View File
@@ -71,6 +71,23 @@ class ModuleStorage:
self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage") self._logger = logging.getLogger(f"owlbot.modules.{module_name}.storage")
self._logger.info(f"Database initialized at: {self._db_path.absolute()}") self._logger.info(f"Database initialized at: {self._db_path.absolute()}")
async def __aenter__(self) -> ModuleStorage:
"""Enter an async context manager that closes the storage on exit.
Enables ``async with ModuleStorage(...) as s:`` for scoped usage
(e.g. test fixtures).
In production, ``ModuleLoader`` manages storage lifecycle with explicit
``_close()`` calls because the storage is created during module load
but closed in separate code paths (unload, load failure, setup failure),
so there is no single scope an ``async with`` block could wrap.
"""
return self
async def __aexit__(self, *exc_info: object) -> None:
"""Close the storage when leaving the ``async with`` block."""
await self._close()
@asynccontextmanager @asynccontextmanager
async def transaction(self) -> AsyncIterator[ModuleStorage]: async def transaction(self) -> AsyncIterator[ModuleStorage]:
""" """
@@ -307,16 +324,10 @@ class ModuleStorage:
self._closed = True self._closed = True
for conn in self._all_connections: for conn in self._all_connections:
try: await conn.close()
await conn.close()
except Exception as e:
self._logger.debug(f"Exception closing connection: {e}")
while not self._pool.empty(): while not self._pool.empty():
try: self._pool.get_nowait()
self._pool.get_nowait()
except asyncio.QueueEmpty:
break
self._all_connections.clear() self._all_connections.clear()
self._logger.info("All pool connections closed.") self._logger.info("All pool connections closed.")
-1
View File
@@ -57,7 +57,6 @@ python_version = "3.14"
strict = true strict = true
warn_unreachable = true warn_unreachable = true
explicit_package_bases = true explicit_package_bases = true
exclude = ["tests/"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+29
View File
@@ -0,0 +1,29 @@
# 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.
from typing import TYPE_CHECKING
import pytest
from owlbot.api.storage import ModuleStorage
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
@pytest.fixture
async def storage(tmp_path: Path) -> AsyncIterator[ModuleStorage]:
async with ModuleStorage(tmp_path, "test_module") as s:
yield s
+354 -169
View File
@@ -19,8 +19,8 @@ templates and user scenarios.
""" """
import random import random
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
import pytest import pytest
from freezegun import freeze_time from freezegun import freeze_time
@@ -28,48 +28,38 @@ from freezegun import freeze_time
from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration from owlbot.builtin_modules.custom_commands.placeholder_handlers import _format_duration
from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders from owlbot.builtin_modules.custom_commands.placeholders import process_placeholders
if TYPE_CHECKING:
from owlbot.api.storage import ModuleStorage
class MockStorage: CREATE_COUNTERS = (
"""In-memory SQLite storage that executes real SQL queries.""" "CREATE TABLE IF NOT EXISTS counters ("
"name TEXT PRIMARY KEY NOT NULL, "
"value INTEGER DEFAULT 0)"
)
def __init__(self, counter_values=None):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
self._conn.execute(
"CREATE TABLE counters ("
"name TEXT PRIMARY KEY NOT NULL, "
"value INTEGER DEFAULT 0)"
)
for name, value in (counter_values or {}).items():
self._conn.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)",
(name, value),
)
self._conn.commit()
async def fetch_one(self, query, params=None): @pytest.fixture
cursor = self._conn.execute(query, params or ()) async def placeholder_storage(storage: ModuleStorage) -> ModuleStorage:
row = cursor.fetchone() """Storage instance with the counters table created."""
self._conn.commit() await storage.execute(CREATE_COUNTERS)
return row return storage
async def fetch_value(self, query, params=None):
row = await self.fetch_one(query, params)
if row is None:
return None
return row[0]
async def process( async def process(
template, args=None, user="Alice", use_count=1, storage=None, max_depth=4 template: str,
): storage: ModuleStorage,
args: list[str] | None = None,
user: str = "Alice",
use_count: int = 1,
max_depth: int = 4,
) -> str:
"""Shorthand for process_placeholders with sensible defaults.""" """Shorthand for process_placeholders with sensible defaults."""
return await process_placeholders( return await process_placeholders(
template, template,
args or [], args or [],
user, user,
use_count, use_count,
storage or MockStorage(), storage,
max_depth=max_depth, max_depth=max_depth,
) )
@@ -131,19 +121,29 @@ class TestSimpleSubstitution:
), ),
], ],
) )
async def test_substitution(self, template, kwargs, expected): async def test_substitution(
self,
placeholder_storage: ModuleStorage,
template: str,
kwargs: dict[str, Any],
expected: str,
) -> None:
"""Placeholders and plain text resolve to the expected string.""" """Placeholders and plain text resolve to the expected string."""
assert await process(template, **kwargs) == expected assert await process(template, placeholder_storage, **kwargs) == expected
async def test_multiple_placeholders_in_one_template(self): async def test_multiple_placeholders_in_one_template(
self, placeholder_storage: ModuleStorage
) -> None:
"""Multiple different placeholder types resolve in a single template.""" """Multiple different placeholder types resolve in a single template."""
storage = MockStorage({"score": 42}) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("score", 42)
)
random.seed(99) random.seed(99)
expected_roll = random.randint(1, 6) expected_roll = random.randint(1, 6)
random.seed(99) random.seed(99)
result = await process( result = await process(
"$(user) has $(getcount score) points (roll: $(rand 1 6))", "$(user) has $(getcount score) points (roll: $(rand 1 6))",
storage=storage, placeholder_storage,
) )
assert result == f"Alice has 42 points (roll: {expected_roll})" assert result == f"Alice has 42 points (roll: {expected_roll})"
@@ -173,9 +173,15 @@ class TestArgPlaceholders:
), ),
], ],
) )
async def test_arg_substitution(self, template, args, expected): async def test_arg_substitution(
self,
placeholder_storage: ModuleStorage,
template: str,
args: list[str],
expected: str,
) -> None:
"""Positional arguments resolve correctly.""" """Positional arguments resolve correctly."""
assert await process(template, args=args) == expected assert await process(template, placeholder_storage, args=args) == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
"template,args,expected", "template,args,expected",
@@ -185,9 +191,15 @@ class TestArgPlaceholders:
pytest.param("$(99)", [], "$(99)", id="index-ninety-nine"), pytest.param("$(99)", [], "$(99)", id="index-ninety-nine"),
], ],
) )
async def test_invalid_arg_index_passthrough(self, template, args, expected): async def test_invalid_arg_index_passthrough(
self,
placeholder_storage: ModuleStorage,
template: str,
args: list[str],
expected: str,
) -> None:
"""Out-of-range or unregistered numeric indices pass through unchanged.""" """Out-of-range or unregistered numeric indices pass through unchanged."""
result = await process(template, args=args) result = await process(template, placeholder_storage, args=args)
assert result == expected assert result == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -207,39 +219,51 @@ class TestArgPlaceholders:
), ),
], ],
) )
async def test_arg_extra_args_error(self, template, args, expected): async def test_arg_extra_args_error(
self,
placeholder_storage: ModuleStorage,
template: str,
args: list[str],
expected: str,
) -> None:
"""Positional arguments with extra tokens report an error.""" """Positional arguments with extra tokens report an error."""
assert await process(template, args=args) == expected assert await process(template, placeholder_storage, args=args) == expected
class TestNamedCounters: class TestNamedCounters:
"""$(count name), $(getcount name), increments, resets, and deltas.""" """$(count name), $(getcount name), increments, resets, and deltas."""
async def test_named_death_counter_increments(self): async def test_named_death_counter_increments(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(count name) increments the named counter by 1 each call.""" """$(count name) increments the named counter by 1 each call."""
storage = MockStorage()
result = await process( result = await process(
"We have died $(count deaths) times.", "We have died $(count deaths) times.",
storage=storage, placeholder_storage,
) )
assert result == "We have died 1 times." assert result == "We have died 1 times."
result = await process( result = await process(
"We have died $(count deaths) times.", "We have died $(count deaths) times.",
storage=storage, placeholder_storage,
) )
assert result == "We have died 2 times." assert result == "We have died 2 times."
async def test_getcount_without_changing(self): async def test_getcount_without_changing(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(getcount name) reads the counter without modifying it.""" """$(getcount name) reads the counter without modifying it."""
storage = MockStorage({"deaths": 5}) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
)
result = await process( result = await process(
"Total deaths so far: $(getcount deaths)", "Total deaths so far: $(getcount deaths)",
storage=storage, placeholder_storage,
) )
assert result == "Total deaths so far: 5" assert result == "Total deaths so far: 5"
row = await storage.fetch_one( row = await placeholder_storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", ("deaths",) "SELECT value FROM counters WHERE name = ?", ("deaths",)
) )
assert row is not None
assert row["value"] == 5 assert row["value"] == 5
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -262,15 +286,25 @@ class TestNamedCounters:
], ],
) )
async def test_count_with_db_check( async def test_count_with_db_check(
self, counter_values, template, expected, db_name, db_value self,
): placeholder_storage: ModuleStorage,
counter_values: dict[str, int],
template: str,
expected: str,
db_name: str,
db_value: int,
) -> None:
"""$(count) modifies the counter and produces the expected text.""" """$(count) modifies the counter and produces the expected text."""
storage = MockStorage(counter_values) for name, value in counter_values.items():
result = await process(template, storage=storage) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
)
result = await process(template, placeholder_storage)
assert result == expected assert result == expected
row = await storage.fetch_one( row = await placeholder_storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", (db_name,) "SELECT value FROM counters WHERE name = ?", (db_name,)
) )
assert row is not None
assert row["value"] == db_value assert row["value"] == db_value
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -287,62 +321,84 @@ class TestNamedCounters:
pytest.param({}, "$(count boss_2_kills)", "1", id="underscore-in-name"), pytest.param({}, "$(count boss_2_kills)", "1", id="underscore-in-name"),
], ],
) )
async def test_count_simple(self, counter_values, template, expected): async def test_count_simple(
self,
placeholder_storage: ModuleStorage,
counter_values: dict[str, int],
template: str,
expected: str,
) -> None:
"""Named counter operations produce the expected text.""" """Named counter operations produce the expected text."""
storage = MockStorage(counter_values) for name, value in counter_values.items():
result = await process(template, storage=storage) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", (name, value)
)
result = await process(template, placeholder_storage)
assert result == expected assert result == expected
async def test_multiple_named_counters(self): async def test_multiple_named_counters(
self, placeholder_storage: ModuleStorage
) -> None:
"""Different named counters are independent of each other.""" """Different named counters are independent of each other."""
storage = MockStorage({"wins": 3, "losses": 1}) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("wins", 3)
)
await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("losses", 1)
)
result = await process( result = await process(
"Wins: $(getcount wins) | Losses: $(getcount losses)", "Wins: $(getcount wins) | Losses: $(getcount losses)",
storage=storage, placeholder_storage,
) )
assert result == "Wins: 3 | Losses: 1" assert result == "Wins: 3 | Losses: 1"
async def test_adjacent_counters_both_increment(self): async def test_adjacent_counters_both_increment(
self, placeholder_storage: ModuleStorage
) -> None:
"""Two different counters in one template both increment independently.""" """Two different counters in one template both increment independently."""
storage = MockStorage()
result = await process( result = await process(
"A: $(count a) B: $(count b)", "A: $(count a) B: $(count b)",
storage=storage, placeholder_storage,
) )
assert result == "A: 1 B: 1" assert result == "A: 1 B: 1"
row = await storage.fetch_one( row = await placeholder_storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", ("a",) "SELECT value FROM counters WHERE name = ?", ("a",)
) )
assert row is not None
assert row["value"] == 1 assert row["value"] == 1
row = await storage.fetch_one( row = await placeholder_storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", ("b",) "SELECT value FROM counters WHERE name = ?", ("b",)
) )
assert row is not None
assert row["value"] == 1 assert row["value"] == 1
async def test_same_counter_twice_in_one_template(self): async def test_same_counter_twice_in_one_template(
self, placeholder_storage: ModuleStorage
) -> None:
"""Two references to the same counter both increment.""" """Two references to the same counter both increment."""
storage = MockStorage()
result = await process( result = await process(
"$(count x) then $(count x)", "$(count x) then $(count x)",
storage=storage, placeholder_storage,
) )
assert result == "1 then 2" assert result == "1 then 2"
async def test_count_and_getcount_same_counter(self): async def test_count_and_getcount_same_counter(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(count) increments, then $(getcount) reads the new value.""" """$(count) increments, then $(getcount) reads the new value."""
storage = MockStorage()
result = await process( result = await process(
"Now: $(count pts) Total: $(getcount pts)", "Now: $(count pts) Total: $(getcount pts)",
storage=storage, placeholder_storage,
) )
assert result == "Now: 1 Total: 1" assert result == "Now: 1 Total: 1"
async def test_count_absolute_set_then_read(self): async def test_count_absolute_set_then_read(
self, placeholder_storage: ModuleStorage
) -> None:
"""Absolute set followed by getcount confirms persistence.""" """Absolute set followed by getcount confirms persistence."""
storage = MockStorage()
result = await process( result = await process(
"$(count score 42) -> $(getcount score)", "$(count score 42) -> $(getcount score)",
storage=storage, placeholder_storage,
) )
assert result == "42 -> 42" assert result == "42 -> 42"
@@ -353,9 +409,11 @@ class TestNamedCounters:
pytest.param("-", id="minus"), pytest.param("-", id="minus"),
], ],
) )
async def test_bare_sign_rejected(self, sign): async def test_bare_sign_rejected(
self, placeholder_storage: ModuleStorage, sign: str
) -> None:
"""$(count x +) and $(count x -) report an error.""" """$(count x +) and $(count x -) report an error."""
result = await process(f"$(count x {sign})") result = await process(f"$(count x {sign})", placeholder_storage)
assert ( assert (
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)" result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
) )
@@ -371,14 +429,21 @@ class TestCounterNameValidation:
pytest.param("$(count 123)", "1", "123", 1, id="numeric-name"), pytest.param("$(count 123)", "1", "123", 1, id="numeric-name"),
], ],
) )
async def test_count_name_normalized(self, template, expected, db_name, db_value): async def test_count_name_normalized(
self,
placeholder_storage: ModuleStorage,
template: str,
expected: str,
db_name: str,
db_value: int,
) -> None:
"""Counter names are normalized and accepted.""" """Counter names are normalized and accepted."""
storage = MockStorage() result = await process(template, placeholder_storage)
result = await process(template, storage=storage)
assert result == expected assert result == expected
row = await storage.fetch_one( row = await placeholder_storage.fetch_one(
"SELECT value FROM counters WHERE name = ?", (db_name,) "SELECT value FROM counters WHERE name = ?", (db_name,)
) )
assert row is not None
assert row["value"] == db_value assert row["value"] == db_value
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -390,30 +455,40 @@ class TestCounterNameValidation:
pytest.param("-3", id="leading-minus"), pytest.param("-3", id="leading-minus"),
], ],
) )
async def test_count_invalid_name_rejected(self, name): async def test_count_invalid_name_rejected(
self, placeholder_storage: ModuleStorage, name: str
) -> None:
"""Names with hyphens, dots, or leading +/- are rejected.""" """Names with hyphens, dots, or leading +/- are rejected."""
result = await process(f"$(count {name})") result = await process(f"$(count {name})", placeholder_storage)
assert result == ( assert result == (
"Invalid $(count): counter name may only contain " "Invalid $(count): counter name may only contain "
"letters, numbers, and underscores" "letters, numbers, and underscores"
) )
async def test_count_space_in_name_rejected(self): async def test_count_space_in_name_rejected(
self, placeholder_storage: ModuleStorage
) -> None:
"""A name with a space becomes two arguments; the second is a bad modifier.""" """A name with a space becomes two arguments; the second is a bad modifier."""
result = await process("$(count my counter)") result = await process("$(count my counter)", placeholder_storage)
assert ( assert (
result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)" result == "Invalid $(count): modifier must be an integer (e.g., +5, -1, 0)"
) )
async def test_getcount_uppercase_name_lowered(self): async def test_getcount_uppercase_name_lowered(
self, placeholder_storage: ModuleStorage
) -> None:
"""Uppercase getcount names are normalized to lowercase.""" """Uppercase getcount names are normalized to lowercase."""
storage = MockStorage({"deaths": 5}) await placeholder_storage.execute(
result = await process("$(getcount Deaths)", storage=storage) "INSERT INTO counters (name, value) VALUES (?, ?)", ("deaths", 5)
)
result = await process("$(getcount Deaths)", placeholder_storage)
assert result == "5" assert result == "5"
async def test_getcount_hyphen_in_name_rejected(self): async def test_getcount_hyphen_in_name_rejected(
self, placeholder_storage: ModuleStorage
) -> None:
"""Hyphens are not allowed in getcount names.""" """Hyphens are not allowed in getcount names."""
result = await process("$(getcount boss-kills)") result = await process("$(getcount boss-kills)", placeholder_storage)
assert result == ( assert result == (
"Invalid $(getcount): counter name may only contain " "Invalid $(getcount): counter name may only contain "
"letters, numbers, and underscores" "letters, numbers, and underscores"
@@ -423,12 +498,12 @@ class TestCounterNameValidation:
class TestRand: class TestRand:
"""All $(rand) behavior: basic usage, negative ranges, and boundaries.""" """All $(rand) behavior: basic usage, negative ranges, and boundaries."""
async def test_dice_roll(self): async def test_dice_roll(self, placeholder_storage: ModuleStorage) -> None:
"""$(rand 1 6) produces a random integer in range.""" """$(rand 1 6) produces a random integer in range."""
random.seed(0) random.seed(0)
expected_roll = random.randint(1, 6) expected_roll = random.randint(1, 6)
random.seed(0) random.seed(0)
result = await process("$(user) rolled a $(rand 1 6)!") result = await process("$(user) rolled a $(rand 1 6)!", placeholder_storage)
assert result == f"Alice rolled a {expected_roll}!" assert result == f"Alice rolled a {expected_roll}!"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -439,63 +514,77 @@ class TestRand:
pytest.param("$(rand 1 1)", "1", id="extra-whitespace"), pytest.param("$(rand 1 1)", "1", id="extra-whitespace"),
], ],
) )
async def test_rand_deterministic(self, template, expected): async def test_rand_deterministic(
self, placeholder_storage: ModuleStorage, template: str, expected: str
) -> None:
"""$(rand) with fixed bounds produces a deterministic result.""" """$(rand) with fixed bounds produces a deterministic result."""
assert await process(template) == expected assert await process(template, placeholder_storage) == expected
async def test_rand_reversed_range(self): async def test_rand_reversed_range(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand 10 1) auto-sorts bounds and produces a value in range.""" """$(rand 10 1) auto-sorts bounds and produces a value in range."""
random.seed(0) random.seed(0)
expected = random.randint(1, 10) expected = random.randint(1, 10)
random.seed(0) random.seed(0)
result = await process("$(rand 10 1)") result = await process("$(rand 10 1)", placeholder_storage)
assert result == str(expected) assert result == str(expected)
async def test_both_negative(self): async def test_both_negative(self, placeholder_storage: ModuleStorage) -> None:
"""$(rand) works with both bounds negative.""" """$(rand) works with both bounds negative."""
random.seed(0) random.seed(0)
expected = random.randint(-10, -1) expected = random.randint(-10, -1)
random.seed(0) random.seed(0)
result = await process("$(rand -10 -1)") result = await process("$(rand -10 -1)", placeholder_storage)
assert result == str(expected) assert result == str(expected)
async def test_negative_to_positive(self): async def test_negative_to_positive(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand) works with a range crossing zero.""" """$(rand) works with a range crossing zero."""
random.seed(0) random.seed(0)
expected = random.randint(-5, 5) expected = random.randint(-5, 5)
random.seed(0) random.seed(0)
result = await process("$(rand -5 5)") result = await process("$(rand -5 5)", placeholder_storage)
assert result == str(expected) assert result == str(expected)
async def test_zero_crossing_reversed(self): async def test_zero_crossing_reversed(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand 5 -5) auto-sorts bounds to produce a valid range.""" """$(rand 5 -5) auto-sorts bounds to produce a valid range."""
random.seed(0) random.seed(0)
expected = random.randint(-5, 5) expected = random.randint(-5, 5)
random.seed(0) random.seed(0)
result = await process("$(rand 5 -5)") result = await process("$(rand 5 -5)", placeholder_storage)
assert result == str(expected) assert result == str(expected)
class TestCountdownCountup: class TestCountdownCountup:
"""Date/time placeholders and duration formatting.""" """Date/time placeholders and duration formatting."""
async def test_countdown_future_date(self): async def test_countdown_future_date(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(countdown) with a future date returns the formatted duration.""" """$(countdown) with a future date returns the formatted duration."""
with freeze_time("2026-06-15 12:00:00", tz_offset=0): with freeze_time("2026-06-15 12:00:00", tz_offset=0):
now = datetime.now(UTC) now = datetime.now(UTC)
# Dec 25 2099 00:00:00 EST = Dec 25 2099 05:00:00 UTC # Dec 25 2099 00:00:00 EST = Dec 25 2099 05:00:00 UTC
target = datetime(2099, 12, 25, 5, 0, 0, tzinfo=UTC) target = datetime(2099, 12, 25, 5, 0, 0, tzinfo=UTC)
expected = _format_duration(int((target - now).total_seconds())) expected = _format_duration(int((target - now).total_seconds()))
result = await process("$(countdown Dec 25 2099 12:00:00 AM EST)") result = await process(
"$(countdown Dec 25 2099 12:00:00 AM EST)", placeholder_storage
)
assert result == expected assert result == expected
async def test_countup_past_date(self): async def test_countup_past_date(self, placeholder_storage: ModuleStorage) -> None:
"""$(countup) with a past date returns the formatted elapsed duration.""" """$(countup) with a past date returns the formatted elapsed duration."""
with freeze_time("2026-06-15 12:00:00", tz_offset=0): with freeze_time("2026-06-15 12:00:00", tz_offset=0):
now = datetime.now(UTC) now = datetime.now(UTC)
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC) target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
expected = _format_duration(int((now - target).total_seconds())) expected = _format_duration(int((now - target).total_seconds()))
result = await process("$(countup Jan 1 2020 12:00:00 AM UTC)") result = await process(
"$(countup Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
)
assert result == expected assert result == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -508,9 +597,11 @@ class TestCountdownCountup:
pytest.param("$(countdown Jan 1 2000 12:00:00 AM utc)", id="lowercase-tz"), pytest.param("$(countdown Jan 1 2000 12:00:00 AM utc)", id="lowercase-tz"),
], ],
) )
async def test_zero_seconds(self, template): async def test_zero_seconds(
self, placeholder_storage: ModuleStorage, template: str
) -> None:
"""Elapsed/remaining time of zero returns '0 seconds'.""" """Elapsed/remaining time of zero returns '0 seconds'."""
assert await process(template) == "0 seconds" assert await process(template, placeholder_storage) == "0 seconds"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"placeholder,bad_date", "placeholder,bad_date",
@@ -522,22 +613,28 @@ class TestCountdownCountup:
pytest.param("countdown", "not a valid date UTC", id="gibberish-with-tz"), pytest.param("countdown", "not a valid date UTC", id="gibberish-with-tz"),
], ],
) )
async def test_invalid_date(self, placeholder, bad_date): async def test_invalid_date(
self, placeholder_storage: ModuleStorage, placeholder: str, bad_date: str
) -> None:
"""Placeholders with an unparseable date report an error.""" """Placeholders with an unparseable date report an error."""
result = await process(f"$({placeholder} {bad_date})") result = await process(f"$({placeholder} {bad_date})", placeholder_storage)
assert result == ( assert result == (
f"Invalid $({placeholder}): unrecognized date format, " f"Invalid $({placeholder}): unrecognized date format, "
f"expected $({placeholder} Dec 25 2025 12:00:00 AM EST)" f"expected $({placeholder} Dec 25 2025 12:00:00 AM EST)"
) )
async def test_fractional_timezone_offset(self): async def test_fractional_timezone_offset(
self, placeholder_storage: ModuleStorage
) -> None:
"""A fractional timezone offset like IST (UTC+5:30) is applied correctly.""" """A fractional timezone offset like IST (UTC+5:30) is applied correctly."""
with freeze_time("2026-06-15 12:00:00", tz_offset=0): with freeze_time("2026-06-15 12:00:00", tz_offset=0):
now = datetime.now(UTC) now = datetime.now(UTC)
# Jan 1 2020 12:00:00 AM IST = Dec 31 2019 18:30:00 UTC # Jan 1 2020 12:00:00 AM IST = Dec 31 2019 18:30:00 UTC
target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC) target = datetime(2019, 12, 31, 18, 30, 0, tzinfo=UTC)
expected = _format_duration(int((now - target).total_seconds())) expected = _format_duration(int((now - target).total_seconds()))
result = await process("$(countup Jan 1 2020 12:00:00 AM IST)") result = await process(
"$(countup Jan 1 2020 12:00:00 AM IST)", placeholder_storage
)
assert result == expected assert result == expected
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -563,7 +660,7 @@ class TestCountdownCountup:
), ),
], ],
) )
async def test_format_duration(self, seconds, expected): async def test_format_duration(self, seconds: int, expected: str) -> None:
"""_format_duration produces the correct human-readable string.""" """_format_duration produces the correct human-readable string."""
assert _format_duration(seconds) == expected assert _format_duration(seconds) == expected
@@ -571,67 +668,84 @@ class TestCountdownCountup:
class TestNesting: class TestNesting:
"""Nested placeholder resolution and child whitespace behavior.""" """Nested placeholder resolution and child whitespace behavior."""
async def test_rand_with_arg_placeholders(self): async def test_rand_with_arg_placeholders(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand $(1) $(2)) resolves inner arguments before calling rand.""" """$(rand $(1) $(2)) resolves inner arguments before calling rand."""
random.seed(0) random.seed(0)
expected = random.randint(5, 10) expected = random.randint(5, 10)
random.seed(0) random.seed(0)
result = await process("$(rand $(1) $(2))", args=["5", "10"]) result = await process(
"$(rand $(1) $(2))", placeholder_storage, args=["5", "10"]
)
assert result == str(expected) assert result == str(expected)
async def test_multi_level_nesting(self): async def test_multi_level_nesting(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand 1 $(rand 5 10)) resolves the inner rand first.""" """$(rand 1 $(rand 5 10)) resolves the inner rand first."""
random.seed(0) random.seed(0)
inner = random.randint(5, 10) inner = random.randint(5, 10)
outer = random.randint(1, inner) outer = random.randint(1, inner)
random.seed(0) random.seed(0)
result = await process("$(rand 1 $(rand 5 10))") result = await process("$(rand 1 $(rand 5 10))", placeholder_storage)
assert result == str(outer) assert result == str(outer)
async def test_nested_in_sentence(self): async def test_nested_in_sentence(self, placeholder_storage: ModuleStorage) -> None:
"""Nested placeholders work when surrounded by literal text.""" """Nested placeholders work when surrounded by literal text."""
random.seed(0) random.seed(0)
expected_roll = random.randint(1, 20) expected_roll = random.randint(1, 20)
random.seed(0) random.seed(0)
result = await process( result = await process(
"$(user) rolled $(rand $(1) $(2))!", "$(user) rolled $(rand $(1) $(2))!",
placeholder_storage,
args=["1", "20"], args=["1", "20"],
) )
assert result == f"Alice rolled {expected_roll}!" assert result == f"Alice rolled {expected_roll}!"
async def test_nested_getcount_as_rand_bound(self): async def test_nested_getcount_as_rand_bound(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(getcount) result feeds into $(rand) as a bound.""" """$(getcount) result feeds into $(rand) as a bound."""
storage = MockStorage({"max": 50}) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("max", 50)
)
random.seed(0) random.seed(0)
expected = random.randint(1, 50) expected = random.randint(1, 50)
random.seed(0) random.seed(0)
result = await process("$(rand 1 $(getcount max))", storage=storage) result = await process("$(rand 1 $(getcount max))", placeholder_storage)
assert result == str(expected) assert result == str(expected)
async def test_nested_count_as_rand_bound(self): async def test_nested_count_as_rand_bound(
self, placeholder_storage: ModuleStorage
) -> None:
"""A counter result feeds into rand as an argument.""" """A counter result feeds into rand as an argument."""
storage = MockStorage({"upper": 9}) await placeholder_storage.execute(
"INSERT INTO counters (name, value) VALUES (?, ?)", ("upper", 9)
)
# $(count upper) increments 9 -> 10. # $(count upper) increments 9 -> 10.
random.seed(0) random.seed(0)
expected = random.randint(1, 10) expected = random.randint(1, 10)
random.seed(0) random.seed(0)
result = await process( result = await process(
"$(rand 1 $(count upper))", "$(rand 1 $(count upper))",
storage=storage, placeholder_storage,
) )
assert result == str(expected) assert result == str(expected)
async def test_three_levels_deep(self): async def test_three_levels_deep(self, placeholder_storage: ModuleStorage) -> None:
"""$(rand 1 $(rand 1 $(rand 5 10))) resolves inside-out.""" """$(rand 1 $(rand 1 $(rand 5 10))) resolves inside-out."""
random.seed(0) random.seed(0)
innermost = random.randint(5, 10) innermost = random.randint(5, 10)
middle = random.randint(1, innermost) middle = random.randint(1, innermost)
outer = random.randint(1, middle) outer = random.randint(1, middle)
random.seed(0) random.seed(0)
result = await process("$(rand 1 $(rand 1 $(rand 5 10)))") result = await process("$(rand 1 $(rand 1 $(rand 5 10)))", placeholder_storage)
assert result == str(outer) assert result == str(outer)
async def test_multiple_nested_placeholders_in_one_template(self): async def test_multiple_nested_placeholders_in_one_template(
self, placeholder_storage: ModuleStorage
) -> None:
"""Two separate nested expressions in one template both resolve.""" """Two separate nested expressions in one template both resolve."""
random.seed(0) random.seed(0)
low = random.randint(1, 5) low = random.randint(1, 5)
@@ -639,18 +753,23 @@ class TestNesting:
random.seed(0) random.seed(0)
result = await process( result = await process(
"Low: $(rand $(1) $(2)) High: $(rand $(3) $(4))", "Low: $(rand $(1) $(2)) High: $(rand $(3) $(4))",
placeholder_storage,
args=["1", "5", "50", "100"], args=["1", "5", "50", "100"],
) )
assert result == f"Low: {low} High: {high}" assert result == f"Low: {low} High: {high}"
async def test_nested_user_in_other_placeholder(self): async def test_nested_user_in_other_placeholder(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(user) nested inside an unknown placeholder passes through.""" """$(user) nested inside an unknown placeholder passes through."""
result = await process("$(greet $(user))") result = await process("$(greet $(user))", placeholder_storage)
assert result == "$(greet Alice)" assert result == "$(greet Alice)"
async def test_meta_nesting_degrades(self): async def test_meta_nesting_degrades(
self, placeholder_storage: ModuleStorage
) -> None:
"""$($(1)) is not valid and degrades gracefully.""" """$($(1)) is not valid and degrades gracefully."""
result = await process("$($(1))", args=["test"]) result = await process("$($(1))", placeholder_storage, args=["test"])
assert result == "$(test)" assert result == "$(test)"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -661,16 +780,22 @@ class TestNesting:
pytest.param("$(rand $(1)$(2))", ["5", "10"], id="no-space-between"), pytest.param("$(rand $(1)$(2))", ["5", "10"], id="no-space-between"),
], ],
) )
async def test_nested_too_few_args(self, template, args): async def test_nested_too_few_args(
self, placeholder_storage: ModuleStorage, template: str, args: list[str]
) -> None:
"""Nested $(rand) with insufficient tokens reports too few arguments.""" """Nested $(rand) with insufficient tokens reports too few arguments."""
result = await process(template, args=args) result = await process(template, placeholder_storage, args=args)
assert ( assert (
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
) )
async def test_adjacent_children_with_explicit_space(self): async def test_adjacent_children_with_explicit_space(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(rand $(1) $(2)) with space between children works normally.""" """$(rand $(1) $(2)) with space between children works normally."""
result = await process("$(rand $(1) $(2))", args=["3", "3"]) result = await process(
"$(rand $(1) $(2))", placeholder_storage, args=["3", "3"]
)
assert result == "3" assert result == "3"
@@ -714,9 +839,15 @@ class TestEscaping:
pytest.param("\\$5", {}, "\\$5", id="dollar-number"), pytest.param("\\$5", {}, "\\$5", id="dollar-number"),
], ],
) )
async def test_escaping(self, template, kwargs, expected): async def test_escaping(
self,
placeholder_storage: ModuleStorage,
template: str,
kwargs: dict[str, Any],
expected: str,
) -> None:
"""Escape sequences produce the expected literal text.""" """Escape sequences produce the expected literal text."""
assert await process(template, **kwargs) == expected assert await process(template, placeholder_storage, **kwargs) == expected
class TestCaseInsensitivity: class TestCaseInsensitivity:
@@ -737,41 +868,60 @@ class TestCaseInsensitivity:
), ),
], ],
) )
async def test_placeholder_name_case_ignored(self, template, kwargs, expected): async def test_placeholder_name_case_ignored(
self,
placeholder_storage: ModuleStorage,
template: str,
kwargs: dict[str, Any],
expected: str,
) -> None:
"""Placeholder names are case-insensitive.""" """Placeholder names are case-insensitive."""
assert await process(template, **kwargs) == expected assert await process(template, placeholder_storage, **kwargs) == expected
async def test_getcount_mixed_case(self): async def test_getcount_mixed_case(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(GetCount) resolves the same as $(getcount).""" """$(GetCount) resolves the same as $(getcount)."""
storage = MockStorage({"x": 3}) await placeholder_storage.execute(
result = await process("$(GetCount x)", storage=storage) "INSERT INTO counters (name, value) VALUES (?, ?)", ("x", 3)
)
result = await process("$(GetCount x)", placeholder_storage)
assert result == "3" assert result == "3"
async def test_countup_case_insensitive(self): async def test_countup_case_insensitive(
self, placeholder_storage: ModuleStorage
) -> None:
"""$(COUNTUP) resolves the same as $(countup).""" """$(COUNTUP) resolves the same as $(countup)."""
with freeze_time("2026-06-15 12:00:00", tz_offset=0): with freeze_time("2026-06-15 12:00:00", tz_offset=0):
now = datetime.now(UTC) now = datetime.now(UTC)
target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC) target = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
expected = _format_duration(int((now - target).total_seconds())) expected = _format_duration(int((now - target).total_seconds()))
result = await process("$(COUNTUP Jan 1 2020 12:00:00 AM UTC)") result = await process(
"$(COUNTUP Jan 1 2020 12:00:00 AM UTC)", placeholder_storage
)
assert result == expected assert result == expected
class TestMaxDepth: class TestMaxDepth:
"""Depth limiting behavior for nested placeholders.""" """Depth limiting behavior for nested placeholders."""
async def test_max_depth_limits_nesting(self): async def test_max_depth_limits_nesting(
self, placeholder_storage: ModuleStorage
) -> None:
"""With max_depth=0, all $( are treated as literal text.""" """With max_depth=0, all $( are treated as literal text."""
result = await process("$(user) $(rand 1 6)", max_depth=0) result = await process("$(user) $(rand 1 6)", placeholder_storage, max_depth=0)
assert result == "$(user) $(rand 1 6)" assert result == "$(user) $(rand 1 6)"
async def test_max_depth_one_blocks_inner(self): async def test_max_depth_one_blocks_inner(
self, placeholder_storage: ModuleStorage
) -> None:
"""With max_depth=1, outer placeholder works but inner $( is literal.""" """With max_depth=1, outer placeholder works but inner $( is literal."""
result = await process("$(user)", max_depth=1) result = await process("$(user)", placeholder_storage, max_depth=1)
assert result == "Alice" assert result == "Alice"
# Inner nesting should not resolve. # Inner nesting should not resolve.
result = await process( result = await process(
"$(rand $(1) $(2))", "$(rand $(1) $(2))",
placeholder_storage,
args=["5", "10"], args=["5", "10"],
max_depth=1, max_depth=1,
) )
@@ -780,16 +930,21 @@ class TestMaxDepth:
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
) )
async def test_max_depth_two_allows_one_level(self): async def test_max_depth_two_allows_one_level(
self, placeholder_storage: ModuleStorage
) -> None:
"""max_depth=2: depth-0 outer and depth-1 inner both resolve.""" """max_depth=2: depth-0 outer and depth-1 inner both resolve."""
result = await process( result = await process(
"$(rand $(1) $(2))", "$(rand $(1) $(2))",
placeholder_storage,
args=["3", "3"], args=["3", "3"],
max_depth=2, max_depth=2,
) )
assert result == "3" assert result == "3"
async def test_nesting_at_default_depth_limit(self): async def test_nesting_at_default_depth_limit(
self, placeholder_storage: ModuleStorage
) -> None:
"""Four levels of nesting hits the default max_depth=4 boundary. """Four levels of nesting hits the default max_depth=4 boundary.
Levels: $(rand 1 $(rand 1 $(rand 1 $(rand 5 5)))) Levels: $(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))
@@ -802,16 +957,23 @@ class TestMaxDepth:
d1 = random.randint(1, d2) d1 = random.randint(1, d2)
d0 = random.randint(1, d1) d0 = random.randint(1, d1)
random.seed(0) random.seed(0)
result = await process("$(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))") result = await process(
"$(rand 1 $(rand 1 $(rand 1 $(rand 5 5))))", placeholder_storage
)
assert result == str(d0) assert result == str(d0)
async def test_nesting_beyond_default_depth_limit(self): async def test_nesting_beyond_default_depth_limit(
self, placeholder_storage: ModuleStorage
) -> None:
"""Five levels of nesting exceeds default max_depth=4. """Five levels of nesting exceeds default max_depth=4.
The innermost $( at depth 4 becomes literal text, breaking the The innermost $( at depth 4 becomes literal text, breaking the
outer placeholders. outer placeholders.
""" """
result = await process("$(rand 1 $(rand 1 $(rand 1 $(rand 1 $(rand 5 5)))))") result = await process(
"$(rand 1 $(rand 1 $(rand 1 $(rand 1 $(rand 5 5)))))",
placeholder_storage,
)
# The depth-4 $( is literal, so the depth-3 rand gets malformed args. # The depth-4 $( is literal, so the depth-3 rand gets malformed args.
assert ( assert (
result == "Invalid $(rand): too many arguments, expected $(rand start stop)" result == "Invalid $(rand): too many arguments, expected $(rand start stop)"
@@ -842,9 +1004,11 @@ class TestParserBoundaries:
pytest.param("$(rand$", "$(rand$", id="trailing-dollar-in-name"), pytest.param("$(rand$", "$(rand$", id="trailing-dollar-in-name"),
], ],
) )
async def test_parser_boundary(self, template, expected): async def test_parser_boundary(
self, placeholder_storage: ModuleStorage, template: str, expected: str
) -> None:
"""Parser edge cases degrade to literal text.""" """Parser edge cases degrade to literal text."""
assert await process(template) == expected assert await process(template, placeholder_storage) == expected
class TestUnknownPlaceholders: class TestUnknownPlaceholders:
@@ -865,9 +1029,11 @@ class TestUnknownPlaceholders:
pytest.param("$(café latte)", "$(café latte)", id="unicode-with-args"), pytest.param("$(café latte)", "$(café latte)", id="unicode-with-args"),
], ],
) )
async def test_unknown_placeholder_passthrough(self, template, expected): async def test_unknown_placeholder_passthrough(
self, placeholder_storage: ModuleStorage, template: str, expected: str
) -> None:
"""Unrecognized placeholders pass through unchanged.""" """Unrecognized placeholders pass through unchanged."""
result = await process(template) result = await process(template, placeholder_storage)
assert result == expected assert result == expected
@@ -894,17 +1060,28 @@ class TestEvaluationSafety:
), ),
], ],
) )
async def test_resolved_text_not_reparsed(self, template, kwargs, expected): async def test_resolved_text_not_reparsed(
self,
placeholder_storage: ModuleStorage,
template: str,
kwargs: dict[str, Any],
expected: str,
) -> None:
"""Resolved text containing placeholder syntax is not re-evaluated.""" """Resolved text containing placeholder syntax is not re-evaluated."""
assert await process(template, **kwargs) == expected assert await process(template, placeholder_storage, **kwargs) == expected
async def test_nested_arg_placeholder_syntax_not_reprocessed(self): async def test_nested_arg_placeholder_syntax_not_reprocessed(
self, placeholder_storage: ModuleStorage
) -> None:
"""An inner result containing $() syntax is not reprocessed.""" """An inner result containing $() syntax is not reprocessed."""
result = await process("$(rand $(1) $(2))", args=["5", "5"]) result = await process(
"$(rand $(1) $(2))", placeholder_storage, args=["5", "5"]
)
assert result == "5" assert result == "5"
# Now with args that contain placeholder syntax. # Now with args that contain placeholder syntax.
result = await process( result = await process(
"Say: $(1)", "Say: $(1)",
placeholder_storage,
args=["$(count) times"], args=["$(count) times"],
) )
assert result == "Say: $(count) times" assert result == "Say: $(count) times"
@@ -981,26 +1158,34 @@ class TestRuntimeErrors:
), ),
], ],
) )
async def test_error_message(self, template, expected_error): async def test_error_message(
self, placeholder_storage: ModuleStorage, template: str, expected_error: str
) -> None:
"""Placeholder errors produce the correct diagnostic message.""" """Placeholder errors produce the correct diagnostic message."""
assert await process(template) == expected_error assert await process(template, placeholder_storage) == expected_error
async def test_error_cancels_entire_render(self): async def test_error_cancels_entire_render(
self, placeholder_storage: ModuleStorage
) -> None:
"""An error in any placeholder cancels the whole template.""" """An error in any placeholder cancels the whole template."""
result = await process("$(user) says $(rand 1)") result = await process("$(user) says $(rand 1)", placeholder_storage)
assert ( assert (
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
) )
async def test_first_error_wins(self): async def test_first_error_wins(self, placeholder_storage: ModuleStorage) -> None:
"""The first PlaceholderError encountered is returned.""" """The first PlaceholderError encountered is returned."""
result = await process("$(rand 1) $(getcount) $(user extra)") result = await process(
"$(rand 1) $(getcount) $(user extra)", placeholder_storage
)
# Evaluation is left-to-right; $(rand 1) fails first. # Evaluation is left-to-right; $(rand 1) fails first.
assert ( assert (
result == "Invalid $(rand): too few arguments, expected $(rand start stop)" result == "Invalid $(rand): too few arguments, expected $(rand start stop)"
) )
async def test_nested_inner_error_cancels_render(self): async def test_nested_inner_error_cancels_render(
self, placeholder_storage: ModuleStorage
) -> None:
"""An error in a nested placeholder cancels the whole render.""" """An error in a nested placeholder cancels the whole render."""
result = await process("$(rand $(getcount) $(2))") result = await process("$(rand $(getcount) $(2))", placeholder_storage)
assert result == "Invalid $(getcount): a counter name is required" assert result == "Invalid $(getcount): a counter name is required"
+420
View File
@@ -0,0 +1,420 @@
# 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 ModuleStorage — the async SQLite storage layer."""
import asyncio
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
from owlbot.api.storage import ModuleStorage, StorageError
CREATE_TABLE = "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, value INTEGER)"
@pytest.fixture
async def storage_with_table(storage: ModuleStorage) -> ModuleStorage:
"""Storage instance with a pre-created items table."""
await storage.execute(CREATE_TABLE)
return storage
class TestLazyConnections:
"""No connections exist until the first operation."""
async def test_no_connections_before_use(self, storage: ModuleStorage) -> None:
"""Pool is empty immediately after construction."""
assert len(storage._all_connections) == 0
async def test_first_operation_creates_one_connection(
self, storage: ModuleStorage
) -> None:
"""The first execute creates exactly one pooled connection."""
await storage.execute("SELECT 1")
assert len(storage._all_connections) == 1
class TestPragmas:
"""WAL mode and foreign keys are enabled on new connections."""
async def test_wal_mode(self, storage: ModuleStorage) -> None:
"""journal_mode is set to WAL."""
value = await storage.fetch_value("PRAGMA journal_mode")
assert value == "wal"
async def test_foreign_keys_enabled(self, storage: ModuleStorage) -> None:
"""foreign_keys pragma is enabled."""
value = await storage.fetch_value("PRAGMA foreign_keys")
assert value == 1
class TestExecute:
"""execute() runs SQL statements and returns a cursor."""
async def test_ddl_creates_table(self, storage: ModuleStorage) -> None:
"""CREATE TABLE produces a table that can be queried."""
await storage.execute(CREATE_TABLE)
rows = await storage.fetch_all(
"SELECT name FROM sqlite_master WHERE type='table' AND name='items'"
)
assert len(rows) == 1
async def test_insert_returns_lastrowid(
self, storage_with_table: ModuleStorage
) -> None:
"""INSERT returns a cursor whose lastrowid is set."""
cursor = await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
assert cursor.lastrowid == 1
async def test_update_returns_rowcount(
self, storage_with_table: ModuleStorage
) -> None:
"""UPDATE returns a cursor whose rowcount reflects affected rows."""
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("b", 2)
)
cursor = await storage_with_table.execute(
"UPDATE items SET value = 0 WHERE value > 0"
)
assert cursor.rowcount == 2
class TestExecuteMany:
"""execute_many() batch-inserts multiple rows."""
async def test_batch_insert(self, storage_with_table: ModuleStorage) -> None:
"""Batch insert adds all rows."""
rows_data = [("a", 1), ("b", 2), ("c", 3)]
await storage_with_table.execute_many(
"INSERT INTO items (name, value) VALUES (?, ?)", rows_data
)
rows = await storage_with_table.fetch_all("SELECT * FROM items")
assert len(rows) == 3
class TestFetchOne:
"""fetch_one() returns a Row or None."""
@pytest.mark.parametrize(
"accessor, expected",
[
pytest.param(lambda row: row["name"], "a", id="key-access"),
pytest.param(lambda row: row[1], "a", id="index-access"),
],
)
async def test_row_access(
self,
storage_with_table: ModuleStorage,
accessor: Callable[[Any], Any],
expected: str,
) -> None:
"""Returned row supports both key and index access."""
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
row = await storage_with_table.fetch_one("SELECT * FROM items WHERE id = 1")
assert accessor(row) == expected
async def test_returns_none_when_empty(
self, storage_with_table: ModuleStorage
) -> None:
"""Returns None when no rows match."""
row = await storage_with_table.fetch_one("SELECT * FROM items WHERE id = 999")
assert row is None
class TestFetchAll:
"""fetch_all() returns a list of Rows or an empty list."""
async def test_returns_all_rows(self, storage_with_table: ModuleStorage) -> None:
"""Returns all matching rows."""
await storage_with_table.execute_many(
"INSERT INTO items (name, value) VALUES (?, ?)",
[("a", 1), ("b", 2)],
)
rows = await storage_with_table.fetch_all("SELECT * FROM items ORDER BY id")
assert len(rows) == 2
assert rows[0]["name"] == "a"
assert rows[1]["name"] == "b"
async def test_returns_empty_list(self, storage_with_table: ModuleStorage) -> None:
"""Returns an empty list when no rows match."""
rows = await storage_with_table.fetch_all("SELECT * FROM items WHERE id = 999")
assert rows == []
class TestFetchValue:
"""fetch_value() returns a scalar or None."""
async def test_returns_scalar(self, storage_with_table: ModuleStorage) -> None:
"""Returns the first column of the first row."""
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 42)
)
value = await storage_with_table.fetch_value(
"SELECT value FROM items WHERE name = ?", ("a",)
)
assert value == 42
async def test_returns_none_when_empty(
self, storage_with_table: ModuleStorage
) -> None:
"""Returns None when no rows match."""
value = await storage_with_table.fetch_value(
"SELECT value FROM items WHERE id = 999"
)
assert value is None
class TestAutoCommit:
"""Outside _checkout(), each operation auto-commits or auto-rolls-back."""
async def test_auto_commits_on_success(
self, storage_with_table: ModuleStorage
) -> None:
"""A successful write is visible from a second storage instance."""
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
# Open a second storage pointing at the same file to verify commit.
async with ModuleStorage(
storage_with_table._db_path.parent, "test_module"
) as s2:
row = await s2.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is not None
assert row["name"] == "a"
async def test_auto_rollback_on_error(
self, storage_with_table: ModuleStorage
) -> None:
"""A failed write does not persist partial changes."""
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
with pytest.raises(StorageError):
# Duplicate primary key triggers an error.
await storage_with_table.execute(
"INSERT INTO items (id, name, value) VALUES (?, ?, ?)",
(1, "b", 2),
)
# Original row should still be intact.
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is not None
assert row["name"] == "a"
class TestCheckout:
"""_checkout() pins a single connection for all operations."""
async def test_commit_persists(self, storage_with_table: ModuleStorage) -> None:
"""Writes inside _checkout() become visible after _commit()."""
async with storage_with_table._checkout():
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
await storage_with_table._commit()
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is not None
assert row["name"] == "a"
async def test_rollback_discards(self, storage_with_table: ModuleStorage) -> None:
"""Writes inside _checkout() are discarded after _rollback()."""
async with storage_with_table._checkout():
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
await storage_with_table._rollback()
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is None
async def test_uncommitted_writes_invisible_from_other_connection(
self, storage_with_table: ModuleStorage
) -> None:
"""Uncommitted writes are not visible from a separate connection."""
async with (
ModuleStorage(storage_with_table._db_path.parent, "test_module") as s2,
storage_with_table._checkout(),
):
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
# Before commit, s2 should not see the row.
row = await s2.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is None
class TestTransaction:
"""transaction() context manager commits on clean exit, rolls back on exception."""
async def test_commits_on_clean_exit(
self, storage_with_table: ModuleStorage
) -> None:
"""Writes inside transaction() persist after clean exit."""
async with storage_with_table._checkout(), storage_with_table.transaction():
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)", ("a", 1)
)
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is not None
assert row["name"] == "a"
async def test_rolls_back_on_exception(
self, storage_with_table: ModuleStorage
) -> None:
"""Writes inside transaction() are discarded if an exception is raised."""
with pytest.raises(RuntimeError, match="boom"):
async with storage_with_table._checkout(), storage_with_table.transaction():
await storage_with_table.execute(
"INSERT INTO items (name, value) VALUES (?, ?)",
("a", 1),
)
raise RuntimeError("boom")
row = await storage_with_table.fetch_one("SELECT name FROM items WHERE id = 1")
assert row is None
class TestConnectionPool:
"""Connection pool reuses connections and respects pool_size."""
async def test_connections_are_reused(self, storage: ModuleStorage) -> None:
"""After release, the same connection is reused."""
await storage.execute("SELECT 1")
assert len(storage._all_connections) == 1
await storage.execute("SELECT 2")
assert len(storage._all_connections) == 1
async def test_pool_grows_up_to_pool_size(self, tmp_path: Path) -> None:
"""Concurrent checkouts grow the pool up to pool_size."""
async with (
ModuleStorage(tmp_path, "pool_test", pool_size=2) as s,
s._checkout(),
s._checkout(),
):
assert len(s._all_connections) == 2
async def test_pool_exhaustion_blocks(self, tmp_path: Path) -> None:
"""When the pool is exhausted, acquire blocks until a connection is released."""
async with (
ModuleStorage(tmp_path, "pool_block", pool_size=1) as s,
s._checkout(),
):
# Pool is exhausted (pool_size=1, one checked out).
# A second acquire should block, so wait_for should time out.
with pytest.raises(TimeoutError):
await asyncio.wait_for(s._acquire(), timeout=0.1)
class TestStorageError:
"""Invalid SQL raises StorageError wrapping the underlying error."""
@pytest.mark.parametrize(
"method, args",
[
pytest.param(
"execute",
("INVALID SQL",),
id="execute",
),
pytest.param(
"fetch_one",
("INVALID SQL",),
id="fetch-one",
),
pytest.param(
"fetch_all",
("INVALID SQL",),
id="fetch-all",
),
pytest.param(
"fetch_value",
("INVALID SQL",),
id="fetch-value",
),
pytest.param(
"execute_many",
("INVALID SQL", [()]),
id="execute-many",
),
],
)
async def test_invalid_sql_raises_storage_error(
self, storage: ModuleStorage, method: str, args: tuple[Any, ...]
) -> None:
"""Invalid SQL raises StorageError for all query methods."""
with pytest.raises(StorageError):
await getattr(storage, method)(*args)
@pytest.mark.parametrize(
"method, args",
[
pytest.param("execute", ("SELECT 1",), id="execute"),
pytest.param("fetch_one", ("SELECT 1",), id="fetch-one"),
pytest.param("fetch_all", ("SELECT 1",), id="fetch-all"),
pytest.param("fetch_value", ("SELECT 1",), id="fetch-value"),
pytest.param("execute_many", ("SELECT 1", [()]), id="execute-many"),
],
)
async def test_closed_storage_raises_storage_error(
self, storage: ModuleStorage, method: str, args: tuple[Any, ...]
) -> None:
"""Operations on closed storage raise StorageError."""
await storage._close()
with pytest.raises(StorageError, match="Storage is closed"):
await getattr(storage, method)(*args)
async def test_storage_error_wraps_cause(self, storage: ModuleStorage) -> None:
"""StorageError.__cause__ is the underlying aiosqlite error."""
with pytest.raises(StorageError) as exc_info:
await storage.execute("INVALID SQL")
assert exc_info.value.__cause__ is not None
class TestClose:
"""_close() drains the pool and marks storage closed."""
async def test_close_drains_pool(self, storage: ModuleStorage) -> None:
"""After close, all connections are removed."""
await storage.execute("SELECT 1")
assert len(storage._all_connections) == 1
await storage._close()
assert len(storage._all_connections) == 0
assert storage._closed is True
async def test_double_close_is_safe(self, storage: ModuleStorage) -> None:
"""Calling _close() twice does not raise."""
await storage.execute("SELECT 1")
await storage._close()
await storage._close()
async def test_context_manager_closes_on_exit(self, tmp_path: Path) -> None:
"""Exiting the async with block closes all connections."""
async with ModuleStorage(tmp_path, "ctx_test") as s:
await s.execute("SELECT 1")
assert len(s._all_connections) == 1
assert len(s._all_connections) == 0
assert s._closed is True