mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-09 07:03:02 +03:00
* Add analytics opt-in setting, prompt notice kind, and notice watermarks * Add the analytics report schema * Add the analytics state file * Add the install and hardware analytics collectors * Add the detection analytics collector * Add the cameras analytics collector * Add the features analytics collector * Add the health analytics collector * Build analytics reports from the section collectors * Add the analytics report transport * Send the daily analytics report from the main process * Add the analytics preview API * Generate the analytics schema and check it in CI * Record the image variant in each Dockerfile * Show the analytics preview in telemetry settings * Document anonymous analytics * change wording * fixes * update network requirements docs * adapt analytics to free-form model scenes, split notice counts, and peewee 4 * fixes * change log levels * docstrings * docstrings * honor file-only analytics opt-outs and skip pre-consent notices An opt-out saved through the YAML editor's "Save only" or a hand edit never reached the live config, so reports kept going out under the old install ID until a restart. The reporter now reads telemetry.analytics from the config file when a report is due, validated with TelemetryConfig so yes, on, and 1 still count as consent, and an opt-out there deletes the state file and skips the send. The first report under a new install ID also carried every notice since the counters started, including ones from before the opt-in, so a fresh state file now resets the notice watermarks before the report's snapshot. * take the notice baseline at opt-in instead of the first send The watermark reset ran at the first send attempt, so notices raised between the opt-in and that send, up to 45 minutes after a startup, were absorbed into the reset and never reported. The reporter now writes the new install ID and resets the watermarks as soon as it sees consent without a state file. * report notice counts cumulatively from a database baseline Moving watermarks after each accepted send kept the install ID and the notice baseline out of step: pre-consent notices went out, notices raised before the first send were dropped, and a send that finished after an off/on toggle overwrote the new identity's baseline. health.notices now carries cumulative counts since the install ID was created. Creating an ID copies the lifetime counts into the existing reported_* columns before the state file names it, and nothing is written after a send, so the backend diffs consecutive reports per install ID. * write held notice repeats before the analytics baseline reset Repeats of batched kinds like failed_login wait in memory until the stats emitter flushes them, so a baseline reset taken while some were held left them out, and the next flush counted those pre-opt-in repeats as post-consent. The reset now writes held repeats to the notice history under the same lock before copying the counts. * seed detector inference speed from the first inference The average started at 0 so idle detectors read 0, but that made every detector's speed climb from 0 for its first couple dozen inferences after a start. Detectors now use the same InferenceSpeed helper as enrichments, so the first inference seeds the average and idle detectors still read 0.
531 lines
20 KiB
Python
531 lines
20 KiB
Python
"""Tests for the notice registry and its kind definitions."""
|
|
|
|
import logging
|
|
import os
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from peewee import SqliteDatabase
|
|
from peewee_migrate import Router
|
|
from playhouse.sqliteq import SqliteQueueDatabase
|
|
|
|
from frigate.models import Notice, NoticeStats
|
|
from frigate.notices.registry import NoticeRegistry
|
|
from frigate.notices.types import NOTICE_KINDS, NoticeKind, NoticeSeverity, notice_id
|
|
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS, TEST_DB_PRAGMAS
|
|
|
|
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
|
|
# values the real catalog picks
|
|
BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
|
|
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
|
|
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
|
|
|
|
|
|
class TestNoticeKinds(unittest.TestCase):
|
|
def test_every_kind_is_keyed_by_its_own_key(self):
|
|
for key, definition in NOTICE_KINDS.items():
|
|
self.assertEqual(key, definition.key)
|
|
|
|
def test_notice_id_with_and_without_scope(self):
|
|
self.assertEqual(notice_id("failed_login", None), "failed_login")
|
|
self.assertEqual(
|
|
notice_id("skipped_detections", "front_door"),
|
|
"skipped_detections:front_door",
|
|
)
|
|
|
|
def test_link_is_filled_in_from_params(self):
|
|
kind = NoticeKind(
|
|
"k", NoticeSeverity.info, "system", link="https://x/v{version}"
|
|
)
|
|
|
|
self.assertEqual(kind.link_for({"version": "0.19.1"}), "https://x/v0.19.1")
|
|
self.assertIsNone(kind.link_for({}))
|
|
|
|
def test_a_kind_without_a_link(self):
|
|
kind = NoticeKind("k", NoticeSeverity.info, "system")
|
|
|
|
self.assertIsNone(kind.link_for({"version": "0.19.1"}))
|
|
|
|
def test_analytics_prompt_links_to_telemetry_settings(self):
|
|
kind = NOTICE_KINDS["analytics_prompt"]
|
|
|
|
self.assertEqual(kind.link_for({}), "/settings?page=systemTelemetry")
|
|
self.assertFalse(kind.counts_repeats)
|
|
self.assertFalse(kind.reportable)
|
|
|
|
def test_update_available_is_not_reported(self):
|
|
self.assertFalse(NOTICE_KINDS["update_available"].reportable)
|
|
|
|
|
|
class RegistryTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
migrate_db = SqliteDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
|
del logging.getLogger("peewee_migrate").handlers[:]
|
|
router = Router(migrate_db)
|
|
router.run()
|
|
migrate_db.close()
|
|
self.db = SqliteQueueDatabase(TEST_DB, pragmas=TEST_DB_PRAGMAS)
|
|
self.db.bind([Notice, NoticeStats])
|
|
self.registry = NoticeRegistry()
|
|
self.listener = MagicMock()
|
|
self.registry.subscribe(self.listener)
|
|
|
|
def tearDown(self):
|
|
if not self.db.is_closed():
|
|
self.db.close()
|
|
|
|
try:
|
|
for file in TEST_DB_CLEANUPS:
|
|
os.remove(file)
|
|
except OSError:
|
|
pass
|
|
|
|
def _raise_at(self, timestamp: float, kind: str, **kwargs) -> None:
|
|
with patch("frigate.notices.registry.datetime") as mock_datetime:
|
|
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
|
self.registry.raise_notice(kind, **kwargs)
|
|
|
|
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
|
|
with patch("frigate.notices.registry.datetime") as mock_datetime:
|
|
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
|
self.registry.acknowledge(row_id)
|
|
|
|
|
|
class TestNoticeRegistry(RegistryTestCase):
|
|
def test_raise_inserts_and_counts_one_occurrence(self):
|
|
self.registry.raise_notice(
|
|
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
|
)
|
|
|
|
active = self.registry.active()
|
|
self.assertEqual(len(active), 1)
|
|
self.assertEqual(active[0]["id"], "skipped_detections:front_door")
|
|
self.assertEqual(active[0]["severity"], "warning")
|
|
self.assertEqual(active[0]["category"], "camera")
|
|
self.assertEqual(active[0]["params"], {"pct": 12.5})
|
|
self.assertEqual(active[0]["link"], "/system#cameras")
|
|
self.assertEqual(active[0]["count"], 1)
|
|
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
|
self.listener.assert_called_once()
|
|
|
|
def test_re_raise_counts_and_updates_params(self):
|
|
self.registry.raise_notice(
|
|
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
|
)
|
|
self.registry.raise_notice(
|
|
"skipped_detections", scope="front_door", params={"pct": 8.0}
|
|
)
|
|
|
|
active = self.registry.active()
|
|
self.assertEqual(active[0]["count"], 2)
|
|
self.assertEqual(active[0]["params"], {"pct": 8.0})
|
|
self.assertEqual(self.registry.stats()[0]["occurrences"], 2)
|
|
|
|
def test_repeat_of_a_non_counting_kind_is_a_no_op(self):
|
|
self.registry.raise_notice(
|
|
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
|
)
|
|
self.listener.reset_mock()
|
|
|
|
self.registry.raise_notice(
|
|
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
|
)
|
|
|
|
self.assertEqual(self.registry.active()[0]["count"], 1)
|
|
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
|
self.listener.assert_not_called()
|
|
|
|
def test_acknowledged_notice_returns_on_a_re_raise(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
|
self.assertEqual(self.registry.active(), [])
|
|
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
|
|
notice = self.registry.active()[0]
|
|
self.assertEqual(notice["count"], 2)
|
|
self.assertIsNone(notice["acknowledged_at"])
|
|
|
|
def test_muted_notice_stays_hidden_on_a_re_raise(self):
|
|
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
|
self.assertTrue(self.registry.mute("skipped_detections:front_door"))
|
|
|
|
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
|
|
|
self.assertEqual(self.registry.active(), [])
|
|
hidden = self.registry.active(include_hidden=True)
|
|
self.assertEqual(hidden[0]["count"], 2)
|
|
self.assertIsNotNone(hidden[0]["muted_at"])
|
|
|
|
def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.acknowledge("detector_stuck:ov")
|
|
|
|
self.assertTrue(self.registry.mute("detector_stuck:ov"))
|
|
|
|
notice = self.registry.active(include_hidden=True)[0]
|
|
self.assertIsNone(notice["acknowledged_at"])
|
|
self.assertIsNotNone(notice["muted_at"])
|
|
|
|
def test_acknowledging_a_muted_notice_keeps_it_muted(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.mute("detector_stuck:ov")
|
|
|
|
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
|
|
|
notice = self.registry.active(include_hidden=True)[0]
|
|
self.assertIsNone(notice["acknowledged_at"])
|
|
self.assertIsNotNone(notice["muted_at"])
|
|
|
|
def test_acknowledge_and_mute_each_count_once(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.raise_notice("shm_too_low", params={})
|
|
|
|
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
|
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
|
self.assertTrue(self.registry.mute("shm_too_low"))
|
|
self.assertTrue(self.registry.mute("shm_too_low"))
|
|
|
|
stats = {s["kind"]: s for s in self.registry.stats()}
|
|
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
|
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
|
|
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
|
|
|
|
def test_unknown_ids_are_rejected(self):
|
|
self.assertFalse(self.registry.acknowledge("nope"))
|
|
self.assertFalse(self.registry.mute("nope"))
|
|
self.assertFalse(self.registry.unhide("nope"))
|
|
|
|
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
|
|
self.registry.raise_notice(
|
|
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
|
)
|
|
|
|
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
|
|
self.assertFalse(
|
|
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
|
|
)
|
|
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
|
|
|
|
def test_hidden_notices_listed_only_when_requested(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.acknowledge("detector_stuck:ov")
|
|
|
|
self.assertEqual(self.registry.active(), [])
|
|
self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
|
|
|
|
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.mute("detector_stuck:ov")
|
|
self.registry.mute("config:detect:fps-greater-than-five:global")
|
|
|
|
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
|
|
self.assertTrue(
|
|
self.registry.unhide("config:detect:fps-greater-than-five:global")
|
|
)
|
|
|
|
notice = self.registry.active()[0]
|
|
self.assertIsNone(notice["muted_at"])
|
|
self.assertEqual(self.registry.muted_checks(), [])
|
|
|
|
def test_resolve_deletes_and_keeps_stats(self):
|
|
self.registry.raise_notice(
|
|
"model_download_failed", scope="yolo/model.onnx", params={}
|
|
)
|
|
self.listener.reset_mock()
|
|
|
|
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
|
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
|
|
|
self.assertEqual(self.registry.active(include_hidden=True), [])
|
|
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
|
self.listener.assert_called_once()
|
|
|
|
def test_resolve_kind_drops_every_scope_of_that_kind(self):
|
|
self.registry.raise_notice("detector_stuck", params={})
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
|
|
|
self.registry.resolve_kind("detector_stuck")
|
|
|
|
ids = [n["id"] for n in self.registry.active()]
|
|
self.assertEqual(ids, ["skipped_detections:garage"])
|
|
|
|
def test_active_fills_in_each_link(self):
|
|
self.registry.raise_notice(
|
|
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
|
)
|
|
self.registry.raise_notice(
|
|
"model_download_failed", scope="yolo/model.onnx", params={}
|
|
)
|
|
|
|
links = {n["kind"]: n["link"] for n in self.registry.active()}
|
|
self.assertEqual(
|
|
links,
|
|
{
|
|
"update_available": "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.1",
|
|
"model_download_failed": None,
|
|
},
|
|
)
|
|
|
|
def test_replay_camera_is_ignored(self):
|
|
self.registry.raise_notice(
|
|
"skipped_detections", scope="_replay_front_door", params={}
|
|
)
|
|
|
|
self.assertEqual(self.registry.active(), [])
|
|
self.assertEqual(self.registry.stats(), [])
|
|
|
|
def test_unknown_kind_is_dropped(self):
|
|
self.registry.raise_notice("not_a_kind", params={})
|
|
|
|
self.assertEqual(self.registry.active(), [])
|
|
self.listener.assert_not_called()
|
|
|
|
def test_active_sorts_by_severity_then_recency(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.raise_notice(
|
|
"model_download_failed", scope="yolo/model.onnx", params={}
|
|
)
|
|
|
|
ids = [n["id"] for n in self.registry.active()]
|
|
self.assertEqual(
|
|
ids, ["model_download_failed:yolo/model.onnx", "detector_stuck:ov"]
|
|
)
|
|
|
|
def test_listener_exception_does_not_propagate(self):
|
|
self.registry.subscribe(MagicMock(side_effect=RuntimeError("boom")))
|
|
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
|
|
self.assertEqual(len(self.registry.active()), 1)
|
|
|
|
def test_resolve_camera_drops_only_that_camera(self):
|
|
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
|
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
|
self.registry.raise_notice(
|
|
"model_download_failed", scope="front_door", params={}
|
|
)
|
|
|
|
self.registry.resolve_camera("front_door")
|
|
|
|
ids = sorted(n["id"] for n in self.registry.active())
|
|
self.assertEqual(
|
|
ids, ["model_download_failed:front_door", "skipped_detections:garage"]
|
|
)
|
|
|
|
def test_resolve_camera_drops_that_cameras_check_mutes(self):
|
|
for check_id in (
|
|
"stream:front_door:0:probe",
|
|
"config:detect:fps-greater-than-five:camera.front_door",
|
|
"config:detect:fps-greater-than-five:global",
|
|
"stream:garage:0:probe",
|
|
):
|
|
self.assertTrue(self.registry.mute(check_id))
|
|
|
|
self.registry.resolve_camera("front_door")
|
|
|
|
ids = sorted(check["id"] for check in self.registry.muted_checks())
|
|
self.assertEqual(
|
|
ids,
|
|
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
|
|
)
|
|
|
|
def test_camera_named_global_keeps_global_check_mutes(self):
|
|
self.registry.mute("config:detect:fps-greater-than-five:global")
|
|
self.registry.mute("config:detect:fps-greater-than-five:camera.global")
|
|
|
|
self.registry.resolve_camera("global")
|
|
|
|
ids = [check["id"] for check in self.registry.muted_checks()]
|
|
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
|
|
|
|
def test_unhide_all_shows_every_notice_and_keeps_counts(self):
|
|
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
|
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
|
self.registry.acknowledge("detector_stuck:ov")
|
|
self.registry.mute("skipped_detections:garage")
|
|
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
|
|
|
|
self.registry.unhide_all()
|
|
|
|
ids = sorted(n["id"] for n in self.registry.active())
|
|
self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
|
|
self.assertEqual(self.registry.muted_checks(), [])
|
|
stats = {s["kind"]: s for s in self.registry.stats()}
|
|
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
|
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
|
|
|
|
|
class TestAnalyticsBaseline(RegistryTestCase):
|
|
def test_reset_counts_from_now_and_new_kinds_start_at_zero(self):
|
|
stuck = {"scope": "ov", "params": {"detector": "ov"}}
|
|
self.registry.raise_notice("detector_stuck", **stuck)
|
|
self.registry.raise_notice("detector_stuck", **stuck)
|
|
|
|
self.registry.reset_analytics_baseline()
|
|
self.registry.raise_notice("detector_stuck", **stuck)
|
|
self.registry.raise_notice(
|
|
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
|
)
|
|
|
|
rows = {row["kind"]: row for row in self.registry.stats()}
|
|
self.assertEqual(
|
|
(
|
|
rows["detector_stuck"]["occurrences"],
|
|
rows["detector_stuck"]["reported_occurrences"],
|
|
),
|
|
(3, 2),
|
|
)
|
|
self.assertEqual(rows["skipped_detections"]["reported_occurrences"], 0)
|
|
|
|
@patch.dict(NOTICE_KINDS, TEST_KINDS)
|
|
def test_reset_puts_held_repeats_in_the_baseline(self):
|
|
for _ in range(3):
|
|
self.registry.raise_notice("batched")
|
|
|
|
self.registry.reset_analytics_baseline()
|
|
self.registry.flush()
|
|
|
|
[row] = self.registry.stats()
|
|
self.assertEqual((row["occurrences"], row["reported_occurrences"]), (3, 3))
|
|
self.assertEqual(self.registry.active()[0]["count"], 3)
|
|
|
|
|
|
class TestApply(RegistryTestCase):
|
|
def test_each_action_reaches_its_method(self):
|
|
self.registry.apply(
|
|
{
|
|
"action": "raise",
|
|
"kind": "detector_stuck",
|
|
"scope": "ov",
|
|
"params": {"detector": "ov"},
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
[n["id"] for n in self.registry.active()], ["detector_stuck:ov"]
|
|
)
|
|
|
|
self.registry.apply(
|
|
{"action": "resolve", "kind": "detector_stuck", "scope": "ov"}
|
|
)
|
|
self.assertEqual(self.registry.active(), [])
|
|
|
|
for scope in ("a", "b"):
|
|
self.registry.apply(
|
|
{"action": "raise", "kind": "detector_stuck", "scope": scope}
|
|
)
|
|
self.registry.apply({"action": "resolve_kind", "kind": "detector_stuck"})
|
|
self.assertEqual(self.registry.active(), [])
|
|
|
|
def test_malformed_updates_are_logged_and_ignored(self):
|
|
with self.assertLogs("frigate.notices.registry", level="WARNING") as logs:
|
|
self.registry.apply({"action": "raise"})
|
|
self.registry.apply({"action": "explode", "kind": "detector_stuck"})
|
|
|
|
self.assertEqual(len(logs.output), 2)
|
|
self.assertEqual(self.registry.active(), [])
|
|
|
|
|
|
@patch.dict(NOTICE_KINDS, TEST_KINDS)
|
|
class TestLifecycleKnobs(RegistryTestCase):
|
|
def test_batched_repeats_wait_for_the_flush(self):
|
|
self.registry.raise_notice("batched", params={"n": 1})
|
|
self.listener.reset_mock()
|
|
|
|
for n in (2, 3, 4):
|
|
self.registry.raise_notice("batched", params={"n": n})
|
|
|
|
self.assertEqual(self.registry.active()[0]["count"], 1)
|
|
self.listener.assert_not_called()
|
|
|
|
self.registry.flush()
|
|
|
|
notice = self.registry.active()[0]
|
|
self.assertEqual(notice["count"], 4)
|
|
self.assertEqual(notice["params"], {"n": 4})
|
|
self.assertEqual(self.registry.stats()[0]["occurrences"], 4)
|
|
self.listener.assert_called_once()
|
|
|
|
def test_flush_with_nothing_held_is_a_no_op(self):
|
|
self.registry.raise_notice("batched")
|
|
self.listener.reset_mock()
|
|
|
|
self.registry.flush()
|
|
|
|
self.listener.assert_not_called()
|
|
|
|
def test_repeats_of_a_resolved_notice_are_dropped(self):
|
|
self.registry.raise_notice("batched")
|
|
self.registry.raise_notice("batched")
|
|
self.registry.resolve("batched")
|
|
self.listener.reset_mock()
|
|
|
|
self.registry.flush()
|
|
|
|
self.assertEqual(self.registry.active(include_hidden=True), [])
|
|
self.listener.assert_not_called()
|
|
|
|
def test_a_new_row_starts_without_stale_repeats(self):
|
|
self.registry.raise_notice("batched")
|
|
self.registry.raise_notice("batched")
|
|
self.registry.resolve("batched")
|
|
|
|
self.registry.raise_notice("batched")
|
|
self.registry.flush()
|
|
|
|
self.assertEqual(self.registry.active()[0]["count"], 1)
|
|
|
|
def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
|
|
self._raise_at(1000.0, "batched")
|
|
self._acknowledge_at(1010.0, "batched")
|
|
|
|
self._raise_at(1020.0, "batched")
|
|
self.assertEqual(self.registry.active(), [])
|
|
|
|
self.registry.flush()
|
|
|
|
self.assertEqual(self.registry.active()[0]["count"], 2)
|
|
|
|
def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
|
|
self._raise_at(1000.0, "batched")
|
|
self._raise_at(1005.0, "batched")
|
|
self._acknowledge_at(1010.0, "batched")
|
|
|
|
self.registry.flush()
|
|
|
|
self.assertEqual(self.registry.active(), [])
|
|
self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
|
|
|
|
def test_keep_latest_drops_the_oldest_rows(self):
|
|
for index, scope in enumerate(("a", "b", "c")):
|
|
self._raise_at(1000.0 + index, "pruned", scope=scope)
|
|
|
|
ids = sorted(n["id"] for n in self.registry.active())
|
|
self.assertEqual(ids, ["pruned:b", "pruned:c"])
|
|
|
|
|
|
class TestCatalogLifecycles(RegistryTestCase):
|
|
def test_a_new_release_replaces_the_old_update_notice(self):
|
|
for index, version in enumerate(("0.19.0", "0.19.1")):
|
|
self._raise_at(
|
|
1000.0 + index,
|
|
"update_available",
|
|
scope=version,
|
|
params={"version": version},
|
|
)
|
|
|
|
ids = [n["id"] for n in self.registry.active()]
|
|
self.assertEqual(ids, ["update_available:0.19.1"])
|
|
|
|
def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
|
|
self.registry.raise_notice("failed_login", scope="1000")
|
|
self.registry.acknowledge("failed_login:1000")
|
|
|
|
self.registry.raise_notice("failed_login", scope="1000")
|
|
self.registry.flush()
|
|
|
|
burst = self.registry.active()[0]
|
|
self.assertEqual(burst["count"], 2)
|
|
self.assertIsNone(burst["acknowledged_at"])
|