dashcam-transfer/tests/test_dashcam_transfer.py
Eddy de Vink 8cc04ea409 feat: harden dashcam transfer tegen flakke SD-lezer + stale-mount cleanup
- dashcam-transfer.py: log echte OSError (shutil.copy2 i.p.v. cp), pre-flight
  leesbaarheidscheck voor ruimte-wissen, retry (3x) + early-abort (5x), guards
  op bestandssysteem-calls, WhatsApp GESTOPT bij defecte kaart
- dashcam-transfer-mount.sh: skip als service al draait, read-only fsck.exfat
  health check, %k device-naam doorgeven
- dashcam-transfer-cleanup.sh: nieuw - ruimt stale mount op bij udev REMOVE
- udev/99-dashcam-trigger.rules: ADD + REMOVE regels met %k
- tests: 20 python + 7 mount + 12 cleanup checks
- README: architectuur, robuustheid, deploy-instructies
2026-08-11 20:37:48 +02:00

251 lines
9.6 KiB
Python

import errno
import importlib.util
import json
import logging
import os
import sys
import tempfile
import unittest
from unittest import mock
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODULE_PATH = os.path.join(REPO_ROOT, "dashcam-transfer.py")
spec = importlib.util.spec_from_file_location("dashcam_transfer", MODULE_PATH)
dashcam = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dashcam)
sys.modules["dashcam_transfer"] = dashcam
logging.getLogger().addHandler(logging.NullHandler())
def make_mp4(path, size):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(b"0" * size)
class GetMp4FilesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
def tearDown(self):
self.tmp.cleanup()
def test_matches_front_and_back_files(self):
src = self.tmp.name
make_mp4(os.path.join(src, "20260622050346_0000001F.MP4"), 10)
make_mp4(os.path.join(src, "20260622050346_0000002B.MP4"), 10)
make_mp4(os.path.join(src, "negeer.txt"), 10)
result = dashcam.get_mp4_files(src)
self.assertEqual(len(result), 2)
self.assertIn(("20260622050346_0000001F.MP4", "20260622", "F"), result)
self.assertIn(("20260622050346_0000002B.MP4", "20260622", "B"), result)
def test_missing_dir_returns_empty_list(self):
self.assertEqual(dashcam.get_mp4_files(os.path.join(self.tmp.name, "nope")), [])
def test_listdir_error_returns_none(self):
with mock.patch.object(dashcam.os, "listdir", side_effect=OSError(errno.EIO, "I/O error")):
self.assertIsNone(dashcam.get_mp4_files(self.tmp.name))
class CheckSourceReadableTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = self.tmp.name
def tearDown(self):
self.tmp.cleanup()
def test_readable_dir_returns_true(self):
fname = "20260622050346_0000001F.MP4"
make_mp4(os.path.join(self.src, fname), 10)
self.assertTrue(dashcam.check_source_readable(self.src, [(fname, "20260622", "F")]))
def test_io_error_on_read_returns_false(self):
fname = "20260622050346_0000001F.MP4"
make_mp4(os.path.join(self.src, fname), 10)
with mock.patch("builtins.open", side_effect=OSError(errno.EIO, "I/O error")):
self.assertFalse(dashcam.check_source_readable(self.src, [(fname, "20260622", "F")]))
class CopyFileTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = os.path.join(self.tmp.name, "src.mp4")
self.dst = os.path.join(self.tmp.name, "dst.mp4")
make_mp4(self.src, 100)
def tearDown(self):
self.tmp.cleanup()
def test_copies_file(self):
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0)
self.assertEqual(os.path.getsize(self.dst), 100)
def test_retries_then_succeeds(self):
real_copy2 = dashcam.shutil.copy2
calls = {"n": 0}
def flaky(s, d):
calls["n"] += 1
if calls["n"] < 3:
raise OSError(errno.EIO, "I/O error")
real_copy2(s, d)
with mock.patch.object(dashcam.shutil, "copy2", side_effect=flaky):
dashcam.copy_file(self.src, self.dst, attempts=3, delay=0)
self.assertEqual(calls["n"], 3)
self.assertEqual(os.path.getsize(self.dst), 100)
def test_raises_after_exhausting_attempts(self):
with mock.patch.object(dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error")):
with self.assertRaises(OSError):
dashcam.copy_file(self.src, self.dst, attempts=2, delay=0)
class TransferFilesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.src = os.path.join(self.tmp.name, "src")
self.base = os.path.join(self.tmp.name, "base")
os.makedirs(self.src)
os.makedirs(self.base)
def tearDown(self):
self.tmp.cleanup()
def _make(self, fname, size):
make_mp4(os.path.join(self.src, fname), size)
return (fname, fname[:8], fname[-5])
def test_transfers_files_to_camera_subdirs(self):
files = [
self._make("20260622050346_0000001F.MP4", 100),
self._make("20260622050346_0000002B.MP4", 200),
]
ok, errors = dashcam.transfer_files(files, self.src, self.base)
self.assertEqual((ok, errors), (2, 0))
self.assertFalse(os.path.exists(os.path.join(self.src, files[0][0])))
self.assertEqual(
os.path.getsize(os.path.join(self.base, "20260622", "front", files[0][0])), 100
)
self.assertEqual(
os.path.getsize(os.path.join(self.base, "20260622", "back", files[1][0])), 200
)
def test_skips_existing_matching_destination(self):
files = [self._make("20260622050346_0000001F.MP4", 100)]
dst = os.path.join(self.base, "20260622", "front", files[0][0])
make_mp4(dst, 100)
ok, errors = dashcam.transfer_files(files, self.src, self.base)
self.assertEqual((ok, errors), (1, 0))
self.assertFalse(os.path.exists(os.path.join(self.src, files[0][0])))
self.assertEqual(os.path.getsize(dst), 100)
def test_overwrites_existing_different_destination(self):
files = [self._make("20260622050346_0000001F.MP4", 100)]
dst = os.path.join(self.base, "20260622", "front", files[0][0])
make_mp4(dst, 10)
ok, errors = dashcam.transfer_files(files, self.src, self.base)
self.assertEqual((ok, errors), (1, 0))
self.assertEqual(os.path.getsize(dst), 100)
def test_verification_mismatch_counts_as_error_and_keeps_source(self):
files = [self._make("20260622050346_0000001F.MP4", 100)]
def bad_copy2(s, d):
with open(d, "wb") as f:
f.write(b"0" * 50)
with mock.patch.object(dashcam.shutil, "copy2", side_effect=bad_copy2), mock.patch.object(
dashcam.time, "sleep"
):
ok, errors = dashcam.transfer_files(files, self.src, self.base)
self.assertEqual((ok, errors), (0, 1))
self.assertTrue(os.path.exists(os.path.join(self.src, files[0][0])))
def test_aborts_after_max_consecutive_errors(self):
files = [
self._make("2026062205034%d_000000%dF.MP4" % (i, i + 1), 10)
for i in range(dashcam.MAX_CONSECUTIVE_ERRORS + 3)
]
with mock.patch.object(
dashcam.shutil, "copy2", side_effect=OSError(errno.EIO, "I/O error")
), mock.patch.object(dashcam.time, "sleep"):
with self.assertRaises(dashcam.TransferAborted) as ctx:
dashcam.transfer_files(files, self.src, self.base)
self.assertEqual(ctx.exception.consecutive, dashcam.MAX_CONSECUTIVE_ERRORS)
self.assertEqual(ctx.exception.ok, 0)
for fname, _, _ in files:
self.assertTrue(os.path.exists(os.path.join(self.src, fname)))
class EnsureFreeSpaceTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.base = self.tmp.name
def tearDown(self):
self.tmp.cleanup()
@staticmethod
def _statvfs(free_bytes):
frsize = 4096
return mock.Mock(f_frsize=frsize, f_bavail=free_bytes // frsize)
def test_no_deletion_when_enough_space(self):
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(10_000_000)):
dashcam.ensure_free_space(1000, self.base)
def test_deletes_oldest_date_dirs_until_enough(self):
for name, fname in (("20260301", "a.mp4"), ("20260310", "b.mp4"), ("20260811", "c.mp4")):
make_mp4(os.path.join(self.base, name, fname), 5000)
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(4096)):
dashcam.ensure_free_space(5000, self.base)
self.assertFalse(os.path.exists(os.path.join(self.base, "20260301")))
self.assertTrue(os.path.exists(os.path.join(self.base, "20260310")))
self.assertTrue(os.path.exists(os.path.join(self.base, "20260811")))
def test_exits_when_still_insufficient(self):
make_mp4(os.path.join(self.base, "20260301", "a.mp4"), 100)
with mock.patch.object(dashcam.os, "statvfs", return_value=self._statvfs(0)):
with self.assertRaises(SystemExit):
dashcam.ensure_free_space(10_000, self.base)
class SendWhatsappTest(unittest.TestCase):
def setUp(self):
patcher = mock.patch.object(dashcam.urllib.request, "urlopen")
self.mock_urlopen = patcher.start()
self.mock_urlopen.return_value.__enter__.return_value = mock.Mock()
self.addCleanup(patcher.stop)
def _body(self):
req = self.mock_urlopen.call_args[0][0]
return json.loads(req.data)["content"]
def test_success_message(self):
dashcam.send_whatsapp_notification(5, 0, 381_000_000)
self.assertIn("5 bestanden", self._body())
self.assertIn("geen fouten", self._body())
def test_errors_message(self):
dashcam.send_whatsapp_notification(3, 2, 100_000_000)
self.assertIn("2 fout(en)", self._body())
def test_aborted_message(self):
dashcam.send_whatsapp_notification(
1, 4, 100_000_000, aborted=True, reason="SD-kaart onleesbaar"
)
self.assertIn("GESTOPT", self._body())
self.assertIn("SD-kaart onleesbaar", self._body())
def test_api_failure_is_swallowed(self):
self.mock_urlopen.side_effect = Exception("boom")
dashcam.send_whatsapp_notification(0, 0, 0)
if __name__ == "__main__":
unittest.main()