#!/usr/bin/env python3 """ Unit tests for promoting an Add-Watch live snapshot into a real watch. The Add Watch UI fetches a screenshot + xpath element data once (the "/snapshot" endpoint), parks it under datastore/temporary/{uuid} in final on-disk format, and on submit renames that directory into place as the new watch's data_dir - so the watch is created without fetching the page a second time. Run from the tests/ directory: python -m unittest unit/test_temporary_watch_seed.py """ import json import os import shutil import tempfile import time import unittest import uuid import zlib from changedetectionio.store import ChangeDetectionStore class TestTemporaryWatchSeed(unittest.TestCase): def setUp(self): self.test_datastore_path = tempfile.mkdtemp() self.store = ChangeDetectionStore( datastore_path=self.test_datastore_path, include_default_watches=False, ) def tearDown(self): self.store.stop_thread = True time.sleep(0.5) shutil.rmtree(self.test_datastore_path, ignore_errors=True) def _make_temp_snapshot(self, temp_uuid=None): """Create a temporary/{uuid} dir in final watch on-disk format, as /snapshot does.""" temp_uuid = temp_uuid or str(uuid.uuid4()) temp_dir = self.store.get_temporary_watch_dir(temp_uuid) os.makedirs(temp_dir, exist_ok=True) with open(os.path.join(temp_dir, "last-screenshot.png"), 'wb') as f: f.write(b"FAKE-SCREENSHOT-BYTES") with open(os.path.join(temp_dir, "elements.deflate"), 'wb') as f: f.write(zlib.compress(json.dumps([{"xpath": "//h1", "width": 10}]).encode())) return temp_uuid, temp_dir def test_promote_renames_snapshot_into_watch(self): temp_uuid, temp_dir = self._make_temp_snapshot() new_uuid = self.store.make_temporary_watch_active_watch( temp_uuid=temp_uuid, url="https://example.com", extras={'paused': True}, ) self.assertIsNotNone(new_uuid) watch = self.store.data['watching'][new_uuid] # The two seed files are now in the watch's data_dir... self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "last-screenshot.png"))) self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "elements.deflate"))) # ...the visual selector reports ready (no re-fetch needed)... self.assertTrue(self.store.visualselector_data_is_ready(new_uuid)) # ...the watch.json was committed alongside the seeded files... self.assertTrue(os.path.isfile(os.path.join(watch.data_dir, "watch.json"))) # ...and it was a move, not a copy: the temp dir is gone. self.assertFalse(os.path.exists(temp_dir)) # Screenshot content survived the rename intact. with open(os.path.join(watch.data_dir, "last-screenshot.png"), 'rb') as f: self.assertEqual(f.read(), b"FAKE-SCREENSHOT-BYTES") def test_falls_back_to_normal_add_when_uuid_missing(self): # No snapshot was ever parked - submit must still create a working watch. new_uuid = self.store.make_temporary_watch_active_watch( temp_uuid='', url="https://example.com", ) self.assertIsNotNone(new_uuid) self.assertIn(new_uuid, self.store.data['watching']) self.assertFalse(self.store.visualselector_data_is_ready(new_uuid)) def test_falls_back_when_temp_dir_does_not_exist(self): # A well-formed but unknown uuid (e.g. expired/cleaned) must not error. new_uuid = self.store.make_temporary_watch_active_watch( temp_uuid=str(uuid.uuid4()), url="https://example.com", ) self.assertIsNotNone(new_uuid) self.assertIn(new_uuid, self.store.data['watching']) def test_path_traversal_uuid_is_rejected(self): # The temp_uuid arrives from a client POST field - must not escape temporary/. self.assertIsNone(self.store.get_temporary_watch_dir("../../etc")) self.assertIsNone(self.store.get_temporary_watch_dir("not-a-uuid")) self.assertIsNone(self.store.get_temporary_watch_dir("")) self.assertIsNone(self.store.get_temporary_watch_dir(None)) # A valid v4 uuid resolves to a path directly under temporary/ good = str(uuid.uuid4()) resolved = self.store.get_temporary_watch_dir(good) self.assertIsNotNone(resolved) self.assertEqual(os.path.basename(resolved), good) def test_processor_consumes_preloaded_fetch_without_network(self): # Create a watch and drop a preload bundle into its data_dir, as the Add Watch # snapshot -> migrate flow would. The processor's call_browser() seam should then # populate its fetcher from disk instead of hitting the network. from changedetectionio.processors.text_json_diff.processor import perform_site_check new_uuid = self.store.add_watch(url="https://example.com", extras={'paused': True}) watch = self.store.data['watching'][new_uuid] watch.ensure_data_dir_exists() html = "