Skip to content

Commit 35f7a09

Browse files
Pin MemoryNodeStore.set_nodes validation contract added in cycle 22
Cycle 22 mirrored ``__init__``'s strip / dedup / empty- rejection validation onto ``set_nodes`` to close a runtime- update bypass. The implementation landed without test coverage; the existing happy-path test masked the gap from quick coverage scans. Add six pins covering the cycle-22 contract: * TypeError on non-string address. * ValueError on empty address. * ValueError on whitespace-only address. * Whitespace stripping yields the canonical address. * Dedup of duplicate addresses (first wins, including whitespace variants matching). * NodeInfo rebuild when address is stripped (frozen dataclass). A regression that drops the validation block surfaces immediately instead of silently re-introducing the runtime- update bypass cycle 22 was meant to fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fbfe06a commit 35f7a09

1 file changed

Lines changed: 90 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Pin: ``MemoryNodeStore.set_nodes`` mirrors ``__init__``'s
2+
strip / dedup / empty-rejection validation contract.
3+
4+
Cycle 22 applied the ``__init__`` validation to
5+
``set_nodes`` to close a runtime-update bypass. Without
6+
these tests, a regression that drops the validation block
7+
silently re-introduces the exact defect the cycle-22
8+
commit was meant to prevent.
9+
10+
Six contract behaviours covered:
11+
12+
* ``TypeError`` on non-string address.
13+
* ``ValueError`` on empty/whitespace-only address.
14+
* Whitespace stripping with NodeInfo rebuild (frozen
15+
dataclass).
16+
* Dedup of duplicate addresses (first wins).
17+
* Stripped variants of an already-seen address are dedup'd.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import pytest
23+
24+
from dqliteclient.node_store import MemoryNodeStore, NodeInfo
25+
from dqlitewire import NodeRole
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_set_nodes_rejects_non_string_address() -> None:
30+
store = MemoryNodeStore()
31+
with pytest.raises(TypeError, match="(?i)address must be"):
32+
await store.set_nodes(
33+
[NodeInfo(node_id=1, address=12345, role=NodeRole.VOTER)] # type: ignore[arg-type]
34+
)
35+
36+
37+
@pytest.mark.asyncio
38+
async def test_set_nodes_rejects_empty_address() -> None:
39+
store = MemoryNodeStore()
40+
with pytest.raises(ValueError, match="(?i)non-empty"):
41+
await store.set_nodes([NodeInfo(node_id=1, address="", role=NodeRole.VOTER)])
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_set_nodes_rejects_whitespace_only_address() -> None:
46+
store = MemoryNodeStore()
47+
with pytest.raises(ValueError, match="(?i)non-empty"):
48+
await store.set_nodes([NodeInfo(node_id=1, address=" ", role=NodeRole.VOTER)])
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_set_nodes_strips_whitespace() -> None:
53+
store = MemoryNodeStore()
54+
await store.set_nodes([NodeInfo(node_id=1, address=" 127.0.0.1:9001 ", role=NodeRole.VOTER)])
55+
nodes = await store.get_nodes()
56+
assert nodes[0].address == "127.0.0.1:9001"
57+
58+
59+
@pytest.mark.asyncio
60+
async def test_set_nodes_dedups_duplicates_first_wins() -> None:
61+
store = MemoryNodeStore()
62+
await store.set_nodes(
63+
[
64+
NodeInfo(node_id=1, address="127.0.0.1:9001", role=NodeRole.VOTER),
65+
NodeInfo(node_id=2, address=" 127.0.0.1:9001 ", role=NodeRole.STANDBY),
66+
NodeInfo(node_id=3, address="127.0.0.1:9002", role=NodeRole.SPARE),
67+
]
68+
)
69+
nodes = await store.get_nodes()
70+
assert len(nodes) == 2
71+
assert nodes[0].node_id == 1
72+
assert nodes[1].node_id == 3
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_set_nodes_rebuilds_nodeinfo_when_address_stripped() -> None:
77+
"""Frozen-dataclass rebuild: a stripped address yields a new
78+
NodeInfo instance, not the caller's original. A refactor that
79+
drops the rebuild branch (and tries in-place mutation, which
80+
would TypeError on the frozen dataclass) surfaces here."""
81+
store = MemoryNodeStore()
82+
original = NodeInfo(node_id=1, address=" 127.0.0.1:9001 ", role=NodeRole.VOTER)
83+
await store.set_nodes([original])
84+
nodes = await store.get_nodes()
85+
stored = nodes[0]
86+
assert stored is not original
87+
assert stored.address == "127.0.0.1:9001"
88+
# node_id and role are preserved across the rebuild.
89+
assert stored.node_id == original.node_id
90+
assert stored.role == original.role

0 commit comments

Comments
 (0)