f56b088643
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
236 lines
9.8 KiB
Python
236 lines
9.8 KiB
Python
"""Tests for the Extend In-Progress Recording feature.
|
|
|
|
Covers:
|
|
- extend() API endpoint (happy path and validation)
|
|
- pre_save signal guard: end_time change must NOT revoke a live recording
|
|
- pre_save signal guard: end_time change MUST still revoke an upcoming recording
|
|
- TOCTOU edge cases (extend on a completed/stopped/nonexistent recording)
|
|
"""
|
|
from datetime import timedelta
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from django.test import TestCase
|
|
from django.utils import timezone
|
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
|
|
|
from apps.channels.models import Channel, Recording
|
|
from apps.channels.api_views import RecordingViewSet
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_admin():
|
|
from django.contrib.auth import get_user_model
|
|
User = get_user_model()
|
|
u, _ = User.objects.get_or_create(
|
|
username="extend_test_admin",
|
|
defaults={"user_level": User.UserLevel.ADMIN},
|
|
)
|
|
u.set_password("pass")
|
|
u.save()
|
|
return u
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extend endpoint tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ExtendEndpointTests(TestCase):
|
|
"""Tests for POST /api/channels/recordings/{id}/extend/"""
|
|
|
|
def setUp(self):
|
|
self.channel = Channel.objects.create(
|
|
channel_number=88, name="Extend Test Channel"
|
|
)
|
|
self.user = _make_admin()
|
|
self.factory = APIRequestFactory()
|
|
|
|
def _extend(self, rec, extra_minutes):
|
|
request = self.factory.post(
|
|
f"/api/channels/recordings/{rec.id}/extend/",
|
|
{"extra_minutes": extra_minutes},
|
|
format="json",
|
|
)
|
|
force_authenticate(request, user=self.user)
|
|
view = RecordingViewSet.as_view({"post": "extend"})
|
|
return view(request, pk=rec.id)
|
|
|
|
def _make_rec(self, status="recording"):
|
|
now = timezone.now()
|
|
return Recording.objects.create(
|
|
channel=self.channel,
|
|
start_time=now - timedelta(hours=1),
|
|
end_time=now + timedelta(hours=1),
|
|
custom_properties={"status": status},
|
|
)
|
|
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_extend_updates_end_time_in_db(self, _ws):
|
|
rec = self._make_rec()
|
|
original_end = rec.end_time
|
|
response = self._extend(rec, 30)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.data.get("success"))
|
|
rec.refresh_from_db()
|
|
expected = original_end + timedelta(minutes=30)
|
|
delta = abs((rec.end_time - expected).total_seconds())
|
|
self.assertLess(delta, 1, "end_time was not extended by the correct amount")
|
|
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_extend_stacks_multiple_extensions(self, _ws):
|
|
"""Calling extend() twice adds both increments."""
|
|
rec = self._make_rec()
|
|
original_end = rec.end_time
|
|
self._extend(rec, 15)
|
|
self._extend(rec, 30)
|
|
rec.refresh_from_db()
|
|
expected = original_end + timedelta(minutes=45)
|
|
delta = abs((rec.end_time - expected).total_seconds())
|
|
self.assertLess(delta, 1)
|
|
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_extend_does_not_clear_task_id(self, _ws):
|
|
"""The running Celery task must survive the DB save."""
|
|
rec = self._make_rec()
|
|
rec.task_id = "dvr-recording-999"
|
|
rec.save(update_fields=["task_id"])
|
|
self._extend(rec, 30)
|
|
rec.refresh_from_db()
|
|
self.assertEqual(rec.task_id, "dvr-recording-999")
|
|
|
|
def test_extend_returns_400_if_finished(self):
|
|
"""Cannot extend a completed, stopped, or interrupted recording."""
|
|
for bad_status in ("completed", "stopped", "interrupted"):
|
|
with self.subTest(status=bad_status):
|
|
rec = self._make_rec(status=bad_status)
|
|
response = self._extend(rec, 30)
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertFalse(response.data.get("success"))
|
|
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_extend_succeeds_before_task_sets_status(self, _ws):
|
|
"""Extend must work when status is empty (task hasn't started yet)."""
|
|
rec = self._make_rec(status="")
|
|
response = self._extend(rec, 15)
|
|
self.assertEqual(response.status_code, 200)
|
|
rec.refresh_from_db()
|
|
expected = rec.end_time # already extended
|
|
self.assertTrue(response.data.get("success"))
|
|
|
|
@patch("apps.channels.signals.revoke_task")
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke):
|
|
"""Extend uses .update() to bypass pre_save — revoke_task must never fire."""
|
|
rec = self._make_rec(status="")
|
|
rec.task_id = "dvr-recording-500"
|
|
rec.save(update_fields=["task_id"])
|
|
self._extend(rec, 15)
|
|
self._extend(rec, 30)
|
|
mock_revoke.assert_not_called()
|
|
rec.refresh_from_db()
|
|
self.assertEqual(rec.task_id, "dvr-recording-500")
|
|
|
|
def test_extend_returns_400_for_zero_minutes(self):
|
|
response = self._extend(self._make_rec(), 0)
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
def test_extend_returns_400_for_negative_minutes(self):
|
|
response = self._extend(self._make_rec(), -15)
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
def test_extend_returns_400_for_non_numeric_minutes(self):
|
|
rec = self._make_rec()
|
|
request = self.factory.post(
|
|
f"/api/channels/recordings/{rec.id}/extend/",
|
|
{"extra_minutes": "lots"},
|
|
format="json",
|
|
)
|
|
force_authenticate(request, user=self.user)
|
|
view = RecordingViewSet.as_view({"post": "extend"})
|
|
response = view(request, pk=rec.id)
|
|
self.assertEqual(response.status_code, 400)
|
|
|
|
def test_extend_returns_404_for_nonexistent_recording(self):
|
|
request = self.factory.post(
|
|
"/api/channels/recordings/999999/extend/",
|
|
{"extra_minutes": 30},
|
|
format="json",
|
|
)
|
|
force_authenticate(request, user=self.user)
|
|
view = RecordingViewSet.as_view({"post": "extend"})
|
|
response = view(request, pk=999999)
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# pre_save signal guard tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class PreSaveExtendGuardTests(TestCase):
|
|
"""The pre_save signal must NOT revoke a live recording when end_time changes,
|
|
but MUST still revoke a scheduled (upcoming) recording as before."""
|
|
|
|
def setUp(self):
|
|
self.channel = Channel.objects.create(
|
|
channel_number=77, name="Signal Guard Channel"
|
|
)
|
|
|
|
def _make_rec(self, status="", task_id="dvr-recording-42"):
|
|
now = timezone.now()
|
|
return Recording.objects.create(
|
|
channel=self.channel,
|
|
start_time=now + timedelta(hours=1),
|
|
end_time=now + timedelta(hours=2),
|
|
task_id=task_id,
|
|
custom_properties={"status": status} if status else {},
|
|
)
|
|
|
|
@patch("apps.channels.signals.revoke_task")
|
|
def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke):
|
|
"""When status='recording', extending end_time must not call revoke_task."""
|
|
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
|
rec.end_time = rec.end_time + timedelta(minutes=30)
|
|
rec.save(update_fields=["end_time"])
|
|
mock_revoke.assert_not_called()
|
|
|
|
@patch("apps.channels.signals.revoke_task")
|
|
def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke):
|
|
"""task_id must not be cleared for a live recording's end_time change."""
|
|
rec = self._make_rec(status="recording", task_id="dvr-recording-42")
|
|
original_task_id = rec.task_id
|
|
rec.end_time = rec.end_time + timedelta(minutes=30)
|
|
rec.save(update_fields=["end_time"])
|
|
rec.refresh_from_db()
|
|
self.assertEqual(rec.task_id, original_task_id)
|
|
|
|
@patch("apps.channels.signals.revoke_task")
|
|
def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke):
|
|
"""The guard must NOT apply to upcoming recordings — existing behavior preserved."""
|
|
rec = self._make_rec(status="", task_id="dvr-recording-77")
|
|
rec.end_time = rec.end_time + timedelta(minutes=30)
|
|
rec.save(update_fields=["end_time"])
|
|
mock_revoke.assert_called_once_with("dvr-recording-77")
|
|
|
|
@patch("apps.channels.signals.revoke_task")
|
|
@patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None)
|
|
def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke):
|
|
"""pre_save reads status from DB (old object), not from the instance being saved."""
|
|
now = timezone.now()
|
|
rec = Recording.objects.create(
|
|
channel=self.channel,
|
|
start_time=now - timedelta(hours=1),
|
|
end_time=now + timedelta(hours=1),
|
|
task_id="dvr-recording-66",
|
|
custom_properties={"status": "recording"},
|
|
)
|
|
# Simulate: DB status changes to 'completed' behind the instance's back
|
|
Recording.objects.filter(pk=rec.pk).update(
|
|
custom_properties={"status": "completed"}
|
|
)
|
|
rec.end_time = rec.end_time + timedelta(minutes=30)
|
|
rec.save(update_fields=["end_time"])
|
|
# revoke_task should be called because DB status is "completed", not "recording"
|
|
mock_revoke.assert_called_once_with("dvr-recording-66")
|