From 2669a5cee64046688956ee7ee1d4462913cc3a44 Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Wed, 2 Sep 2026 15:43:59 +0200 Subject: [PATCH] Feature/Watch limit - Adding test (#4360) * Env var - PAGE_WATCH_LIMIT enhancements * Rebuild API docs * Bump APi doc version * test: cover PAGE_WATCH_LIMIT across every add path - API create returns 429; API import returns 429 and refuses the batch whole - quick-add and the UI importer flash the limit (importer once per file, not per row) and hand unimported URLs back - clone at the limit no longer KeyErrors - an instance already over the limit still loads from disk and stays editable, only new watches are refused - the Info tab shows the limit only when one is set - add_watch() with no request context returns None instead of raising from flash() - an absent, empty or unparseable env var all mean unlimited Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- changedetectionio/tests/test_watch_limit.py | 222 ++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 changedetectionio/tests/test_watch_limit.py diff --git a/changedetectionio/tests/test_watch_limit.py b/changedetectionio/tests/test_watch_limit.py new file mode 100644 index 000000000..3a4717663 --- /dev/null +++ b/changedetectionio/tests/test_watch_limit.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""PAGE_WATCH_LIMIT - the optional cap on how many watches one instance will hold. + +The limit is enforced in datastore.add_watch(), which every add path funnels through, but each +surface has to report it in its own terms: a flash for the UI, a 429 for the API, one flash for +a whole file in the importers, and nothing at all (just a None) where there's no request context. +""" + +import json +from flask import url_for +from .util import delete_all_watches + + +def test_watch_limit_absent_or_junk_means_unlimited(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """No env var, an empty one, or an unparseable one all leave the limit switched off.""" + datastore = live_server.app.config['DATASTORE'] + + monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False) + assert datastore.watch_limit is None + assert datastore.watch_limit_reached() is False + + # Junk must not block every add, and must not raise + monkeypatch.setenv('PAGE_WATCH_LIMIT', 'not-a-number') + assert datastore.watch_limit is None + assert datastore.watch_limit_reached() is False + + # Set-but-empty is the same as unset + monkeypatch.setenv('PAGE_WATCH_LIMIT', '') + assert datastore.watch_limit is None + assert datastore.watch_limit_reached() is False + + +def test_api_create_watch_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '1') + + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": test_url}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + ) + assert res.status_code == 201 + + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": f"{test_url}?second=1"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + ) + assert res.status_code == 429 + assert b'Watch limit reached (1/1 watches)' in res.data + assert len(datastore.data['watching']) == 1 + + delete_all_watches(client) + + +def test_api_import_refuses_the_whole_batch(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """A 429 from import always means nothing was created, so the same request can be retried.""" + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + headers = {'x-api-key': api_key, 'content-type': 'text/plain'} + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '3') + + res = client.post(url_for("import"), data=f"{test_url}?a=1\n{test_url}?a=2", headers=headers) + assert res.status_code == 200 + assert len(res.json) == 2 + + # Two more would make four - refused whole rather than importing the one that fits + res = client.post(url_for("import"), data=f"{test_url}?a=3\n{test_url}?a=4", headers=headers) + assert res.status_code == 429 + assert b'would exceed it' in res.data + assert len(datastore.data['watching']) == 2 + + # The one that does fit still goes in + res = client.post(url_for("import"), data=f"{test_url}?a=3", headers=headers) + assert res.status_code == 200 + assert len(datastore.data['watching']) == 3 + + delete_all_watches(client) + + +def test_quick_watch_add_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '1') + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": test_url, 'tags': ''}, + follow_redirects=True + ) + assert b'Watch added' in res.data + + res = client.post( + url_for("ui.ui_views.form_quick_watch_add"), + data={"url": f"{test_url}?second=1", 'tags': ''}, + follow_redirects=True + ) + assert b'Watch limit reached (1/1 watches)' in res.data + assert b'Watch added' not in res.data + assert len(datastore.data['watching']) == 1 + + delete_all_watches(client) + + +def test_ui_import_reports_limit_once_and_hands_back_the_rest(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """The importer stops at the limit instead of letting add_watch() flash per remaining row.""" + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '2') + + urls = "\n".join(f"{test_url}?i={i}" for i in range(5)) + res = client.post(url_for("imports.import_page"), data={"urls": urls}, follow_redirects=True) + + assert res.data.count(b'Watch limit reached') == 1, "The limit should be reported once for the file, not once per row" + assert len(datastore.data['watching']) == 2 + # 3 unprocessed URLs come back in the textarea to retry once there's room + assert b'3 Skipped' in res.data + assert b'i=4' in res.data + + delete_all_watches(client) + + +def test_clone_refused_at_limit(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """Clone used to raise KeyError(None) here, then redirect to an edit page for uuid=None.""" + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + uuid = datastore.add_watch(url=test_url) + monkeypatch.setenv('PAGE_WATCH_LIMIT', '1') + + res = client.post(url_for("ui.form_clone", uuid=uuid), follow_redirects=True) + assert res.status_code == 200 + assert b'Watch limit reached (1/1 watches)' in res.data + assert b'Cloned' not in res.data + assert len(datastore.data['watching']) == 1 + + delete_all_watches(client) + + +def test_over_limit_instance_still_loads_and_edits(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """A limit set below what an install already holds must only block *new* watches. + + Everything already there keeps loading from disk and stays editable - the limit is not + retroactive and never hides or drops a watch. + """ + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False) + uuids = [datastore.add_watch(url=f"{test_url}?i={i}") for i in range(3)] + assert all(uuids) + + # Now cap it well below what's already stored + monkeypatch.setenv('PAGE_WATCH_LIMIT', '1') + assert datastore.watch_limit_reached() is True + + # Re-reading from disk is not gated by the limit + datastore._load_watches() + assert len(datastore.data['watching']) == 3 + + # Still all listed + assert client.get(url_for("watchlist.index")).status_code == 200 + res = client.get(url_for("createwatch"), headers={'x-api-key': api_key}) + assert len(res.json) == 3 + + # And still editable + res = client.put( + url_for("watch", uuid=uuids[0]), + data=json.dumps({"title": "Still editable"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + ) + assert res.status_code == 200 + assert datastore.data['watching'][uuids[0]].get('title') == "Still editable" + + # Only adding is refused + res = client.post( + url_for("createwatch"), + data=json.dumps({"url": f"{test_url}?new=1"}), + headers={'content-type': 'application/json', 'x-api-key': api_key}, + ) + assert res.status_code == 429 + assert len(datastore.data['watching']) == 3 + + delete_all_watches(client) + + +def test_limit_shown_in_settings_info_tab(client, live_server, measure_memory_usage, datastore_path, monkeypatch): + """The Info tab names the limit only when one is configured.""" + monkeypatch.delenv('PAGE_WATCH_LIMIT', raising=False) + res = client.get(url_for("settings.settings_page")) + assert b'Maximum number of page watches' not in res.data + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '42') + res = client.get(url_for("settings.settings_page")) + assert b'Maximum number of page watches' in res.data + assert b'42' in res.data + + +def test_limit_reported_without_a_request_context(client, live_server, measure_memory_usage, datastore_path, monkeypatch, mocker): + """The CLI (-u) and the API's background import thread call add_watch() with no request + context, where flash() raises RuntimeError instead of reporting anything.""" + datastore = live_server.app.config['DATASTORE'] + test_url = url_for('test_endpoint', _external=True) + + monkeypatch.setenv('PAGE_WATCH_LIMIT', '1') + assert datastore.add_watch(url=test_url) + + # pytest-flask pushes a request context around every test, so take it away + mocker.patch('changedetectionio.store.has_request_context', return_value=False) + + assert datastore.add_watch(url=f"{test_url}?second=1") is None + assert len(datastore.data['watching']) == 1 + + delete_all_watches(client)