Files
romm/backend/tests/test_startup.py
Georges-Antoine Assi 1ae49b6420 fix(webp): backfill cover conversion on startup when enabled
The frontend rewrites every cover URL to .webp as soon as the heartbeat
reports ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP, but existing covers have
no .webp sibling until the scheduled cron eventually runs (the inline
conversion only covers art fetched after enabling). This produced 404s on
all existing covers until the cron fired.

Enqueue a one-off backfill run of the conversion task on startup when the
feature is enabled, mirroring the recompute-save-hashes pattern. A fixed
job_id + Job.exists guard prevents duplicate jobs across restarts, and the
task already skips covers that have a .webp sibling so repeated runs are
cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:46:08 -04:00

135 lines
5.1 KiB
Python

"""Tests for startup-time auto-enqueue of the recompute task."""
import startup
from rq.job import JOB_ID_PATTERN
def test_enqueue_recompute_skips_when_no_missing_hashes(mocker):
"""Saves all have content_hash -> no enqueue."""
mocker.patch.object(
startup.db_save_handler, "count_saves_missing_content_hash", return_value=0
)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_recompute_save_hashes_if_needed()
enqueue.assert_not_called()
def test_enqueue_recompute_fires_when_missing_hashes_present(mocker):
"""At least one Save row has NULL content_hash -> enqueue exactly once."""
mocker.patch.object(
startup.db_save_handler, "count_saves_missing_content_hash", return_value=42
)
mocker.patch.object(startup.Job, "exists", return_value=False)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_recompute_save_hashes_if_needed()
enqueue.assert_called_once()
args, kwargs = enqueue.call_args
# First positional arg is the bound task.run method
assert args[0].__self__ is startup.recompute_save_content_hashes_task
# Sanity-check the meta payload routes correctly in the task list UI
assert kwargs["meta"]["task_name"] == (
startup.recompute_save_content_hashes_task.title
)
assert kwargs["meta"]["task_type"] == (
startup.recompute_save_content_hashes_task.task_type.value
)
# Job timeout must be passed; otherwise long-running recomputes get killed
# by RQ's default short timeout on very large libraries.
assert kwargs["job_timeout"] == startup.TASK_TIMEOUT
assert kwargs["job_id"] == startup.RECOMPUTE_SAVE_HASHES_JOB_ID
def test_recompute_job_id_is_valid_rq_id():
"""RQ rejects any job_id not matching [A-Za-z0-9_-]+ (ValueError in set_id),
which the broad except here would swallow -> backfill silently never enqueues.
A colon was the original culprit; assert the full contract, not just that."""
assert JOB_ID_PATTERN.fullmatch(startup.RECOMPUTE_SAVE_HASHES_JOB_ID)
def test_enqueue_recompute_skips_when_already_queued(mocker):
"""An in-flight job from a previous restart -> skip enqueue, don't double up."""
mocker.patch.object(
startup.db_save_handler, "count_saves_missing_content_hash", return_value=10
)
mocker.patch.object(startup.Job, "exists", return_value=True)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_recompute_save_hashes_if_needed()
enqueue.assert_not_called()
def test_enqueue_recompute_swallows_count_error(mocker):
"""A failed COUNT query must not crash startup."""
mocker.patch.object(
startup.db_save_handler,
"count_saves_missing_content_hash",
side_effect=RuntimeError("db gone"),
)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_recompute_save_hashes_if_needed()
enqueue.assert_not_called()
def test_enqueue_recompute_swallows_enqueue_error(mocker):
"""A failed enqueue must not crash startup."""
mocker.patch.object(
startup.db_save_handler, "count_saves_missing_content_hash", return_value=5
)
mocker.patch.object(startup.Job, "exists", return_value=False)
mocker.patch.object(
startup.low_prio_queue, "enqueue", side_effect=RuntimeError("redis gone")
)
startup._enqueue_recompute_save_hashes_if_needed()
def test_enqueue_convert_webp_fires_when_not_queued(mocker):
"""No in-flight bootstrap job -> enqueue the backfill exactly once."""
mocker.patch.object(startup.Job, "exists", return_value=False)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_convert_images_to_webp()
enqueue.assert_called_once()
args, kwargs = enqueue.call_args
assert args[0].__self__ is startup.convert_images_to_webp_task
assert kwargs["meta"]["task_name"] == startup.convert_images_to_webp_task.title
assert kwargs["meta"]["task_type"] == (
startup.convert_images_to_webp_task.task_type.value
)
assert kwargs["job_timeout"] == startup.TASK_TIMEOUT
assert kwargs["job_id"] == startup.CONVERT_IMAGES_TO_WEBP_JOB_ID
def test_convert_webp_job_id_is_valid_rq_id():
"""An invalid job_id raises in set_id, which the broad except swallows ->
backfill silently never enqueues. Assert the id matches RQ's contract."""
assert JOB_ID_PATTERN.fullmatch(startup.CONVERT_IMAGES_TO_WEBP_JOB_ID)
def test_enqueue_convert_webp_skips_when_already_queued(mocker):
"""An in-flight job from a previous restart -> skip enqueue, don't double up."""
mocker.patch.object(startup.Job, "exists", return_value=True)
enqueue = mocker.patch.object(startup.low_prio_queue, "enqueue")
startup._enqueue_convert_images_to_webp()
enqueue.assert_not_called()
def test_enqueue_convert_webp_swallows_enqueue_error(mocker):
"""A failed enqueue must not crash startup."""
mocker.patch.object(startup.Job, "exists", return_value=False)
mocker.patch.object(
startup.low_prio_queue, "enqueue", side_effect=RuntimeError("redis gone")
)
startup._enqueue_convert_images_to_webp()