Use celery tasks instead of systemd timers for periodic work; and add more tests
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-04-08 03:23:18 +02:00
commit 66ea46cf23
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
25 changed files with 2133 additions and 104 deletions

575
twitch/tests/test_tasks.py Normal file
View file

@ -0,0 +1,575 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import call
from unittest.mock import patch
import httpx
import pytest
from twitch.models import DropBenefit
from twitch.models import DropCampaign
from twitch.models import RewardCampaign
from twitch.tasks import _convert_to_modern_formats
from twitch.tasks import _download_and_save
from twitch.tasks import backup_database
from twitch.tasks import download_all_images
from twitch.tasks import download_benefit_image
from twitch.tasks import download_campaign_image
from twitch.tasks import download_game_image
from twitch.tasks import download_reward_campaign_image
from twitch.tasks import import_chat_badges
from twitch.tasks import import_twitch_file
from twitch.tasks import scan_pending_twitch_files
def test_scan_pending_twitch_files_dispatches_json_files(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""It should dispatch an import task for each JSON file in the pending directory."""
first = tmp_path / "one.json"
second = tmp_path / "two.json"
ignored = tmp_path / "notes.txt"
first.write_text("{}", encoding="utf-8")
second.write_text("{}", encoding="utf-8")
ignored.write_text("ignored", encoding="utf-8")
monkeypatch.setenv("TTVDROPS_PENDING_DIR", str(tmp_path))
with patch("twitch.tasks.import_twitch_file.delay") as delay_mock:
scan_pending_twitch_files.run()
delay_mock.assert_has_calls([call(str(first)), call(str(second))], any_order=True)
assert delay_mock.call_count == 2
def test_scan_pending_twitch_files_skips_when_env_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""It should do nothing when TTVDROPS_PENDING_DIR is not configured."""
monkeypatch.delenv("TTVDROPS_PENDING_DIR", raising=False)
with patch("twitch.tasks.import_twitch_file.delay") as delay_mock:
scan_pending_twitch_files.run()
delay_mock.assert_not_called()
def test_import_twitch_file_calls_importer_for_existing_file(tmp_path: Path) -> None:
"""It should run BetterImportDrops with the provided path when the file exists."""
source = tmp_path / "drops.json"
source.write_text("{}", encoding="utf-8")
with patch(
"twitch.management.commands.better_import_drops.Command.handle",
) as handle_mock:
import_twitch_file.run(str(source))
assert handle_mock.call_count == 1
assert handle_mock.call_args.kwargs["path"] == source
def test_import_twitch_file_retries_on_import_error(tmp_path: Path) -> None:
"""It should retry when the importer raises an exception."""
source = tmp_path / "drops.json"
source.write_text("{}", encoding="utf-8")
error = RuntimeError("boom")
with (
patch(
"twitch.management.commands.better_import_drops.Command.handle",
side_effect=error,
),
patch.object(
import_twitch_file,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
import_twitch_file.run(str(source))
retry_mock.assert_called_once_with(exc=error)
def test_download_and_save_skips_when_image_already_cached() -> None:
"""It should not download when the target ImageField already has a file name."""
file_field = MagicMock()
file_field.name = "already-there.jpg"
with patch("twitch.tasks.httpx.Client") as client_mock:
result = _download_and_save(
url="https://example.com/test.jpg",
name="game-1",
file_field=file_field,
)
assert result is False
client_mock.assert_not_called()
def test_download_and_save_saves_downloaded_content() -> None:
"""It should save downloaded bytes and trigger modern format conversion."""
file_field = MagicMock()
file_field.name = ""
file_field.path = "C:/cache/game-1.png"
response = MagicMock()
response.content = b"img-bytes"
response.raise_for_status.return_value = None
client = MagicMock()
client.get.return_value = response
client_cm = MagicMock()
client_cm.__enter__.return_value = client
with (
patch("twitch.tasks.httpx.Client", return_value=client_cm),
patch("twitch.tasks._convert_to_modern_formats") as convert_mock,
):
result = _download_and_save(
url="https://example.com/path/picture.png",
name="game-1",
file_field=file_field,
)
assert result is True
file_field.save.assert_called_once()
assert file_field.save.call_args.args[0] == "game-1.png"
assert file_field.save.call_args.kwargs["save"] is True
convert_mock.assert_called_once_with(Path("C:/cache/game-1.png"))
def test_download_and_save_returns_false_on_http_error() -> None:
"""It should return False when HTTP requests fail."""
file_field = MagicMock()
file_field.name = ""
client = MagicMock()
client.get.side_effect = httpx.HTTPError("network down")
client_cm = MagicMock()
client_cm.__enter__.return_value = client
with patch("twitch.tasks.httpx.Client", return_value=client_cm):
result = _download_and_save(
url="https://example.com/path/picture.png",
name="game-1",
file_field=file_field,
)
assert result is False
file_field.save.assert_not_called()
def test_download_game_image_downloads_normalized_twitch_url() -> None:
"""It should normalize Twitch box art URLs before downloading."""
game = SimpleNamespace(
box_art="https://static-cdn.jtvnw.net/ttv-boxart/1-{width}x{height}.jpg",
twitch_id="123",
box_art_file=MagicMock(),
)
with (
patch("twitch.models.Game.objects.get", return_value=game),
patch(
"twitch.utils.is_twitch_box_art_url",
return_value=True,
) as is_twitch_mock,
patch(
"twitch.utils.normalize_twitch_box_art_url",
return_value="https://cdn.example.com/box.jpg",
) as normalize_mock,
patch("twitch.tasks._download_and_save") as save_mock,
):
download_game_image.run(1)
is_twitch_mock.assert_called_once_with(game.box_art)
normalize_mock.assert_called_once_with(game.box_art)
save_mock.assert_called_once_with(
"https://cdn.example.com/box.jpg",
"123",
game.box_art_file,
)
def test_download_game_image_retries_on_download_error() -> None:
"""It should retry when the image download helper fails unexpectedly."""
game = SimpleNamespace(
box_art="https://static-cdn.jtvnw.net/ttv-boxart/1-{width}x{height}.jpg",
twitch_id="123",
box_art_file=MagicMock(),
)
error = RuntimeError("boom")
with (
patch("twitch.models.Game.objects.get", return_value=game),
patch("twitch.utils.is_twitch_box_art_url", return_value=True),
patch(
"twitch.utils.normalize_twitch_box_art_url",
return_value="https://cdn.example.com/box.jpg",
),
patch("twitch.tasks._download_and_save", side_effect=error),
patch.object(
download_game_image,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
download_game_image.run(1)
retry_mock.assert_called_once_with(exc=error)
def test_download_all_images_calls_expected_commands() -> None:
"""It should invoke both image import management commands."""
with patch("twitch.tasks.call_command") as call_command_mock:
download_all_images.run()
call_command_mock.assert_has_calls([
call("download_box_art"),
call("download_campaign_images", model="all"),
])
def test_import_chat_badges_retries_on_error() -> None:
"""It should retry when import_chat_badges command execution fails."""
error = RuntimeError("boom")
with (
patch("twitch.tasks.call_command", side_effect=error),
patch.object(
import_chat_badges,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
import_chat_badges.run()
retry_mock.assert_called_once_with(exc=error)
def test_backup_database_calls_backup_command() -> None:
"""It should invoke the backup_db management command."""
with patch("twitch.tasks.call_command") as call_command_mock:
backup_database.run()
call_command_mock.assert_called_once_with("backup_db")
def test_convert_to_modern_formats_skips_non_image_files(tmp_path: Path) -> None:
"""It should skip files that are not jpg, jpeg, or png."""
text_file = tmp_path / "document.txt"
text_file.write_text("Not an image", encoding="utf-8")
with patch("PIL.Image.open") as open_mock:
_convert_to_modern_formats(text_file)
open_mock.assert_not_called()
def test_convert_to_modern_formats_skips_missing_files(tmp_path: Path) -> None:
"""It should skip files that do not exist."""
missing_file = tmp_path / "nonexistent.jpg"
with patch("PIL.Image.open") as open_mock:
_convert_to_modern_formats(missing_file)
open_mock.assert_not_called()
def test_convert_to_modern_formats_converts_rgb_image(tmp_path: Path) -> None:
"""It should save image in WebP and AVIF formats for RGB images."""
original = tmp_path / "image.jpg"
original.write_bytes(b"fake-jpeg")
mock_image = MagicMock()
mock_image.mode = "RGB"
mock_copy = MagicMock()
mock_image.copy.return_value = mock_copy
with (
patch("PIL.Image.open") as open_mock,
):
open_mock.return_value.__enter__.return_value = mock_image
_convert_to_modern_formats(original)
open_mock.assert_called_once_with(original)
assert mock_copy.save.call_count == 2
webp_call = [c for c in mock_copy.save.call_args_list if "webp" in str(c)]
avif_call = [c for c in mock_copy.save.call_args_list if "avif" in str(c)]
assert len(webp_call) == 1
assert len(avif_call) == 1
def test_convert_to_modern_formats_converts_rgba_image_with_background(
tmp_path: Path,
) -> None:
"""It should create RGB background and paste RGBA image onto it."""
original = tmp_path / "image.png"
original.write_bytes(b"fake-png")
mock_rgba = MagicMock()
mock_rgba.mode = "RGBA"
mock_rgba.split.return_value = [MagicMock(), MagicMock(), MagicMock(), MagicMock()]
mock_rgba.size = (100, 100)
mock_rgba.convert.return_value = mock_rgba
mock_bg = MagicMock()
with (
patch("PIL.Image.open") as open_mock,
patch("PIL.Image.new", return_value=mock_bg) as new_mock,
):
open_mock.return_value.__enter__.return_value = mock_rgba
_convert_to_modern_formats(original)
new_mock.assert_called_once_with("RGB", (100, 100), (255, 255, 255))
mock_bg.paste.assert_called_once()
assert mock_bg.save.call_count == 2
def test_convert_to_modern_formats_handles_conversion_error(tmp_path: Path) -> None:
"""It should gracefully handle format conversion failures."""
original = tmp_path / "image.jpg"
original.write_bytes(b"fake-jpeg")
mock_image = MagicMock()
mock_image.mode = "RGB"
mock_image.copy.return_value = MagicMock()
mock_image.copy.return_value.save.side_effect = Exception("PIL error")
with (
patch("PIL.Image.open") as open_mock,
):
open_mock.return_value.__enter__.return_value = mock_image
_convert_to_modern_formats(original) # Should not raise
def test_download_campaign_image_downloads_when_url_exists() -> None:
"""It should download and save campaign image when image_url is present."""
campaign = SimpleNamespace(
image_url="https://example.com/campaign.jpg",
twitch_id="camp123",
image_file=MagicMock(),
)
with (
patch("twitch.models.DropCampaign.objects.get", return_value=campaign),
patch("twitch.tasks._download_and_save", return_value=True) as save_mock,
):
download_campaign_image.run(1)
save_mock.assert_called_once_with(
"https://example.com/campaign.jpg",
"camp123",
campaign.image_file,
)
def test_download_campaign_image_skips_when_no_url() -> None:
"""It should skip when campaign has no image_url."""
campaign = SimpleNamespace(
image_url=None,
twitch_id="camp123",
image_file=MagicMock(),
)
with (
patch("twitch.models.DropCampaign.objects.get", return_value=campaign),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_campaign_image.run(1)
save_mock.assert_not_called()
def test_download_campaign_image_skips_when_not_found() -> None:
"""It should skip when campaign does not exist."""
with (
patch(
"twitch.models.DropCampaign.objects.get",
side_effect=DropCampaign.DoesNotExist(),
),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_campaign_image.run(1)
save_mock.assert_not_called()
def test_download_campaign_image_retries_on_error() -> None:
"""It should retry when image download fails unexpectedly."""
campaign = SimpleNamespace(
image_url="https://example.com/campaign.jpg",
twitch_id="camp123",
image_file=MagicMock(),
)
error = RuntimeError("boom")
with (
patch("twitch.models.DropCampaign.objects.get", return_value=campaign),
patch("twitch.tasks._download_and_save", side_effect=error),
patch.object(
download_campaign_image,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
download_campaign_image.run(1)
retry_mock.assert_called_once_with(exc=error)
def test_download_benefit_image_downloads_when_url_exists() -> None:
"""It should download and save benefit image when image_asset_url is present."""
benefit = SimpleNamespace(
image_asset_url="https://example.com/benefit.png",
twitch_id="benefit123",
image_file=MagicMock(),
)
with (
patch("twitch.models.DropBenefit.objects.get", return_value=benefit),
patch("twitch.tasks._download_and_save", return_value=True) as save_mock,
):
download_benefit_image.run(1)
save_mock.assert_called_once_with(
url="https://example.com/benefit.png",
name="benefit123",
file_field=benefit.image_file,
)
def test_download_benefit_image_skips_when_no_url() -> None:
"""It should skip when benefit has no image_asset_url."""
benefit = SimpleNamespace(
image_asset_url=None,
twitch_id="benefit123",
image_file=MagicMock(),
)
with (
patch("twitch.models.DropBenefit.objects.get", return_value=benefit),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_benefit_image.run(1)
save_mock.assert_not_called()
def test_download_benefit_image_skips_when_not_found() -> None:
"""It should skip when benefit does not exist."""
with (
patch(
"twitch.models.DropBenefit.objects.get",
side_effect=DropBenefit.DoesNotExist(),
),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_benefit_image.run(1)
save_mock.assert_not_called()
def test_download_benefit_image_retries_on_error() -> None:
"""It should retry when image download fails unexpectedly."""
benefit = SimpleNamespace(
image_asset_url="https://example.com/benefit.png",
twitch_id="benefit123",
image_file=MagicMock(),
)
error = RuntimeError("boom")
with (
patch("twitch.models.DropBenefit.objects.get", return_value=benefit),
patch("twitch.tasks._download_and_save", side_effect=error),
patch.object(
download_benefit_image,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
download_benefit_image.run(1)
retry_mock.assert_called_once_with(exc=error)
def test_download_reward_campaign_image_downloads_when_url_exists() -> None:
"""It should download and save reward campaign image when image_url is present."""
reward = SimpleNamespace(
image_url="https://example.com/reward.jpg",
twitch_id="reward123",
image_file=MagicMock(),
)
with (
patch("twitch.models.RewardCampaign.objects.get", return_value=reward),
patch("twitch.tasks._download_and_save", return_value=True) as save_mock,
):
download_reward_campaign_image.run(1)
save_mock.assert_called_once_with(
"https://example.com/reward.jpg",
"reward123",
reward.image_file,
)
def test_download_reward_campaign_image_skips_when_no_url() -> None:
"""It should skip when reward has no image_url."""
reward = SimpleNamespace(
image_url=None,
twitch_id="reward123",
image_file=MagicMock(),
)
with (
patch("twitch.models.RewardCampaign.objects.get", return_value=reward),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_reward_campaign_image.run(1)
save_mock.assert_not_called()
def test_download_reward_campaign_image_skips_when_not_found() -> None:
"""It should skip when reward does not exist."""
with (
patch(
"twitch.models.RewardCampaign.objects.get",
side_effect=RewardCampaign.DoesNotExist(),
),
patch("twitch.tasks._download_and_save") as save_mock,
):
download_reward_campaign_image.run(1)
save_mock.assert_not_called()
def test_download_reward_campaign_image_retries_on_error() -> None:
"""It should retry when image download fails unexpectedly."""
reward = SimpleNamespace(
image_url="https://example.com/reward.jpg",
twitch_id="reward123",
image_file=MagicMock(),
)
error = RuntimeError("boom")
with (
patch("twitch.models.RewardCampaign.objects.get", return_value=reward),
patch("twitch.tasks._download_and_save", side_effect=error),
patch.object(
download_reward_campaign_image,
"retry",
side_effect=RuntimeError("retried"),
) as retry_mock,
pytest.raises(RuntimeError, match="retried"),
):
download_reward_campaign_image.run(1)
retry_mock.assert_called_once_with(exc=error)