Long-running website monitoring: new cloud/resident.py layer on top of the automations claim/quota model (no task state-machine change). States running/paused/ended; pause preserves the content-hash cursor and stops polling, resume re-checks from the cursor, end is terminal (resume after end is 409, a new watch is required). Polling is a cheap fetch + hash comparison; only a real change creates a queued model task. Quota exhaustion pauses (quota_exceeded) instead of ending and keeps the cursor. Driver is the worker run_loop round boundary; server exposes create/list/get/update/pause/resume/end with audit. Console UI intentionally not wired this round (see verification summary for deviations).
181 lines
6.9 KiB
Python
181 lines
6.9 KiB
Python
"""常驻监听服务端单测:POST/GET /projects/{id}/resident-watches、
|
|
PATCH、POST pause / resume / end。不跑真实模型、不联网。
|
|
骨架与 test_automations_server.py 同款(本地临时端口 + 临时
|
|
SQLite)。"""
|
|
import http.client
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from cloud.auth import AuthStore
|
|
from cloud.server import create_server
|
|
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
from cloud.auth import AuthStore
|
|
from cloud.server import create_server
|
|
|
|
URL = "https://example.com/data"
|
|
|
|
|
|
class ResidentServerTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
|
self.auth = AuthStore(self.db_path)
|
|
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
|
self.outsider_id, self.outsider_key = self.auth.create_user(
|
|
"outsider")
|
|
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
|
self.port = self.server.server_address[1]
|
|
self.thread = threading.Thread(
|
|
target=self.server.serve_forever, daemon=True)
|
|
self.thread.start()
|
|
status, self.project = self._req(
|
|
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
|
self.assertEqual(status, 201)
|
|
|
|
def tearDown(self):
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self.thread.join(timeout=5)
|
|
try:
|
|
self.auth.conn.close()
|
|
except Exception:
|
|
pass
|
|
self.tmp.cleanup()
|
|
|
|
def _req(self, method, path, body=None, key=None):
|
|
conn = http.client.HTTPConnection(
|
|
"127.0.0.1", self.port, timeout=10)
|
|
hdrs = {}
|
|
payload = None
|
|
if body is not None:
|
|
payload = json.dumps(body).encode("utf-8")
|
|
hdrs["Content-Type"] = "application/json"
|
|
if key is not None:
|
|
hdrs["X-API-Key"] = key
|
|
try:
|
|
conn.request(method, path, body=payload, headers=hdrs)
|
|
resp = conn.getresponse()
|
|
data = resp.read()
|
|
finally:
|
|
conn.close()
|
|
try:
|
|
parsed = json.loads(data.decode("utf-8")) if data else None
|
|
except ValueError:
|
|
parsed = None
|
|
return resp.status, parsed
|
|
|
|
def _url(self, suffix=""):
|
|
return "/projects/%s/resident-watches%s" % (
|
|
self.project["id"], suffix)
|
|
|
|
def _create(self, **extra):
|
|
body = {"name": "watch1", "target_url": URL,
|
|
"prompt": "变化时汇报"}
|
|
body.update(extra)
|
|
return self._req("POST", self._url(), body, self.owner_key)
|
|
|
|
def _audit_actions(self):
|
|
conn = sqlite3.connect(self.db_path)
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT action FROM audit_log WHERE project_id = ?",
|
|
(self.project["id"],)).fetchall()
|
|
finally:
|
|
conn.close()
|
|
return [r[0] for r in rows]
|
|
|
|
def test_create_list_get_and_validation(self):
|
|
status, watch = self._create(interval_seconds=3600)
|
|
self.assertEqual(status, 201, watch)
|
|
self.assertEqual(watch["status"], "running")
|
|
self.assertEqual(watch["target_url"], URL)
|
|
self.assertTrue(watch["due"])
|
|
self.assertIsNotNone(watch["next_poll_at"])
|
|
self.assertIn("resident_watch_created", self._audit_actions())
|
|
status, body = self._req("GET", self._url(), None, self.owner_key)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(len(body["resident_watches"]), 1)
|
|
self.assertEqual(body["due_count"], 1)
|
|
status, body = self._req(
|
|
"GET", self._url("/" + watch["id"]), None, self.owner_key)
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["id"], watch["id"])
|
|
self.assertEqual(body["polls"], [])
|
|
for bad in (
|
|
{"name": "x", "target_url": "ftp://example.com/x",
|
|
"prompt": "p"},
|
|
{"name": "x", "target_url": "https://localhost/x",
|
|
"prompt": "p"},
|
|
{"name": "x", "target_url": URL, "prompt": "p",
|
|
"interval_seconds": 30},
|
|
{"name": "watch1", "target_url": URL, "prompt": "p"},
|
|
{"target_url": URL, "prompt": "p"},
|
|
{"name": "x", "prompt": "p"}):
|
|
status, _ = self._req("POST", self._url(), bad, self.owner_key)
|
|
self.assertEqual(status, 400, bad)
|
|
status, _ = self._req(
|
|
"GET", self._url("/nope"), None, self.owner_key)
|
|
self.assertEqual(status, 404)
|
|
|
|
def test_pause_resume_end_lifecycle_and_terminal_409(self):
|
|
status, watch = self._create()
|
|
self.assertEqual(status, 201)
|
|
wid = watch["id"]
|
|
status, body = self._req(
|
|
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
|
|
self.assertEqual(status, 200, body)
|
|
self.assertEqual(body["status"], "paused")
|
|
self.assertEqual(body["paused_reason"], "user_paused")
|
|
self.assertIsNone(body["next_poll_at"])
|
|
self.assertFalse(body["due"])
|
|
status, body = self._req(
|
|
"POST", self._url("/%s/pause" % wid), {}, self.owner_key)
|
|
self.assertEqual(status, 409)
|
|
status, body = self._req(
|
|
"POST", self._url("/%s/resume" % wid), {}, self.owner_key)
|
|
self.assertEqual(status, 200, body)
|
|
self.assertEqual(body["status"], "running")
|
|
self.assertIsNone(body["paused_reason"])
|
|
self.assertTrue(body["due"])
|
|
status, body = self._req(
|
|
"POST", self._url("/%s/end" % wid), {}, self.owner_key)
|
|
self.assertEqual(status, 200, body)
|
|
self.assertEqual(body["status"], "ended")
|
|
for action in ("pause", "resume", "end"):
|
|
status, body = self._req(
|
|
"POST", self._url("/%s/%s" % (wid, action)), {},
|
|
self.owner_key)
|
|
self.assertEqual(status, 409, action)
|
|
self.assertIn("terminal", body["error"])
|
|
actions = self._audit_actions()
|
|
self.assertIn("resident_watch_paused", actions)
|
|
self.assertIn("resident_watch_resumed", actions)
|
|
self.assertIn("resident_watch_ended", actions)
|
|
|
|
def test_update_and_outsider_forbidden(self):
|
|
status, watch = self._create()
|
|
self.assertEqual(status, 201)
|
|
wid = watch["id"]
|
|
status, body = self._req(
|
|
"PATCH", self._url("/" + wid),
|
|
{"interval_seconds": 7200}, self.owner_key)
|
|
self.assertEqual(status, 200, body)
|
|
self.assertEqual(body["interval_seconds"], 7200)
|
|
status, _ = self._req(
|
|
"POST", self._url(),
|
|
{"name": "x", "target_url": URL, "prompt": "p"},
|
|
self.outsider_key)
|
|
self.assertIn(status, (401, 403, 404))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|