diff --git a/changedetectionio/api/__init__.py b/changedetectionio/api/__init__.py
index 088da8a1f..c95a37005 100644
--- a/changedetectionio/api/__init__.py
+++ b/changedetectionio/api/__init__.py
@@ -12,9 +12,17 @@ schema = api_schema.build_watch_json_schema(watch_base_config)
schema_create_watch = copy.deepcopy(schema)
schema_create_watch['required'] = ['url']
del schema_create_watch['properties']['last_viewed']
+# Allow processor_config_* fields (handled separately in endpoint)
+schema_create_watch['patternProperties'] = {
+ '^processor_config_': {'type': ['string', 'number', 'boolean', 'object', 'array', 'null']}
+}
schema_update_watch = copy.deepcopy(schema)
schema_update_watch['additionalProperties'] = False
+# Allow processor_config_* fields (handled separately in endpoint)
+schema_update_watch['patternProperties'] = {
+ '^processor_config_': {'type': ['string', 'number', 'boolean', 'object', 'array', 'null']}
+}
# Tag schema is also based on watch_base since Tag inherits from it
schema_tag = copy.deepcopy(schema)
diff --git a/changedetectionio/model/__init__.py b/changedetectionio/model/__init__.py
index 1a60a7652..ce22d598c 100644
--- a/changedetectionio/model/__init__.py
+++ b/changedetectionio/model/__init__.py
@@ -6,6 +6,127 @@ USE_SYSTEM_DEFAULT_NOTIFICATION_FORMAT_FOR_WATCH = 'System default'
CONDITIONS_MATCH_LOGIC_DEFAULT = 'ALL'
class watch_base(dict):
+ """
+ Base watch domain model (inherits from dict for backward compatibility).
+
+ WARNING: This class inherits from dict, which violates proper encapsulation.
+ Dict inheritance is legacy technical debt that should be refactored to a proper
+ domain model (e.g., Pydantic BaseModel) for better type safety and validation.
+
+ TODO: Migrate to Pydantic BaseModel or dataclass for:
+ - Type safety and IDE autocomplete
+ - Automatic validation
+ - Clear separation between domain model and serialization
+ - Immutability options
+ - Better testing
+
+ Core Fields:
+ uuid (str): Unique identifier for this watch (auto-generated)
+ url (str): Target URL to monitor for changes
+ title (str|None): Custom display name (overrides page_title if set)
+ page_title (str|None): Title extracted from
tag of monitored page
+ tags (List[str]): List of tag UUIDs for categorization
+ tag (str): DEPRECATED - Old single-tag system, use tags instead
+
+ Check Configuration:
+ processor (str): Processor type ('text_json_diff', 'restock_diff', etc.)
+ fetch_backend (str): Fetcher to use ('system', 'html_requests', 'playwright', etc.)
+ method (str): HTTP method ('GET', 'POST', etc.)
+ headers (dict): Custom HTTP headers to send
+ proxy (str|None): Preferred proxy server
+ paused (bool): Whether change detection is paused
+
+ Scheduling:
+ time_between_check (dict): Check interval {'weeks': int, 'days': int, 'hours': int, 'minutes': int, 'seconds': int}
+ time_between_check_use_default (bool): Use global default interval if True
+ time_schedule_limit (dict): Weekly schedule limiting when checks can run
+ Structure: {
+ 'enabled': bool,
+ 'monday/tuesday/.../sunday': {
+ 'enabled': bool,
+ 'start_time': str ('HH:MM'),
+ 'duration': {'hours': str, 'minutes': str}
+ }
+ }
+
+ Content Filtering:
+ include_filters (List[str]): CSS/XPath selectors to extract content
+ subtractive_selectors (List[str]): Selectors to remove from content
+ ignore_text (List[str]): Text patterns to ignore in change detection
+ trigger_text (List[str]): Text/regex that must be present to trigger change
+ text_should_not_be_present (List[str]): Text that should NOT be present
+ extract_text (List[str]): Regex patterns to extract specific text after filtering
+
+ Text Processing:
+ trim_text_whitespace (bool): Strip leading/trailing whitespace
+ sort_text_alphabetically (bool): Sort lines alphabetically before comparison
+ remove_duplicate_lines (bool): Remove duplicate lines
+ check_unique_lines (bool): Compare against all history for unique lines
+ strip_ignored_lines (bool|None): Remove lines matching ignore patterns
+
+ Change Detection Filters:
+ filter_text_added (bool): Include added text in change detection
+ filter_text_removed (bool): Include removed text in change detection
+ filter_text_replaced (bool): Include replaced text in change detection
+
+ Browser Automation:
+ browser_steps (List[dict]): Browser automation steps for JS-heavy sites
+ browser_steps_last_error_step (int|None): Last step that caused error
+ webdriver_delay (int|None): Seconds to wait after page load
+ webdriver_js_execute_code (str|None): JavaScript to execute before extraction
+
+ Restock Detection:
+ in_stock_only (bool): Only trigger on in-stock transitions
+ follow_price_changes (bool): Monitor price changes
+ has_ldjson_price_data (bool|None): Whether page has LD-JSON price data
+ track_ldjson_price_data (str|None): Track LD-JSON price data ('ACCEPT', 'REJECT', None)
+ price_change_threshold_percent (float|None): Minimum price change % to trigger
+
+ Notifications:
+ notification_urls (List[str]): Apprise URLs for notifications
+ notification_title (str|None): Custom notification title template
+ notification_body (str|None): Custom notification body template
+ notification_format (str): Notification format (e.g., 'System default', 'Text', 'HTML')
+ notification_muted (bool): Disable notifications for this watch
+ notification_screenshot (bool): Include screenshot in notifications
+ notification_alert_count (int): Number of notifications sent
+ last_notification_error (str|None): Last notification error message
+ body (str|None): DEPRECATED? Legacy notification body field
+ filter_failure_notification_send (bool): Send notification on filter failures
+
+ History & State:
+ date_created (int|None): Unix timestamp of watch creation
+ last_checked (int): Unix timestamp of last check
+ last_viewed (int): History snapshot key of last user view
+ last_error (str|bool): Last error message or False if no error
+ check_count (int): Total number of checks performed
+ fetch_time (float): Duration of last fetch in seconds
+ consecutive_filter_failures (int): Counter for consecutive filter match failures
+ previous_md5 (str|bool): MD5 hash of previous content
+ previous_md5_before_filters (str|bool): MD5 hash before filters applied
+ history_snapshot_max_length (int|None): Max history snapshots to keep (None = use global)
+
+ Conditions:
+ conditions (dict): Custom conditions for change detection logic
+ conditions_match_logic (str): Logic operator ('ALL', 'ANY') for conditions
+
+ Metadata:
+ content-type (str|None): Content-Type from last fetch
+ remote_server_reply (str|None): Server header from last response
+ ignore_status_codes (List[int]|None): HTTP status codes to ignore
+ use_page_title_in_list (bool|None): Display page title in watch list (None = use system default)
+
+ Instance Attributes (not serialized):
+ __datastore: Reference to parent DataStore (set externally after creation)
+ watch_data_dir: Filesystem path for this watch's data directory
+
+ Notes:
+ - Many fields default to None to distinguish "not set" from "set to default"
+ - When field is None, system-level defaults are used
+ - Processor-specific configs (e.g., processor_config_*) are NOT stored in watch.json
+ They are stored in separate {processor_name}.json files
+ - This class is used for both Watch and Tag objects (tags reuse the structure)
+ """
def __init__(self, *arg, **kw):
self.update({
diff --git a/changedetectionio/tests/test_api.py b/changedetectionio/tests/test_api.py
index 19f1d0f6b..659ff897d 100644
--- a/changedetectionio/tests/test_api.py
+++ b/changedetectionio/tests/test_api.py
@@ -465,7 +465,10 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage, datasto
assert res.status_code == 400, "Should get error 400 when we give a field that doesnt exist"
# Message will come from `flask_expects_json`
- assert b'Additional properties are not allowed' in res.data
+ # With patternProperties for processor_config_*, the error message format changed slightly
+ assert (b'Additional properties are not allowed' in res.data or
+ b'does not match any of the regexes' in res.data), \
+ "Should reject unknown fields with schema validation error"
# Try a XSS URL
diff --git a/changedetectionio/tests/test_commit_persistence.py b/changedetectionio/tests/test_commit_persistence.py
new file mode 100644
index 000000000..3d8d062bf
--- /dev/null
+++ b/changedetectionio/tests/test_commit_persistence.py
@@ -0,0 +1,661 @@
+#!/usr/bin/env python3
+"""
+Tests for immediate commit-based persistence system.
+
+Tests cover:
+- Watch.commit() persistence to disk
+- Concurrent commit safety (race conditions)
+- Processor config separation
+- Data loss prevention (settings, tags, watch modifications)
+"""
+
+import json
+import os
+import threading
+import time
+from flask import url_for
+from .util import wait_for_all_checks
+
+
+# ==============================================================================
+# 2. Commit() Persistence Tests
+# ==============================================================================
+
+def test_watch_commit_persists_to_disk(client, live_server):
+ """Test that watch.commit() actually writes to watch.json immediately"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Create a watch
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Original Title'})
+ watch = datastore.data['watching'][uuid]
+
+ # Modify and commit
+ watch['title'] = 'Modified Title'
+ watch['paused'] = True
+ watch.commit()
+
+ # Read directly from disk (bypass datastore cache)
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ assert os.path.exists(watch_json_path), "watch.json should exist on disk"
+
+ with open(watch_json_path, 'r') as f:
+ disk_data = json.load(f)
+
+ assert disk_data['title'] == 'Modified Title', "Title should be persisted to disk"
+ assert disk_data['paused'] == True, "Paused state should be persisted to disk"
+ assert disk_data['uuid'] == uuid, "UUID should match"
+
+
+def test_watch_commit_survives_reload(client, live_server):
+ """Test that committed changes survive datastore reload"""
+ from changedetectionio.store import ChangeDetectionStore
+
+ datastore = client.application.config.get('DATASTORE')
+ datastore_path = datastore.datastore_path
+
+ # Create and modify a watch
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test Watch'})
+ watch = datastore.data['watching'][uuid]
+ watch['title'] = 'Persisted Title'
+ watch['paused'] = True
+ watch['tags'] = ['tag-1', 'tag-2']
+ watch.commit()
+
+ # Simulate app restart - create new datastore instance
+ datastore2 = ChangeDetectionStore(datastore_path=datastore_path)
+ datastore2.reload_state(
+ datastore_path=datastore_path,
+ include_default_watches=False,
+ version_tag='test'
+ )
+
+ # Check data survived
+ assert uuid in datastore2.data['watching'], "Watch should exist after reload"
+ reloaded_watch = datastore2.data['watching'][uuid]
+ assert reloaded_watch['title'] == 'Persisted Title', "Title should survive reload"
+ assert reloaded_watch['paused'] == True, "Paused state should survive reload"
+ assert reloaded_watch['tags'] == ['tag-1', 'tag-2'], "Tags should survive reload"
+
+
+def test_watch_commit_atomic_on_crash(client, live_server):
+ """Test that atomic writes prevent corruption (temp file pattern)"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Original'})
+ watch = datastore.data['watching'][uuid]
+
+ # First successful commit
+ watch['title'] = 'First Save'
+ watch.commit()
+
+ # Verify watch.json exists and is valid
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f) # Should not raise JSONDecodeError
+ assert data['title'] == 'First Save'
+
+ # Second commit - even if interrupted, original file should be intact
+ # (atomic write uses temp file + rename, so original is never corrupted)
+ watch['title'] = 'Second Save'
+ watch.commit()
+
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['title'] == 'Second Save'
+
+
+def test_multiple_watches_commit_independently(client, live_server):
+ """Test that committing one watch doesn't affect others"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Create multiple watches
+ uuid1 = datastore.add_watch(url='http://example1.com', extras={'title': 'Watch 1'})
+ uuid2 = datastore.add_watch(url='http://example2.com', extras={'title': 'Watch 2'})
+ uuid3 = datastore.add_watch(url='http://example3.com', extras={'title': 'Watch 3'})
+
+ watch1 = datastore.data['watching'][uuid1]
+ watch2 = datastore.data['watching'][uuid2]
+ watch3 = datastore.data['watching'][uuid3]
+
+ # Modify and commit only watch2
+ watch2['title'] = 'Modified Watch 2'
+ watch2['paused'] = True
+ watch2.commit()
+
+ # Read all from disk
+ def read_watch_json(uuid):
+ watch = datastore.data['watching'][uuid]
+ path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(path, 'r') as f:
+ return json.load(f)
+
+ data1 = read_watch_json(uuid1)
+ data2 = read_watch_json(uuid2)
+ data3 = read_watch_json(uuid3)
+
+ # Only watch2 should have changes
+ assert data1['title'] == 'Watch 1', "Watch 1 should be unchanged"
+ assert data1['paused'] == False, "Watch 1 should not be paused"
+
+ assert data2['title'] == 'Modified Watch 2', "Watch 2 should be modified"
+ assert data2['paused'] == True, "Watch 2 should be paused"
+
+ assert data3['title'] == 'Watch 3', "Watch 3 should be unchanged"
+ assert data3['paused'] == False, "Watch 3 should not be paused"
+
+
+# ==============================================================================
+# 3. Concurrency/Race Condition Tests
+# ==============================================================================
+
+def test_concurrent_watch_commits_dont_corrupt(client, live_server):
+ """Test that simultaneous commits to same watch don't corrupt JSON"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})
+ watch = datastore.data['watching'][uuid]
+
+ errors = []
+
+ def modify_and_commit(field, value):
+ try:
+ watch[field] = value
+ watch.commit()
+ except Exception as e:
+ errors.append(e)
+
+ # Run 10 concurrent commits
+ threads = []
+ for i in range(10):
+ t = threading.Thread(target=modify_and_commit, args=('title', f'Title {i}'))
+ threads.append(t)
+ t.start()
+
+ for t in threads:
+ t.join()
+
+ # Should not have any errors
+ assert len(errors) == 0, f"Expected no errors, got: {errors}"
+
+ # JSON file should still be valid (not corrupted)
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f) # Should not raise JSONDecodeError
+ assert data['uuid'] == uuid, "UUID should still be correct"
+ assert 'Title' in data['title'], "Title should contain 'Title'"
+
+
+def test_concurrent_modifications_during_commit(client, live_server):
+ """Test that modifying watch during commit doesn't cause RuntimeError"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})
+ watch = datastore.data['watching'][uuid]
+
+ errors = []
+ stop_flag = threading.Event()
+
+ def keep_modifying():
+ """Continuously modify watch"""
+ try:
+ i = 0
+ while not stop_flag.is_set():
+ watch['title'] = f'Title {i}'
+ watch['paused'] = i % 2 == 0
+ i += 1
+ time.sleep(0.001)
+ except Exception as e:
+ errors.append(('modifier', e))
+
+ def keep_committing():
+ """Continuously commit watch"""
+ try:
+ for _ in range(20):
+ watch.commit()
+ time.sleep(0.005)
+ except Exception as e:
+ errors.append(('committer', e))
+
+ # Start concurrent modification and commits
+ modifier = threading.Thread(target=keep_modifying)
+ committer = threading.Thread(target=keep_committing)
+
+ modifier.start()
+ committer.start()
+
+ committer.join()
+ stop_flag.set()
+ modifier.join()
+
+ # Should not have RuntimeError from dict changing during iteration
+ runtime_errors = [e for source, e in errors if isinstance(e, RuntimeError)]
+ assert len(runtime_errors) == 0, f"Should not have RuntimeError, got: {runtime_errors}"
+
+
+def test_datastore_lock_protects_commit_snapshot(client, live_server):
+ """Test that datastore.lock prevents race conditions during deepcopy"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Test'})
+ watch = datastore.data['watching'][uuid]
+
+ # Add some complex nested data
+ watch['browser_steps'] = [
+ {'operation': 'click', 'selector': '#foo'},
+ {'operation': 'wait', 'seconds': 5}
+ ]
+
+ errors = []
+ commits_succeeded = [0]
+
+ def rapid_commits():
+ try:
+ for i in range(50):
+ watch['title'] = f'Title {i}'
+ watch.commit()
+ commits_succeeded[0] += 1
+ time.sleep(0.001)
+ except Exception as e:
+ errors.append(e)
+
+ # Multiple threads doing rapid commits
+ threads = [threading.Thread(target=rapid_commits) for _ in range(3)]
+
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert len(errors) == 0, f"Expected no errors, got: {errors}"
+ assert commits_succeeded[0] == 150, f"Expected 150 commits, got {commits_succeeded[0]}"
+
+ # Final JSON should be valid
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['uuid'] == uuid
+
+
+# ==============================================================================
+# 4. Processor Config Separation Tests
+# ==============================================================================
+
+def test_processor_config_never_in_watch_json(client, live_server):
+ """Test that processor_config_* fields are filtered out of watch.json"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(
+ url='http://example.com',
+ extras={
+ 'title': 'Test Watch',
+ 'processor': 'restock_diff'
+ }
+ )
+
+ watch = datastore.data['watching'][uuid]
+
+ # Try to set processor config fields (these should be filtered during commit)
+ watch['processor_config_price_threshold'] = 10.0
+ watch['processor_config_some_setting'] = 'value'
+ watch['processor_config_another'] = {'nested': 'data'}
+ watch.commit()
+
+ # Read watch.json from disk
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+
+ # Verify processor_config_* fields are NOT in watch.json
+ for key in data.keys():
+ assert not key.startswith('processor_config_'), \
+ f"Found {key} in watch.json - processor configs should be in separate file!"
+
+ # Normal fields should still be there
+ assert data['title'] == 'Test Watch'
+ assert data['processor'] == 'restock_diff'
+
+
+def test_api_post_saves_processor_config_separately(client, live_server):
+ """Test that API POST saves processor configs to {processor}.json"""
+ import json
+ from changedetectionio.processors import extract_processor_config_from_form_data
+
+ # Get API key
+ api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+
+ # Create watch via API with processor config
+ response = client.post(
+ url_for("createwatch"),
+ data=json.dumps({
+ 'url': 'http://example.com',
+ 'processor': 'restock_diff',
+ 'processor_config_price_threshold': 10.0,
+ 'processor_config_in_stock_only': True
+ }),
+ headers={'content-type': 'application/json', 'x-api-key': api_key}
+ )
+
+ assert response.status_code in (200, 201), f"Expected 200/201, got {response.status_code}"
+ uuid = response.json.get('uuid')
+ assert uuid, "Should return UUID"
+
+ datastore = client.application.config.get('DATASTORE')
+ watch = datastore.data['watching'][uuid]
+
+ # Check that processor config file exists
+ processor_config_path = os.path.join(watch.watch_data_dir, 'restock_diff.json')
+ assert os.path.exists(processor_config_path), "Processor config file should exist"
+
+ with open(processor_config_path, 'r') as f:
+ config = json.load(f)
+
+ # Verify fields are saved WITHOUT processor_config_ prefix
+ assert config.get('price_threshold') == 10.0, "Should have price_threshold (no prefix)"
+ assert config.get('in_stock_only') == True, "Should have in_stock_only (no prefix)"
+ assert 'processor_config_price_threshold' not in config, "Should NOT have prefixed keys"
+
+
+def test_api_put_saves_processor_config_separately(client, live_server):
+ """Test that API PUT updates processor configs in {processor}.json"""
+ import json
+ datastore = client.application.config.get('DATASTORE')
+
+ # Get API key
+ api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+
+ # Create watch
+ uuid = datastore.add_watch(
+ url='http://example.com',
+ extras={'processor': 'restock_diff'}
+ )
+
+ # Update via API with processor config
+ response = client.put(
+ url_for("watch", uuid=uuid),
+ data=json.dumps({
+ 'processor_config_price_threshold': 15.0,
+ 'processor_config_min_stock': 5
+ }),
+ headers={'content-type': 'application/json', 'x-api-key': api_key}
+ )
+
+ # PUT might return different status codes, 200 or 204 are both OK
+ assert response.status_code in (200, 204), f"Expected 200/204, got {response.status_code}: {response.data}"
+
+ watch = datastore.data['watching'][uuid]
+
+ # Check processor config file
+ processor_config_path = os.path.join(watch.watch_data_dir, 'restock_diff.json')
+ assert os.path.exists(processor_config_path), "Processor config file should exist"
+
+ with open(processor_config_path, 'r') as f:
+ config = json.load(f)
+
+ assert config.get('price_threshold') == 15.0, "Should have updated price_threshold"
+ assert config.get('min_stock') == 5, "Should have min_stock"
+
+
+def test_ui_edit_saves_processor_config_separately(client, live_server):
+ """Test that processor_config_* fields never appear in watch.json (even from UI)"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Create watch
+ uuid = datastore.add_watch(
+ url='http://example.com',
+ extras={'processor': 'text_json_diff', 'title': 'Test'}
+ )
+
+ watch = datastore.data['watching'][uuid]
+
+ # Simulate someone accidentally trying to set processor_config fields directly
+ watch['processor_config_should_not_save'] = 'test_value'
+ watch['processor_config_another_field'] = 123
+ watch['normal_field'] = 'this_should_save'
+ watch.commit()
+
+ # Check watch.json has NO processor_config_* fields (main point of this test)
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ watch_data = json.load(f)
+
+ for key in watch_data.keys():
+ assert not key.startswith('processor_config_'), \
+ f"Found {key} in watch.json - processor configs should be filtered during commit"
+
+ # Verify normal fields still save
+ assert watch_data['normal_field'] == 'this_should_save', "Normal fields should save"
+ assert watch_data['title'] == 'Test', "Original fields should still be there"
+
+
+def test_browser_steps_normalized_to_empty_list(client, live_server):
+ """Test that meaningless browser_steps are normalized to [] during commit"""
+ datastore = client.application.config.get('DATASTORE')
+
+ uuid = datastore.add_watch(url='http://example.com')
+ watch = datastore.data['watching'][uuid]
+
+ # Set browser_steps to meaningless values
+ watch['browser_steps'] = [
+ {'operation': 'Choose one', 'selector': ''},
+ {'operation': 'Goto site', 'selector': ''},
+ {'operation': '', 'selector': '#foo'}
+ ]
+ watch.commit()
+
+ # Read from disk
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+
+ # Should be normalized to empty list
+ assert data['browser_steps'] == [], "Meaningless browser_steps should be normalized to []"
+
+
+# ==============================================================================
+# 5. Data Loss Prevention Tests
+# ==============================================================================
+
+def test_settings_persist_after_update(client, live_server):
+ """Test that settings updates are committed and survive restart"""
+ from changedetectionio.store import ChangeDetectionStore
+
+ datastore = client.application.config.get('DATASTORE')
+ datastore_path = datastore.datastore_path
+
+ # Update settings directly (bypass form validation issues)
+ datastore.data['settings']['application']['empty_pages_are_a_change'] = True
+ datastore.data['settings']['application']['fetch_backend'] = 'html_requests'
+ datastore.data['settings']['requests']['time_between_check']['minutes'] = 120
+ datastore.commit()
+
+ # Simulate restart
+ datastore2 = ChangeDetectionStore(datastore_path=datastore_path)
+ datastore2.reload_state(
+ datastore_path=datastore_path,
+ include_default_watches=False,
+ version_tag='test'
+ )
+
+ # Verify settings survived
+ assert datastore2.data['settings']['application']['empty_pages_are_a_change'] == True, "empty_pages_are_a_change should persist"
+ assert datastore2.data['settings']['application']['fetch_backend'] == 'html_requests', "fetch_backend should persist"
+ assert datastore2.data['settings']['requests']['time_between_check']['minutes'] == 120, "time_between_check should persist"
+
+
+def test_tag_mute_persists(client, live_server):
+ """Test that tag mute/unmute operations persist"""
+ from changedetectionio.store import ChangeDetectionStore
+
+ datastore = client.application.config.get('DATASTORE')
+ datastore_path = datastore.datastore_path
+
+ # Add a tag
+ tag_uuid = datastore.add_tag('Test Tag')
+
+ # Mute the tag
+ response = client.get(url_for("tags.mute", uuid=tag_uuid))
+ assert response.status_code == 302 # Redirect
+
+ # Verify muted in memory
+ assert datastore.data['settings']['application']['tags'][tag_uuid]['notification_muted'] == True
+
+ # Simulate restart
+ datastore2 = ChangeDetectionStore(datastore_path=datastore_path)
+ datastore2.reload_state(
+ datastore_path=datastore_path,
+ include_default_watches=False,
+ version_tag='test'
+ )
+
+ # Verify mute state survived
+ assert tag_uuid in datastore2.data['settings']['application']['tags']
+ assert datastore2.data['settings']['application']['tags'][tag_uuid]['notification_muted'] == True
+
+
+def test_tag_delete_removes_from_watches(client, live_server):
+ """Test that deleting a tag removes it from all watches"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Create a tag
+ tag_uuid = datastore.add_tag('Test Tag')
+
+ # Create watches with this tag
+ uuid1 = datastore.add_watch(url='http://example1.com')
+ uuid2 = datastore.add_watch(url='http://example2.com')
+ uuid3 = datastore.add_watch(url='http://example3.com')
+
+ watch1 = datastore.data['watching'][uuid1]
+ watch2 = datastore.data['watching'][uuid2]
+ watch3 = datastore.data['watching'][uuid3]
+
+ watch1['tags'] = [tag_uuid]
+ watch1.commit()
+ watch2['tags'] = [tag_uuid, 'other-tag']
+ watch2.commit()
+ # watch3 has no tags
+
+ # Delete the tag
+ response = client.get(url_for("tags.delete", uuid=tag_uuid))
+ assert response.status_code == 302
+
+ # Wait for background thread to complete
+ time.sleep(1)
+
+ # Tag should be removed from settings
+ assert tag_uuid not in datastore.data['settings']['application']['tags']
+
+ # Tag should be removed from watches and persisted
+ def check_watch_tags(uuid):
+ watch = datastore.data['watching'][uuid]
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ return json.load(f)['tags']
+
+ assert tag_uuid not in check_watch_tags(uuid1), "Tag should be removed from watch1"
+ assert tag_uuid not in check_watch_tags(uuid2), "Tag should be removed from watch2"
+ assert 'other-tag' in check_watch_tags(uuid2), "Other tags should remain in watch2"
+ assert check_watch_tags(uuid3) == [], "Watch3 should still have empty tags"
+
+
+def test_watch_pause_unpause_persists(client, live_server):
+ """Test that pause/unpause operations commit and persist"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Get API key
+ api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+
+ uuid = datastore.add_watch(url='http://example.com')
+ watch = datastore.data['watching'][uuid]
+
+ # Pause via API
+ response = client.get(url_for("watch", uuid=uuid, paused='paused'), headers={'x-api-key': api_key})
+ assert response.status_code == 200
+
+ # Check persisted to disk
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['paused'] == True, "Pause should be persisted"
+
+ # Unpause
+ response = client.get(url_for("watch", uuid=uuid, paused='unpaused'), headers={'x-api-key': api_key})
+ assert response.status_code == 200
+
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['paused'] == False, "Unpause should be persisted"
+
+
+def test_watch_mute_unmute_persists(client, live_server):
+ """Test that mute/unmute operations commit and persist"""
+ datastore = client.application.config.get('DATASTORE')
+
+ # Get API key
+ api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+
+ uuid = datastore.add_watch(url='http://example.com')
+ watch = datastore.data['watching'][uuid]
+
+ # Mute via API
+ response = client.get(url_for("watch", uuid=uuid, muted='muted'), headers={'x-api-key': api_key})
+ assert response.status_code == 200
+
+ # Check persisted to disk
+ watch_json_path = os.path.join(watch.watch_data_dir, 'watch.json')
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['notification_muted'] == True, "Mute should be persisted"
+
+ # Unmute
+ response = client.get(url_for("watch", uuid=uuid, muted='unmuted'), headers={'x-api-key': api_key})
+ assert response.status_code == 200
+
+ with open(watch_json_path, 'r') as f:
+ data = json.load(f)
+ assert data['notification_muted'] == False, "Unmute should be persisted"
+
+
+def test_ui_watch_edit_persists_all_fields(client, live_server):
+ """Test that UI watch edit form persists all modified fields"""
+ from changedetectionio.store import ChangeDetectionStore
+
+ datastore = client.application.config.get('DATASTORE')
+ datastore_path = datastore.datastore_path
+
+ # Create watch
+ uuid = datastore.add_watch(url='http://example.com')
+
+ # Edit via UI with multiple field changes
+ response = client.post(
+ url_for("ui.ui_edit.edit_page", uuid=uuid),
+ data={
+ 'url': 'http://updated-example.com',
+ 'title': 'Updated Watch Title',
+ 'time_between_check-hours': '2',
+ 'time_between_check-minutes': '30',
+ 'include_filters': '#content',
+ 'fetch_backend': 'html_requests',
+ 'method': 'POST',
+ 'ignore_text': 'Advertisement\nTracking'
+ },
+ follow_redirects=True
+ )
+
+ assert b"Updated watch" in response.data or b"Saved" in response.data
+
+ # Simulate restart
+ datastore2 = ChangeDetectionStore(datastore_path=datastore_path)
+ datastore2.reload_state(
+ datastore_path=datastore_path,
+ include_default_watches=False,
+ version_tag='test'
+ )
+
+ # Verify all fields survived
+ watch = datastore2.data['watching'][uuid]
+ assert watch['url'] == 'http://updated-example.com'
+ assert watch['title'] == 'Updated Watch Title'
+ assert watch['time_between_check']['hours'] == 2
+ assert watch['time_between_check']['minutes'] == 30
+ assert watch['fetch_backend'] == 'html_requests'
+ assert watch['method'] == 'POST'