ai did his job.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from favorite_stickers.bot import (
|
||||
UnsupportedMediaError,
|
||||
download_with_limit,
|
||||
select_media,
|
||||
)
|
||||
|
||||
|
||||
def message(**values):
|
||||
defaults = {
|
||||
"sticker": None,
|
||||
"photo": None,
|
||||
"video": None,
|
||||
"animation": None,
|
||||
"video_note": None,
|
||||
"document": None,
|
||||
}
|
||||
defaults.update(values)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_photo_selects_largest_size_as_static_media() -> None:
|
||||
small = SimpleNamespace(file_unique_id="small", file_size=10)
|
||||
large = SimpleNamespace(file_unique_id="large", file_size=20)
|
||||
|
||||
selected = select_media(message(photo=[small, large]))
|
||||
|
||||
assert selected.downloadable is large
|
||||
assert selected.source_unique_id == "large"
|
||||
assert selected.kind == "static"
|
||||
assert selected.suffix == ".jpg"
|
||||
|
||||
|
||||
def test_video_sticker_preserves_emoji() -> None:
|
||||
sticker = SimpleNamespace(
|
||||
file_unique_id="sticker",
|
||||
file_size=42,
|
||||
is_animated=False,
|
||||
is_video=True,
|
||||
emoji="🔥",
|
||||
)
|
||||
|
||||
selected = select_media(message(sticker=sticker))
|
||||
|
||||
assert selected.kind == "video"
|
||||
assert selected.emoji == "🔥"
|
||||
assert selected.suffix == ".webm"
|
||||
|
||||
|
||||
def test_tgs_sticker_reports_clear_limitation() -> None:
|
||||
sticker = SimpleNamespace(
|
||||
file_unique_id="animated",
|
||||
file_size=42,
|
||||
is_animated=True,
|
||||
is_video=False,
|
||||
emoji="🙂",
|
||||
)
|
||||
|
||||
with pytest.raises(UnsupportedMediaError, match="TGS"):
|
||||
select_media(message(sticker=sticker))
|
||||
|
||||
|
||||
def test_image_document_lets_ffprobe_detect_animation() -> None:
|
||||
document = SimpleNamespace(
|
||||
file_unique_id="doc",
|
||||
file_size=42,
|
||||
mime_type="image/png",
|
||||
file_name="picture.PNG",
|
||||
)
|
||||
|
||||
selected = select_media(message(document=document))
|
||||
|
||||
assert selected.kind == "auto"
|
||||
assert selected.suffix == ".png"
|
||||
|
||||
|
||||
def test_gif_document_lets_ffprobe_detect_animation() -> None:
|
||||
document = SimpleNamespace(
|
||||
file_unique_id="gif",
|
||||
file_size=42,
|
||||
mime_type="image/gif",
|
||||
file_name="animation.gif",
|
||||
)
|
||||
|
||||
selected = select_media(message(document=document))
|
||||
|
||||
assert selected.kind == "auto"
|
||||
|
||||
|
||||
class FakeDownloadBot:
|
||||
async def download(self, downloadable, destination, seek=False):
|
||||
destination.write(b"1234")
|
||||
destination.flush()
|
||||
destination.write(b"5678")
|
||||
destination.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_enforces_actual_byte_limit(tmp_path) -> None:
|
||||
destination = tmp_path / "source.bin"
|
||||
|
||||
with pytest.raises(UnsupportedMediaError, match="слишком большой"):
|
||||
await download_with_limit(FakeDownloadBot(), object(), destination, 6)
|
||||
|
||||
assert not destination.exists()
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
|
||||
from favorite_stickers.config import Settings
|
||||
|
||||
|
||||
def test_settings_require_bot_token(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.delenv("BOT_TOKEN", raising=False)
|
||||
with pytest.raises(ValueError, match="BOT_TOKEN"):
|
||||
Settings.from_env()
|
||||
|
||||
|
||||
def test_settings_read_paths_and_limit(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("BOT_TOKEN", "123:test")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "db.sqlite3"))
|
||||
monkeypatch.setenv("MAX_DOWNLOAD_MB", "12")
|
||||
monkeypatch.setenv("FFMPEG_TIMEOUT_SECONDS", "15")
|
||||
monkeypatch.setenv("MAX_CONVERSIONS", "3")
|
||||
|
||||
settings = Settings.from_env()
|
||||
|
||||
assert settings.token == "123:test"
|
||||
assert settings.database_path == tmp_path / "db.sqlite3"
|
||||
assert settings.max_download_bytes == 12 * 1024 * 1024
|
||||
assert settings.ffmpeg_timeout_seconds == 15
|
||||
assert settings.max_conversions == 3
|
||||
@@ -0,0 +1,398 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from favorite_stickers.media import ConvertedMedia, MediaConversionError, MediaConverter
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("ffmpeg") or not shutil.which("ffprobe"),
|
||||
reason="ffmpeg and ffprobe are required",
|
||||
)
|
||||
|
||||
|
||||
def ffprobe(path: Path) -> dict:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_image_becomes_valid_video_sticker(tmp_path) -> None:
|
||||
source = tmp_path / "source.png"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=320x160",
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
converted = await MediaConverter().convert_path(source, "static")
|
||||
info = ffprobe(converted.path)
|
||||
video = next(
|
||||
stream for stream in info["streams"] if stream["codec_type"] == "video"
|
||||
)
|
||||
|
||||
assert video["codec_name"] == "vp9"
|
||||
assert max(video["width"], video["height"]) == 512
|
||||
assert not any(stream["codec_type"] == "audio" for stream in info["streams"])
|
||||
assert converted.path.stat().st_size <= 256 * 1024
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_image_document_is_auto_detected(tmp_path) -> None:
|
||||
source = tmp_path / "source.png"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=320x160",
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
converted = await MediaConverter().convert_path(source, "auto")
|
||||
info = ffprobe(converted.path)
|
||||
|
||||
assert float(info["format"]["duration"]) >= 0.9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_longer_than_three_seconds_is_rejected(tmp_path) -> None:
|
||||
source = tmp_path / "long.mp4"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=blue:s=64x64:d=4",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MediaConversionError, match="3"):
|
||||
await MediaConverter().convert_path(source, "video")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_animated_gif_document_is_detected_and_converted(tmp_path) -> None:
|
||||
source = tmp_path / "animated.gif"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=128x128:r=10:d=2",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
converted = await MediaConverter().convert_path(source, "auto")
|
||||
info = ffprobe(converted.path)
|
||||
|
||||
assert float(info["format"]["duration"]) <= 3
|
||||
assert converted.path.stat().st_size <= 256 * 1024
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_animated_gif_document_longer_than_three_seconds_is_rejected(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
source = tmp_path / "long.gif"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=64x64:r=5:d=4",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MediaConversionError, match="3"):
|
||||
await MediaConverter().convert_path(source, "auto")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_animated_apng_document_is_detected_and_converted(tmp_path) -> None:
|
||||
source = tmp_path / "animated.apng"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=128x128:r=10:d=2",
|
||||
"-plays",
|
||||
"0",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
converted = await MediaConverter().convert_path(source, "auto")
|
||||
info = ffprobe(converted.path)
|
||||
|
||||
assert 1.9 <= float(info["format"]["duration"]) <= 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_animated_apng_longer_than_three_seconds_is_rejected(tmp_path) -> None:
|
||||
source = tmp_path / "long.apng"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=64x64:r=5:d=4",
|
||||
"-plays",
|
||||
"0",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
with pytest.raises(MediaConversionError, match="3"):
|
||||
await MediaConverter().convert_path(source, "auto")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_frame_apng_document_is_converted(tmp_path) -> None:
|
||||
source = tmp_path / "single.apng"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=green:s=64x64",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-plays",
|
||||
"0",
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
converted = await MediaConverter().convert_path(source, "auto")
|
||||
|
||||
assert converted.path.stat().st_size <= 256 * 1024
|
||||
|
||||
|
||||
class SlowConverter(MediaConverter):
|
||||
async def _convert_path_unlocked(self, source: Path, kind: str):
|
||||
await asyncio.sleep(0.2)
|
||||
raise AssertionError("timeout was not enforced")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversion_has_total_timeout(tmp_path) -> None:
|
||||
converter = SlowConverter(process_timeout=0.01)
|
||||
|
||||
with pytest.raises(MediaConversionError, match="время"):
|
||||
await converter.convert_path(tmp_path / "source.bin", "auto")
|
||||
|
||||
|
||||
class CountingConverter(MediaConverter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(max_parallel=2)
|
||||
self.active = 0
|
||||
self.maximum_active = 0
|
||||
|
||||
async def _convert_path_unlocked(self, source: Path, kind: str):
|
||||
self.active += 1
|
||||
self.maximum_active = max(self.maximum_active, self.active)
|
||||
await asyncio.sleep(0.02)
|
||||
self.active -= 1
|
||||
return ConvertedMedia(source, "hash")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversion_parallelism_is_bounded(tmp_path) -> None:
|
||||
converter = CountingConverter()
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
converter.convert_path(tmp_path / f"{index}.bin", "auto")
|
||||
for index in range(5)
|
||||
)
|
||||
)
|
||||
|
||||
assert converter.maximum_active == 2
|
||||
|
||||
|
||||
def test_output_validation_rejects_non_vp9_video() -> None:
|
||||
probe = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 512,
|
||||
"height": 256,
|
||||
"avg_frame_rate": "30/1",
|
||||
}
|
||||
],
|
||||
"format": {"duration": "1.0"},
|
||||
}
|
||||
|
||||
with pytest.raises(MediaConversionError, match="VP9"):
|
||||
MediaConverter._validate_output(probe)
|
||||
|
||||
|
||||
def test_output_validation_rejects_audio_stream() -> None:
|
||||
probe = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "vp9",
|
||||
"width": 512,
|
||||
"height": 256,
|
||||
"avg_frame_rate": "30/1",
|
||||
},
|
||||
{"codec_type": "audio", "codec_name": "opus"},
|
||||
],
|
||||
"format": {"duration": "1.0"},
|
||||
}
|
||||
|
||||
with pytest.raises(MediaConversionError, match="аудио"):
|
||||
MediaConverter._validate_output(probe)
|
||||
|
||||
|
||||
def test_input_validation_rejects_decompression_bomb_dimensions() -> None:
|
||||
probe = {
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "png",
|
||||
"width": 100_000,
|
||||
"height": 100_000,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(MediaConversionError, match="разрешение"):
|
||||
MediaConverter._validate_input(probe)
|
||||
|
||||
|
||||
def test_ffmpeg_commands_allow_only_stdin_protocol() -> None:
|
||||
converter = MediaConverter()
|
||||
source = Path("untrusted.m3u8")
|
||||
command = converter._command(source, Path("output.webm"), "video", 32, input_fd=7)
|
||||
|
||||
assert command[command.index("-protocol_whitelist") + 1] == "fd"
|
||||
assert command[command.index("-fd") + 1] == "7"
|
||||
assert "fd:" in command
|
||||
assert str(source) not in command
|
||||
|
||||
probe_command = converter._probe_command(count_frames=False, input_fd=8)
|
||||
assert probe_command[probe_command.index("-protocol_whitelist") + 1] == "fd"
|
||||
assert probe_command[probe_command.index("-fd") + 1] == "8"
|
||||
assert "fd:" in probe_command
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_stderr_is_not_exposed_to_user(tmp_path) -> None:
|
||||
ffprobe = tmp_path / "fake-ffprobe"
|
||||
ffprobe.write_text(
|
||||
"#!/bin/sh\necho '/secret/internal/path: decoder exploded' >&2\nexit 1\n"
|
||||
)
|
||||
ffprobe.chmod(0o700)
|
||||
source = tmp_path / "source.bin"
|
||||
source.write_bytes(b"not media")
|
||||
|
||||
with pytest.raises(MediaConversionError) as caught:
|
||||
await MediaConverter(ffprobe=str(ffprobe)).convert_path(source, "auto")
|
||||
|
||||
assert "/secret/internal/path" not in str(caught.value)
|
||||
assert "decoder exploded" not in str(caught.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_manifest_cannot_read_another_local_file(tmp_path) -> None:
|
||||
referenced = tmp_path / "referenced.webm"
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=red:s=64x64:d=1",
|
||||
str(referenced),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
manifest = tmp_path / "untrusted.ffconcat"
|
||||
manifest.write_text(
|
||||
f"ffconcat version 1.0\nfile '{referenced.as_posix()}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(MediaConversionError, match="не поддерживается"):
|
||||
await MediaConverter().convert_path(manifest, "auto")
|
||||
@@ -0,0 +1,31 @@
|
||||
from favorite_stickers.naming import pack_name, pack_title
|
||||
|
||||
|
||||
def test_first_pack_uses_stable_bot_owned_name() -> None:
|
||||
assert pack_name("FavoriteKeeperBot", 123456, 1) == (
|
||||
"favorites_123456_by_favoritekeeperbot"
|
||||
)
|
||||
assert pack_title("FavoriteKeeperBot", 123456, 1) == (
|
||||
"FavoriteKeeperBot - Favorites 123456"
|
||||
)
|
||||
|
||||
|
||||
def test_next_pack_has_number_suffix() -> None:
|
||||
assert pack_name("FavoriteKeeperBot", 123456, 2) == (
|
||||
"favorites_123456_2_by_favoritekeeperbot"
|
||||
)
|
||||
assert pack_title("FavoriteKeeperBot", 123456, 2) == (
|
||||
"FavoriteKeeperBot - Favorites 123456 [2]"
|
||||
)
|
||||
|
||||
|
||||
def test_names_respect_telegram_length_limit() -> None:
|
||||
username = "a" * 32
|
||||
assert len(pack_name(username, 9_223_372_036_854_775_807, 9999)) <= 64
|
||||
assert len(pack_title(username, 9_223_372_036_854_775_807, 9999)) <= 64
|
||||
|
||||
|
||||
def test_short_name_never_contains_consecutive_underscores() -> None:
|
||||
name = pack_name("my__favorite_bot", 123, 2)
|
||||
assert "__" not in name
|
||||
assert name.endswith("_by_my_favorite_bot")
|
||||
@@ -0,0 +1,203 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from favorite_stickers.media import ConvertedMedia, IncomingMedia
|
||||
from favorite_stickers.service import (
|
||||
FavoriteStickerService,
|
||||
RemoteSticker,
|
||||
ToggleAction,
|
||||
)
|
||||
from favorite_stickers.store import Store
|
||||
|
||||
|
||||
class FakeConverter:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
async def convert(self, media: IncomingMedia) -> ConvertedMedia:
|
||||
self.calls += 1
|
||||
return ConvertedMedia(Path("result.webm"), media.content_hash or "hash")
|
||||
|
||||
|
||||
class FakeTelegram:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[str] = []
|
||||
self.added: list[str] = []
|
||||
self.deleted: list[str] = []
|
||||
self.packs: dict[str, list[RemoteSticker]] = {}
|
||||
self.number = 0
|
||||
|
||||
async def create_pack(
|
||||
self,
|
||||
user_id: int,
|
||||
name: str,
|
||||
title: str,
|
||||
sticker_path: Path,
|
||||
emoji: str,
|
||||
) -> RemoteSticker:
|
||||
self.created.append(name)
|
||||
remote = self._remote()
|
||||
self.packs[name] = [remote]
|
||||
return remote
|
||||
|
||||
async def add_sticker(
|
||||
self, user_id: int, name: str, sticker_path: Path, emoji: str
|
||||
) -> RemoteSticker:
|
||||
self.added.append(name)
|
||||
remote = self._remote()
|
||||
self.packs[name].append(remote)
|
||||
return remote
|
||||
|
||||
async def delete_sticker(self, sticker_file_id: str) -> None:
|
||||
self.deleted.append(sticker_file_id)
|
||||
for stickers in self.packs.values():
|
||||
stickers[:] = [item for item in stickers if item.file_id != sticker_file_id]
|
||||
|
||||
async def get_pack(self, name: str) -> list[RemoteSticker] | None:
|
||||
stickers = self.packs.get(name)
|
||||
return list(stickers) if stickers is not None else None
|
||||
|
||||
def _remote(self) -> RemoteSticker:
|
||||
self.number += 1
|
||||
return RemoteSticker(
|
||||
file_id=f"file-{self.number}", unique_id=f"unique-{self.number}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_media_creates_video_pack_without_questions(tmp_path) -> None:
|
||||
store = Store(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
telegram = FakeTelegram()
|
||||
converter = FakeConverter()
|
||||
service = FavoriteStickerService(store, telegram, converter, "TestBot")
|
||||
|
||||
result = await service.toggle(
|
||||
7, IncomingMedia(Path("source.png"), "source-id", "static", "❤️", "hash")
|
||||
)
|
||||
|
||||
assert result.action is ToggleAction.ADDED
|
||||
assert result.pack_name == "favorites_7_by_testbot"
|
||||
assert telegram.created == [result.pack_name]
|
||||
pack = store.latest_pack(7)
|
||||
assert pack is not None
|
||||
assert pack.sticker_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resending_original_sticker_removes_it_without_conversion(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = Store(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
telegram = FakeTelegram()
|
||||
converter = FakeConverter()
|
||||
service = FavoriteStickerService(store, telegram, converter, "TestBot")
|
||||
media = IncomingMedia(Path("source.webp"), "same-id", "static", "👍", "same-hash")
|
||||
await service.toggle(7, media)
|
||||
|
||||
result = await service.toggle(7, media)
|
||||
|
||||
assert result.action is ToggleAction.REMOVED
|
||||
assert telegram.deleted == ["file-1"]
|
||||
assert converter.calls == 1
|
||||
pack = store.latest_pack(7)
|
||||
assert pack is not None
|
||||
assert pack.sticker_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pack_rolls_over_to_numbered_pack(tmp_path) -> None:
|
||||
store = Store(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
telegram = FakeTelegram()
|
||||
converter = FakeConverter()
|
||||
service = FavoriteStickerService(
|
||||
store, telegram, converter, "TestBot", pack_limit=2
|
||||
)
|
||||
|
||||
results = []
|
||||
for index in range(3):
|
||||
results.append(
|
||||
await service.toggle(
|
||||
7,
|
||||
IncomingMedia(
|
||||
Path(f"{index}.png"),
|
||||
f"source-{index}",
|
||||
"static",
|
||||
"❤️",
|
||||
f"hash-{index}",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert telegram.created == [
|
||||
"favorites_7_by_testbot",
|
||||
"favorites_7_2_by_testbot",
|
||||
]
|
||||
assert telegram.added == ["favorites_7_by_testbot"]
|
||||
assert results[-1].pack_name == "favorites_7_2_by_testbot"
|
||||
|
||||
|
||||
class FailOnceStore(Store):
|
||||
fail_add = False
|
||||
fail_remove = False
|
||||
|
||||
def complete_add(self, user_id: int, file_id: str, unique_id: str) -> None:
|
||||
if self.fail_add:
|
||||
self.fail_add = False
|
||||
raise RuntimeError("simulated SQLite add failure")
|
||||
super().complete_add(user_id, file_id, unique_id)
|
||||
|
||||
def complete_remove(self, user_id: int) -> None:
|
||||
if self.fail_remove:
|
||||
self.fail_remove = False
|
||||
raise RuntimeError("simulated SQLite remove failure")
|
||||
super().complete_remove(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_add_is_recovered_after_sqlite_failure(tmp_path) -> None:
|
||||
store = FailOnceStore(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
store.fail_add = True
|
||||
telegram = FakeTelegram()
|
||||
converter = FakeConverter()
|
||||
service = FavoriteStickerService(store, telegram, converter, "TestBot")
|
||||
media = IncomingMedia(Path("source.png"), "source", "static", "❤️", "hash")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SQLite add"):
|
||||
await service.toggle(7, media)
|
||||
|
||||
assert store.pending_operation(7) is not None
|
||||
result = await service.toggle(7, media)
|
||||
assert store.pending_operation(7) is None
|
||||
assert result.action is ToggleAction.ADDED
|
||||
assert telegram.deleted == []
|
||||
assert converter.calls == 1
|
||||
assert store.find_sticker(7, telegram_unique_id="source") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_remove_is_recovered_after_sqlite_failure(tmp_path) -> None:
|
||||
store = FailOnceStore(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
telegram = FakeTelegram()
|
||||
converter = FakeConverter()
|
||||
service = FavoriteStickerService(store, telegram, converter, "TestBot")
|
||||
media = IncomingMedia(Path("source.png"), "source", "static", "❤️", "hash")
|
||||
await service.toggle(7, media)
|
||||
store.fail_remove = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="SQLite remove"):
|
||||
await service.toggle(7, media)
|
||||
|
||||
assert store.pending_operation(7) is not None
|
||||
result = await service.toggle(7, media)
|
||||
assert store.pending_operation(7) is None
|
||||
assert result.action is ToggleAction.REMOVED
|
||||
assert telegram.created == ["favorites_7_by_testbot"]
|
||||
assert telegram.added == []
|
||||
assert converter.calls == 1
|
||||
assert store.find_sticker(7, telegram_unique_id="source") is None
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from favorite_stickers.store import StickerRecord, Store, StoreLockedError
|
||||
|
||||
|
||||
def test_store_finds_sticker_by_original_or_generated_telegram_id(tmp_path) -> None:
|
||||
store = Store(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
store.add_pack(7, 1, "favorites_7_by_testbot")
|
||||
record = StickerRecord(
|
||||
user_id=7,
|
||||
pack_name="favorites_7_by_testbot",
|
||||
source_unique_id="original-id",
|
||||
content_hash="abc",
|
||||
sticker_file_id="file-id",
|
||||
sticker_unique_id="generated-id",
|
||||
)
|
||||
store.add_sticker(record)
|
||||
|
||||
assert store.find_sticker(7, telegram_unique_id="original-id") == record
|
||||
assert store.find_sticker(7, telegram_unique_id="generated-id") == record
|
||||
assert store.find_sticker(7, content_hash="abc") == record
|
||||
assert store.find_sticker(8, telegram_unique_id="original-id") is None
|
||||
|
||||
|
||||
def test_removing_sticker_updates_pack_count(tmp_path) -> None:
|
||||
store = Store(tmp_path / "bot.sqlite3")
|
||||
store.initialize()
|
||||
store.add_pack(7, 1, "favorites_7_by_testbot")
|
||||
record = StickerRecord(7, "favorites_7_by_testbot", "src", "abc", "file", "unique")
|
||||
sticker_id = store.add_sticker(record)
|
||||
|
||||
pack = store.latest_pack(7)
|
||||
assert pack is not None
|
||||
assert pack.sticker_count == 1
|
||||
store.remove_sticker(sticker_id)
|
||||
pack = store.latest_pack(7)
|
||||
assert pack is not None
|
||||
assert pack.sticker_count == 0
|
||||
|
||||
|
||||
def test_only_one_store_process_can_own_database(tmp_path) -> None:
|
||||
path = tmp_path / "bot.sqlite3"
|
||||
first = Store(path)
|
||||
first.initialize()
|
||||
second = Store(path)
|
||||
|
||||
with pytest.raises(StoreLockedError, match="запущен"):
|
||||
second.initialize()
|
||||
|
||||
first.close()
|
||||
second.initialize()
|
||||
second.close()
|
||||
Reference in New Issue
Block a user