Skip to content

Commit 0419466

Browse files
committed
test(validation): ✅ cover offline cache-miss warning de-duplication
1 parent c0bea7f commit 0419466

1 file changed

Lines changed: 131 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Copyright (c) 2024-2026 CRS4
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from unittest.mock import MagicMock
18+
19+
import pytest
20+
21+
from rocrate_validator import models as models_module
22+
from rocrate_validator.models import ValidationContext
23+
from rocrate_validator.utils.http import OfflineCacheMissError, find_offline_cache_miss
24+
25+
26+
# ---------- find_offline_cache_miss ----------
27+
def test_find_offline_cache_miss_direct():
28+
exc = OfflineCacheMissError("https://example.org/x")
29+
assert find_offline_cache_miss(exc) is exc
30+
31+
32+
def test_find_offline_cache_miss_walks_cause_chain():
33+
inner = OfflineCacheMissError("https://example.org/x")
34+
try:
35+
try:
36+
raise inner
37+
except OfflineCacheMissError as e:
38+
raise RuntimeError("wrapped") from e
39+
except Exception as outer:
40+
found = find_offline_cache_miss(outer)
41+
assert found is inner
42+
43+
44+
def test_find_offline_cache_miss_walks_context_chain():
45+
# `raise` inside `except` without `from` populates __context__.
46+
try:
47+
try:
48+
raise OfflineCacheMissError("https://example.org/y")
49+
except OfflineCacheMissError:
50+
raise RuntimeError("wrapped via context")
51+
except Exception as outer:
52+
found = find_offline_cache_miss(outer)
53+
assert isinstance(found, OfflineCacheMissError)
54+
assert found.url == "https://example.org/y"
55+
56+
57+
def test_find_offline_cache_miss_returns_none_for_unrelated():
58+
assert find_offline_cache_miss(ValueError("nope")) is None
59+
60+
61+
def test_find_offline_cache_miss_handles_cyclic_chain():
62+
# Two exceptions referencing each other must not loop forever.
63+
a = RuntimeError("a")
64+
b = RuntimeError("b")
65+
a.__context__ = b
66+
b.__context__ = a
67+
assert find_offline_cache_miss(a) is None
68+
69+
70+
# ---------- ValidationContext.maybe_warn_offline_cache_miss ----------
71+
@pytest.fixture
72+
def bare_context():
73+
"""A ValidationContext with only the state needed by the dedup helper."""
74+
ctx = ValidationContext.__new__(ValidationContext)
75+
ctx._offline_cache_misses_warned = set()
76+
return ctx
77+
78+
79+
@pytest.fixture
80+
def mock_logger(monkeypatch):
81+
"""
82+
Replace the module-level logger in ``rocrate_validator.models`` with a
83+
MagicMock. The project's custom logger sets ``propagate=False``, so
84+
pytest's ``caplog`` does not see its records — observing the mock is
85+
both simpler and more precise.
86+
"""
87+
fake = MagicMock()
88+
monkeypatch.setattr(models_module, "logger", fake)
89+
return fake
90+
91+
92+
def test_maybe_warn_returns_false_for_unrelated_exception(bare_context, mock_logger):
93+
assert bare_context.maybe_warn_offline_cache_miss(ValueError("nope")) is False
94+
mock_logger.warning.assert_not_called()
95+
96+
97+
def test_maybe_warn_emits_once_per_url(bare_context, mock_logger):
98+
url = "https://example.org/ctx"
99+
for _ in range(3):
100+
assert bare_context.maybe_warn_offline_cache_miss(OfflineCacheMissError(url)) is True
101+
assert mock_logger.warning.call_count == 1
102+
# The bare miss exception is logged via "%s" so it stringifies and the
103+
# URL appears verbatim in the formatted message.
104+
args, _ = mock_logger.warning.call_args
105+
assert url in str(args[1])
106+
107+
108+
def test_maybe_warn_emits_once_per_distinct_url(bare_context, mock_logger):
109+
url_a = "https://example.org/a"
110+
url_b = "https://example.org/b"
111+
bare_context.maybe_warn_offline_cache_miss(OfflineCacheMissError(url_a))
112+
bare_context.maybe_warn_offline_cache_miss(OfflineCacheMissError(url_b))
113+
bare_context.maybe_warn_offline_cache_miss(OfflineCacheMissError(url_a))
114+
assert mock_logger.warning.call_count == 2
115+
logged = " ".join(str(call.args[1]) for call in mock_logger.warning.call_args_list)
116+
assert url_a in logged
117+
assert url_b in logged
118+
119+
120+
def test_maybe_warn_dedups_when_miss_is_wrapped(bare_context, mock_logger):
121+
url = "https://example.org/ctx"
122+
try:
123+
raise RuntimeError("wrapped") from OfflineCacheMissError(url)
124+
except RuntimeError as wrapped_exc:
125+
wrapped = wrapped_exc
126+
# First call: direct miss; warning emitted.
127+
assert bare_context.maybe_warn_offline_cache_miss(OfflineCacheMissError(url)) is True
128+
# Second call: same URL but reached via a wrapper exception. Must still
129+
# be recognized through the __cause__ chain and dedup'd against the first.
130+
assert bare_context.maybe_warn_offline_cache_miss(wrapped) is True
131+
assert mock_logger.warning.call_count == 1

0 commit comments

Comments
 (0)