mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-18 19:36:04 +00:00
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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
68a445df1b
commit
2669a5cee6
@@ -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)
|
||||
Reference in New Issue
Block a user