Expanded integration coverage and enforced test categories.
Audit / Dependencies (push) Successful in 9s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (push) Successful in 1m7s
CI / Type Checking (push) Successful in 12s
CI / Spelling (push) Successful in 8s

This commit is contained in:
2026-06-18 16:10:40 -04:00
parent 49062159f9
commit 5bbb0bbde5
22 changed files with 2433 additions and 550 deletions
+15
View File
@@ -0,0 +1,15 @@
# 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.
"""Database integration tests for Crabstero."""
+35
View File
@@ -0,0 +1,35 @@
# 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.
"""Fixtures used only by database-bound integration tests."""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import Database
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
@pytest.fixture
async def db(tmp_path: Path) -> AsyncGenerator[Database]:
"""Yield an isolated file-backed database for SQLite integration tests."""
database = await Database.connect(str(tmp_path / "crabstero.db"))
try:
yield database
finally:
await database.close()
+645
View File
@@ -0,0 +1,645 @@
# 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.
"""Integration tests for the Database class.
Tests cover Database.connect (pragmas, schema), markov start word and
transition CRUD, image storage, flag CRUD, and channel ingestion tracking.
"""
from typing import TYPE_CHECKING
import pytest
from crabstero.database import ChannelImage, Database, StartWord, Transition
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING:
from pathlib import Path
class TestConnect:
"""Database.connect creates a configured SQLite database."""
async def test_synchronous_normal(self, db: Database) -> None:
"""Synchronous mode is set to NORMAL."""
async with db._connection.execute("PRAGMA synchronous") as cursor:
row = await cursor.fetchone()
assert row is not None
assert row[0] == 1
async def test_schema_creates_tables(self, db: Database) -> None:
"""All expected tables exist after connect."""
async with db._connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name",
) as cursor:
tables = [row[0] for row in await cursor.fetchall()]
assert tables == [
"channel_images",
"flags",
"ingested_channels",
"markov_start_words",
"markov_transitions",
]
class TestAddMarkovData:
"""Markov start word and transition storage via add_markov_data."""
async def test_stores_start_word(self, db: Database) -> None:
"""Inserted start word can be retrieved by channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
result = await db.get_random_start_word(1)
assert result == "Hello"
async def test_stores_transition(self, db: Database) -> None:
"""Inserted transition can be retrieved by channel and word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
result = await db.get_random_next_word(1, "Hello")
assert result == "world."
async def test_stores_both_in_single_call(self, db: Database) -> None:
"""Start words and transitions are stored in a single call."""
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_empty_lists_is_noop(self, db: Database) -> None:
"""Empty lists do not store any data."""
await db.add_markov_data([], [])
assert await db.get_random_start_word(1) is None
class TestMarkovReadMethods:
"""Markov read methods return None for out-of-scope or missing data."""
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None when no data has been stored."""
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "nonexistent") is None
assert await db.get_random_completing_next_word(1, "nonexistent") is None
async def test_start_word_scoped_to_channel(self, db: Database) -> None:
"""A start word in one channel is not returned for another channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(2) is None
async def test_next_word_scoped_to_channel(self, db: Database) -> None:
"""A transition in one channel is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(2, "Hello") is None
async def test_completing_next_word_scoped_to_channel(self, db: Database) -> None:
"""Completing transition is not returned for another channel."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(2, "Hello") is None
async def test_next_word_scoped_to_word(self, db: Database) -> None:
"""A transition for one word is not returned when querying a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Goodbye") is None
async def test_completing_next_word_scoped_to_word(self, db: Database) -> None:
"""Completing transition is not returned for a different word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_completing_next_word(1, "Goodbye") is None
@pytest.mark.parametrize(
"completing_word",
[
pytest.param("world.", id="period"),
pytest.param("world!", id="exclamation"),
pytest.param("world?", id="question"),
pytest.param("world\u00a7", id="section-sign"),
],
)
async def test_completing_word_filters_punctuation(
self,
db: Database,
completing_word: str,
) -> None:
"""get_random_completing_next_word only returns sentence-ending words."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "beautiful"),
Transition(1, 100, "Hello", completing_word),
],
)
for _ in range(100):
result = await db.get_random_completing_next_word(1, "Hello")
assert result == completing_word
async def test_completing_returns_none_without_match(self, db: Database) -> None:
"""Returns None when no transitions end with sentence punctuation."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "beautiful")])
result = await db.get_random_completing_next_word(1, "Hello")
assert result is None
async def test_start_word_pooled_across_users(self, db: Database) -> None:
"""Start words from different users are visible in the same channel query."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Goodbye")],
[],
)
assert await db.get_random_start_word(1) in {"Hello", "Goodbye"}
async def test_next_word_pooled_across_users(self, db: Database) -> None:
"""Transitions from different users are visible in the same channel query."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_next_word(1, "Hello") in {"world.", "friend."}
async def test_completing_next_word_pooled_across_users(self, db: Database) -> None:
"""Completing transitions from different users are visible."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "friend."),
],
)
assert await db.get_random_completing_next_word(1, "Hello") in {
"world.",
"friend.",
}
class TestRemoveMarkovData:
"""Markov data removal via remove_markov_data."""
async def test_removes_one_start_word(self, db: Database) -> None:
"""Removes exactly one matching start word row."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
# One copy should remain.
assert await db.get_random_start_word(1) == "Hello"
async def test_removes_one_transition(self, db: Database) -> None:
"""Removes exactly one matching transition row."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_removes_last_start_word(self, db: Database) -> None:
"""Removing the only start word leaves the table empty for that channel."""
await db.add_markov_data([StartWord(1, 100, "Hello")], [])
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
async def test_removes_last_transition(self, db: Database) -> None:
"""Removing the only transition leaves no next word."""
await db.add_markov_data([], [Transition(1, 100, "Hello", "world.")])
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent row does not raise."""
await db.remove_markov_data(
[StartWord(1, 100, "nope")],
[Transition(1, 100, "nope", "nah")],
)
async def test_start_word_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a start word in one channel leaves another channel intact."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(2, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) == "Hello"
async def test_transition_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing a transition in one channel leaves another channel intact."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(2, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(2, "Hello") == "world."
async def test_start_word_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a start word for one user leaves another user."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 200, "Hello")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "Hello"
async def test_transition_removal_scoped_to_user(self, db: Database) -> None:
"""Removing a transition for one user leaves another user."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 200, "Hello", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_start_word_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one start word leaves a different start word."""
await db.add_markov_data(
[StartWord(1, 100, "Hello"), StartWord(1, 100, "World")],
[],
)
await db.remove_markov_data([StartWord(1, 100, "Hello")], [])
assert await db.get_random_start_word(1) == "World"
async def test_transition_removal_scoped_to_word(self, db: Database) -> None:
"""Removing one word's transition leaves another word's."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Goodbye", "world."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(1, "Goodbye") == "world."
async def test_transition_removal_scoped_to_next_word(self, db: Database) -> None:
"""Removing one next_word leaves a different next_word."""
await db.add_markov_data(
[],
[
Transition(1, 100, "Hello", "world."),
Transition(1, 100, "Hello", "friend."),
],
)
await db.remove_markov_data([], [Transition(1, 100, "Hello", "world.")])
assert await db.get_random_next_word(1, "Hello") == "friend."
async def test_empty_lists_is_noop(self, db: Database) -> None:
"""Empty lists do not error."""
await db.remove_markov_data([], [])
class TestImages:
"""Image URL storage and random retrieval."""
async def test_add_and_retrieve(self, db: Database) -> None:
"""Inserted image URL can be retrieved by channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
result = await db.get_random_image(1)
assert result == "https://example.com/cat.png"
async def test_returns_none_when_empty(self, db: Database) -> None:
"""Returns None for a channel with no images."""
result = await db.get_random_image(999)
assert result is None
async def test_image_scoped_to_channel(self, db: Database) -> None:
"""An image in one channel is not returned for another channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
assert await db.get_random_image(2) is None
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not store any data."""
await db.add_images([])
assert await db.get_random_image(1) is None
async def test_image_pooled_across_users(self, db: Database) -> None:
"""Images from different users are visible in the same channel query."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/b.png"),
],
)
assert await db.get_random_image(1) in {
"https://example.com/a.png",
"https://example.com/b.png",
}
class TestRemoveImages:
"""Image removal via remove_images."""
async def test_removes_one_image(self, db: Database) -> None:
"""Removes exactly one matching image row."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
# One copy should remain.
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_removes_last_image(self, db: Database) -> None:
"""Removing the only image leaves none for that channel."""
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
async def test_image_removal_scoped_to_channel(self, db: Database) -> None:
"""Removing an image in one channel leaves another channel intact."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) == "https://example.com/a.png"
async def test_image_removal_scoped_to_user(self, db: Database) -> None:
"""Removing an image for one user leaves another user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 200, "https://example.com/a.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/a.png"
async def test_image_removal_scoped_to_url(self, db: Database) -> None:
"""Removing one URL leaves a different URL for the same user."""
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(1, 100, "https://example.com/b.png"),
],
)
await db.remove_images([ChannelImage(1, 100, "https://example.com/a.png")])
assert await db.get_random_image(1) == "https://example.com/b.png"
async def test_no_match_is_noop(self, db: Database) -> None:
"""Removing a non-existent image does not raise."""
await db.remove_images([ChannelImage(1, 100, "https://example.com/nope.png")])
async def test_empty_list_is_noop(self, db: Database) -> None:
"""Empty list does not error."""
await db.remove_images([])
class TestFlags:
"""Flag CRUD operations on entities."""
async def test_set_and_check(self, db: Database) -> None:
"""A set flag is reported as set."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_unset_flag_is_false(self, db: Database) -> None:
"""An unset flag is reported as not set."""
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_clear_flag(self, db: Database) -> None:
"""A cleared flag is no longer reported as set."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is False
async def test_set_idempotent(self, db: Database) -> None:
"""Setting the same flag twice does not raise."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
async def test_scoped_to_entity_id(self, db: Database) -> None:
"""A flag set on one entity is not visible on another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is False
async def test_scoped_to_entity_type(self, db: Database) -> None:
"""A flag set on one entity type is not visible on another."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is False
async def test_scoped_to_flag_name(self, db: Database) -> None:
"""A flag set under one name is not visible under a different name."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is False
async def test_clear_scoped_to_flag_name(self, db: Database) -> None:
"""Clearing one flag leaves other flags on the same entity intact."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_INGEST) is True
async def test_clear_scoped_to_entity_id(self, db: Database) -> None:
"""Clearing a flag on one entity leaves another entity."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.CHANNEL, "456", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.CHANNEL, "456", Flag.NO_REPLY) is True
async def test_clear_scoped_to_entity_type(self, db: Database) -> None:
"""Clearing a flag on one entity type leaves another type."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
assert await db.is_flag_set(EntityType.USER, "123", Flag.NO_REPLY) is True
async def test_clear_unset_flag_is_noop(self, db: Database) -> None:
"""Clearing a flag that was never set does not raise or affect other flags."""
await db.set_flag(EntityType.CHANNEL, "123", Flag.NO_REPLY)
await db.clear_flag(EntityType.CHANNEL, "123", Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "123", Flag.NO_REPLY) is True
class TestChannelIngestion:
"""Channel ingestion tracking."""
async def test_mark_and_check(self, db: Database) -> None:
"""A marked channel is reported as ingested."""
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(42) is True
async def test_not_ingested_by_default(self, db: Database) -> None:
"""Unmarked channels are not reported as ingested."""
assert await db.is_channel_ingested(42) is False
async def test_mark_idempotent(self, db: Database) -> None:
"""Marking the same channel twice does not raise."""
await db.mark_channel_ingested(42)
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(42) is True
async def test_scoped_to_channel(self, db: Database) -> None:
"""Marking one channel as ingested does not affect another channel."""
await db.mark_channel_ingested(42)
assert await db.is_channel_ingested(99) is False
class TestTransactionRollback:
"""Transaction rolls back all changes on error."""
async def test_error_rolls_back_insert(self, db: Database) -> None:
"""An error during a transaction prevents partial data from persisting."""
async def insert_and_fail() -> None:
async with db._transaction():
await db._connection.execute(
"INSERT INTO markov_start_words"
" (channel_id, user_id, word)"
" VALUES (?, ?, ?)",
(1, 100, "should_not_persist"),
)
msg = "simulated failure"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="simulated"):
await insert_and_fail()
assert await db.get_random_start_word(1) is None
class TestForgetUser:
"""Atomic forget-user transaction across all tables."""
async def test_deletes_data_and_sets_no_ingest(self, db: Database) -> None:
"""All user data is removed and noIngest flag is set."""
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_image(1) is None
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_preserves_other_users(self, db: Database) -> None:
"""Data belonging to other users is not affected."""
await db.add_markov_data(
[StartWord(1, 100, "Gone"), StartWord(1, 200, "Keep")],
[
Transition(1, 100, "Gone", "away."),
Transition(1, 200, "Keep", "this."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/gone.png"),
ChannelImage(1, 200, "https://example.com/stay.png"),
],
)
await db.set_flag(EntityType.USER, "200", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) == "Keep"
assert await db.get_random_next_word(1, "Keep") == "this."
assert await db.get_random_image(1) == "https://example.com/stay.png"
assert await db.is_flag_set(EntityType.USER, "200", Flag.ALLOW_PINGS) is True
async def test_preserves_other_entity_type_flags(self, db: Database) -> None:
"""Flags on channels with the same entity ID are not affected."""
await db.set_flag(EntityType.CHANNEL, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.CHANNEL, "100", Flag.NO_REPLY) is True
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
async def test_clears_existing_flags_except_no_ingest(self, db: Database) -> None:
"""Existing user flags are cleared but noIngest remains."""
await db.set_flag(EntityType.USER, "100", Flag.NO_REPLY)
await db.set_flag(EntityType.USER, "100", Flag.ALLOW_PINGS)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_REPLY) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.ALLOW_PINGS) is False
assert await db.is_flag_set(EntityType.USER, "100", Flag.NO_INGEST) is True
async def test_deletes_across_channels(self, db: Database) -> None:
"""All user data is removed from every channel."""
await db.add_markov_data(
[StartWord(1, 100, "One"), StartWord(2, 100, "Two")],
[
Transition(1, 100, "One", "fish."),
Transition(2, 100, "Two", "fish."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/a.png"),
ChannelImage(2, 100, "https://example.com/b.png"),
],
)
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) is None
assert await db.get_random_next_word(1, "One") is None
assert await db.get_random_next_word(2, "Two") is None
assert await db.get_random_image(1) is None
assert await db.get_random_image(2) is None
async def test_noop_for_nonexistent_user(self, db: Database) -> None:
"""Forgetting a user with no data does not raise."""
await db.forget_user(999, Flag.NO_INGEST)
assert await db.is_flag_set(EntityType.USER, "999", Flag.NO_INGEST) is True
class TestWriteDurability:
"""Writes persist across close and reopen."""
async def test_markov_data_survives_reopen(self, tmp_path: Path) -> None:
"""Data written via add_markov_data is durable after close/reopen."""
db_path = str(tmp_path / "durability.db")
db = await Database.connect(db_path)
try:
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
finally:
await db.close()
db2 = await Database.connect(db_path)
try:
assert await db2.get_random_start_word(1) == "Hello"
assert await db2.get_random_next_word(1, "Hello") == "world."
finally:
await db2.close()
@@ -0,0 +1,297 @@
# 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.
"""Integration tests for the full ingest → uningest cycle."""
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
import aiosqlite
import pytest
from crabstero.cache import CachedMessage, IngestCache
from crabstero.database import ChannelImage
from crabstero.markov import ingest, uningest
from crabstero.messages import uningest_message
if TYPE_CHECKING:
from crabstero.database import Database
type SnapshotRows = Callable[["Database"], Awaitable[dict[str, list[aiosqlite.Row]]]]
@pytest.fixture
def snapshot_rows() -> SnapshotRows:
"""Return a snapshot reader for Markov and image tables."""
async def read(db: Database) -> dict[str, list[aiosqlite.Row]]:
tables: dict[str, list[aiosqlite.Row]] = {}
for table in ("markov_start_words", "markov_transitions", "channel_images"):
async with db._connection.execute(
f"SELECT * FROM {table}", # noqa: S608
) as cursor:
tables[table] = sorted(await cursor.fetchall())
return tables
return read
@pytest.fixture
def ingest_cache() -> IngestCache:
"""Return an empty ingest cache for uningest-message tests."""
return IngestCache()
class TestIngestUningestCycle:
"""Full round-trip: ingest data, then uningest it completely."""
async def test_content_round_trip(self, db: Database) -> None:
"""Ingest and uningest content leaves the database clean."""
await ingest(db, 1, 100, "Hello beautiful world.")
await uningest(db, 1, 100, "Hello beautiful world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_next_word(1, "beautiful") is None
async def test_image_round_trip(self, db: Database) -> None:
"""Ingest and uningest an image leaves the database clean."""
await db.add_images([ChannelImage(1, 100, "https://example.com/cat.png")])
await db.remove_images([ChannelImage(1, 100, "https://example.com/cat.png")])
assert await db.get_random_image(1) is None
async def test_multi_sentence_round_trip(self, db: Database) -> None:
"""Multi-sentence ingest and uningest leaves the database clean."""
text = "Hello world. Goodbye world! How are you?"
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
assert await db.get_random_start_word(1) is None
async def test_uningest_only_removes_one_copy(self, db: Database) -> None:
"""Uningesting once preserves data from a second identical ingest."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
# One copy of each row should remain.
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
async def test_uningest_does_not_affect_other_channels(self, db: Database) -> None:
"""Uningesting from one channel leaves another channel's data intact."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 2, 100, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) is None
assert await db.get_random_start_word(2) == "Hello"
assert await db.get_random_next_word(2, "Hello") == "world."
async def test_uningest_does_not_affect_other_users(self, db: Database) -> None:
"""Uningesting for one user leaves another user's data."""
await ingest(db, 1, 100, "Hello world.")
await ingest(db, 1, 200, "Hello world.")
await uningest(db, 1, 100, "Hello world.")
assert await db.get_random_start_word(1) == "Hello"
assert await db.get_random_next_word(1, "Hello") == "world."
class TestUningestRestoresState:
"""Uningest restores the database to its prior state."""
@pytest.mark.parametrize(
"text",
[
pytest.param("Hello world.", id="simple-sentence"),
pytest.param("Hello world", id="missing-punctuation"),
pytest.param(
"Hello world. Goodbye world! How are you?",
id="multi-sentence",
),
pytest.param("One.", id="single-word"),
pytest.param(
"Lots of extra spaces\nand\nnewlines here.",
id="whitespace-normalization",
),
],
)
async def test_uningest_restores_empty_db(
self,
db: Database,
text: str,
snapshot_rows: SnapshotRows,
) -> None:
"""Ingest then uningest on an empty database leaves all tables empty."""
before = await snapshot_rows(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await snapshot_rows(db)
assert after == before
@pytest.mark.parametrize(
"text",
[
pytest.param("Hello world.", id="simple-sentence"),
pytest.param(
"Hello world. Goodbye world! How are you?",
id="multi-sentence",
),
],
)
async def test_uningest_restores_preexisting_data(
self,
db: Database,
text: str,
snapshot_rows: SnapshotRows,
) -> None:
"""Ingest then uningest preserves unrelated pre-existing data exactly."""
await ingest(db, 99, 200, "Pre-existing data stays safe.")
await db.add_images([ChannelImage(99, 200, "https://example.com/existing.png")])
before = await snapshot_rows(db)
await ingest(db, 1, 100, text)
await uningest(db, 1, 100, text)
after = await snapshot_rows(db)
assert after == before
class TestUningestMessage:
"""Orchestrated uningest via cache lookup and database reversal."""
async def test_content_only(self, db: Database, ingest_cache: IngestCache) -> None:
"""Uningest reverses a content-only message via the cache."""
await ingest(db, 1, 100, "Hello beautiful world.")
ingest_cache.put(
555,
CachedMessage(
channel_id=1,
user_id=100,
content="Hello beautiful world.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, ingest_cache, 555)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
async def test_embeds_only(self, db: Database, ingest_cache: IngestCache) -> None:
"""Uningest reverses embed text ingestion."""
await ingest(db, 1, 100, "Embed title here.")
await ingest(db, 1, 100, "Embed description here.")
ingest_cache.put(
556,
CachedMessage(
channel_id=1,
user_id=100,
content=None,
embed_texts=["Embed title here.", "Embed description here."],
image_urls=[],
),
)
await uningest_message(db, ingest_cache, 556)
assert await db.get_random_start_word(1) is None
async def test_content_with_embeds_and_images(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""Uningest reverses content, embed text, and image data together."""
await ingest(db, 1, 100, "Body text here.")
await ingest(db, 1, 100, "Embed title.")
await db.add_images([ChannelImage(1, 100, "https://example.com/img.png")])
ingest_cache.put(
557,
CachedMessage(
channel_id=1,
user_id=100,
content="Body text here.",
embed_texts=["Embed title."],
image_urls=["https://example.com/img.png"],
),
)
await uningest_message(db, ingest_cache, 557)
assert await db.get_random_start_word(1) is None
assert await db.get_random_image(1) is None
async def test_cache_miss_is_noop(
self,
db: Database,
ingest_cache: IngestCache,
snapshot_rows: SnapshotRows,
) -> None:
"""A message not in the cache leaves the database unchanged."""
await ingest(db, 1, 100, "Keep this data.")
before = await snapshot_rows(db)
await uningest_message(db, ingest_cache, 999)
after = await snapshot_rows(db)
assert after == before
async def test_preserves_other_messages(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""Uningesting one message leaves another message's data intact."""
await ingest(db, 1, 100, "First message.")
await ingest(db, 1, 100, "Second message.")
ingest_cache.put(
601,
CachedMessage(
channel_id=1,
user_id=100,
content="First message.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, ingest_cache, 601)
assert await db.get_random_start_word(1) == "Second"
assert await db.get_random_next_word(1, "Second") == "message."
async def test_pops_entry_from_cache(
self,
db: Database,
ingest_cache: IngestCache,
) -> None:
"""The cache entry is consumed after uningest."""
await ingest(db, 1, 100, "Hello world.")
ingest_cache.put(
602,
CachedMessage(
channel_id=1,
user_id=100,
content="Hello world.",
embed_texts=[],
image_urls=[],
),
)
await uningest_message(db, ingest_cache, 602)
assert ingest_cache.pop(602) is None