diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1500795..b31b635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,8 @@ permissions: jobs: test: name: Python ${{ matrix.python }} - runs-on: ubuntu-latest + # setup-python ships no 3.9 build for ubuntu-26.04 (ubuntu-latest); keep 3.9 on 24.04 while it stays supported. + runs-on: ${{ matrix.python == '3.9' && 'ubuntu-24.04' || 'ubuntu-latest' }} strategy: fail-fast: false matrix: diff --git a/tests/test_behaviour_conformance.py b/tests/test_behaviour_conformance.py index 3384f44..b737691 100644 --- a/tests/test_behaviour_conformance.py +++ b/tests/test_behaviour_conformance.py @@ -1030,6 +1030,11 @@ def run(self) -> None: def _eval_redis_layout(case: Mapping[str, Any]) -> None: """``redis_layout``: the three key-name cases (no broker) and the behavioural scenarios (Redis).""" if "layout" in case: + if case.get("name", "").startswith("encode-fails-") and redis_backend.connect()[1] == "emulated": + # fakeredis + lupa serialises inf/NaN without error where real lua-cjson raises, so the + # cjson.encode guard cannot be exercised on the emulator (see PopScriptWithoutRedisTest + # for the broker-free proof). CI runs these against a real redis:7 (BQ_REQUIRE_REDIS). + raise unittest.SkipTest("encode-fails-* needs a real Redis: the emulator's cjson masks the bug") _LayoutScenario(case).run() else: _eval_redis_key_names(case) diff --git a/tests/test_redis_transport.py b/tests/test_redis_transport.py index b8d0e6b..c172a4f 100644 --- a/tests/test_redis_transport.py +++ b/tests/test_redis_transport.py @@ -53,6 +53,10 @@ SOURCE = Path(__file__).resolve().parent.parent / "src" / "babelqueue" / "redis_transport.py" +def _reject_constant(name: str) -> None: + raise ValueError(f"{name} is not JSON (RFC 8259)") + + def assert_poison_record(case: unittest.TestCase, record: str, body: str, queue: str) -> None: """``record`` is the ADR-0035 poison DLQ record for ``body`` read from ``queue``.""" parsed = json.loads(record) @@ -66,7 +70,9 @@ def assert_poison_record(case: unittest.TestCase, record: str, body: str, queue: members = None if decoded is not None: try: - members = json.loads(decoded) + # ADR-0035 section 1: a strict RFC 8259 object is spliced; NaN / Infinity / -Infinity + # (which Python's json.loads accepts by default) make the body a wrapper case. + members = json.loads(decoded, parse_constant=_reject_constant) except ValueError: members = None if decoded is None: @@ -255,7 +261,7 @@ class PopScriptWithoutRedisTest(unittest.TestCase): def _lua(self): try: from lupa import LuaRuntime - except ImportError: # pragma: no cover - lupa ships with the test extras + except ImportError: # pragma: no cover - lupa is optional (it comes with fakeredis[lua]) self.skipTest("lupa is not installed") return LuaRuntime(unpack_returned_tuples=True) @@ -294,6 +300,7 @@ def test_the_stub_models_the_bug_an_unguarded_encode_raises(self): "reserved = cjson.encode(decoded)", ) self.assertNotEqual(unguarded, _POP) + self._lua() # skips when lupa is not installed (the matrix jobs install no Redis extras) from lupa import LuaError with self.assertRaises(LuaError): @@ -1101,7 +1108,8 @@ def test_undecodable_body_is_dead_lettered_raw_on_first_delivery(self): app = self.app(max_attempts=3, dead_letter=True) app.register(URN, lambda data, meta: None) self.assertEqual(app.consume(self.queue, max_messages=1, timeout=0), 1) - self.assertEqual(self.client.lrange(f"{self.queue}.dlq", 0, -1), [garbage]) + (record,) = self.client.lrange(f"{self.queue}.dlq", 0, -1) + assert_poison_record(self, record, garbage, self.queue) # ADR-0035 wrapper, not the raw body self.assertEqual(self.client.llen(self.processing), 0) self.assertEqual(self.client.zcard(self.deadlines), 0) self.assertEqual(self.client.llen(self.queue), 0) # never re-queued for a retry @@ -1111,7 +1119,8 @@ def test_undecodable_body_is_dead_lettered_with_the_dlq_off_too(self): app = self.app(max_attempts=3, dead_letter=False) app.register(URN, lambda data, meta: None) self.assertEqual(app.consume(self.queue, max_messages=1, timeout=0), 1) - self.assertEqual(self.client.lrange(f"{self.queue}.dlq", 0, -1), ["[1]"]) + (record,) = self.client.lrange(f"{self.queue}.dlq", 0, -1) + assert_poison_record(self, record, "[1]", self.queue) self.assertEqual(self.client.llen(self.processing), 0) def test_publish_with_headers_stores_a_frame_and_pop_unframes(self):