260 lines
8.7 KiB
Python
260 lines
8.7 KiB
Python
"""
|
|
Tests for microCoAPy Observe Extension
|
|
|
|
Run with: python -m pytest tests/test_observe.py -v
|
|
Or simply: python tests/test_observe.py
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
|
|
# Add parent directory to path so we can import microcoapy
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from microcoapy.coap_macros import (
|
|
COAP_OPTION_NUMBER, COAP_TYPE, COAP_METHOD, COAP_RESPONSE_CODE,
|
|
COAP_CONTENT_FORMAT, COAP_OBSERVE_REGISTER, COAP_OBSERVE_DEREGISTER,
|
|
)
|
|
from microcoapy.coap_packet import CoapPacket
|
|
from microcoapy.coap_writer import writePacketHeaderInfo, writePacketOptions, writePacketPayload
|
|
from microcoapy.coap_reader import parsePacketHeaderInfo, parsePacketOptionsAndPayload
|
|
from microcoapy.observe_manager import ObserveManager
|
|
|
|
|
|
def test_observe_option_encode_single_byte():
|
|
"""Observe value < 256 should encode as 1 byte."""
|
|
pkt = CoapPacket()
|
|
pkt.setObserve(0)
|
|
|
|
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
|
|
assert len(obs_opts) == 1
|
|
assert obs_opts[0].buffer == bytearray([0])
|
|
print("PASS: observe option encode single byte (value=0)")
|
|
|
|
pkt2 = CoapPacket()
|
|
pkt2.setObserve(42)
|
|
obs_opts2 = [o for o in pkt2.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
|
|
assert obs_opts2[0].buffer == bytearray([42])
|
|
print("PASS: observe option encode single byte (value=42)")
|
|
|
|
|
|
def test_observe_option_encode_two_bytes():
|
|
"""Observe value 256-65535 should encode as 2 bytes."""
|
|
pkt = CoapPacket()
|
|
pkt.setObserve(1000)
|
|
|
|
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
|
|
assert len(obs_opts) == 1
|
|
buf = obs_opts[0].buffer
|
|
assert len(buf) == 2
|
|
val = (buf[0] << 8) | buf[1]
|
|
assert val == 1000
|
|
print("PASS: observe option encode two bytes (value=1000)")
|
|
|
|
|
|
def test_observe_option_encode_three_bytes():
|
|
"""Observe value 65536+ should encode as 3 bytes."""
|
|
pkt = CoapPacket()
|
|
pkt.setObserve(100000)
|
|
|
|
obs_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_OBSERVE]
|
|
buf = obs_opts[0].buffer
|
|
assert len(buf) == 3
|
|
val = (buf[0] << 16) | (buf[1] << 8) | buf[2]
|
|
assert val == 100000
|
|
print("PASS: observe option encode three bytes (value=100000)")
|
|
|
|
|
|
def test_get_observe_value():
|
|
"""getObserveValue() should decode the Observe option."""
|
|
pkt = CoapPacket()
|
|
assert pkt.getObserveValue() is None
|
|
print("PASS: getObserveValue returns None when no option set")
|
|
|
|
pkt.setObserve(0)
|
|
assert pkt.getObserveValue() == 0
|
|
print("PASS: getObserveValue returns 0 (register)")
|
|
|
|
pkt2 = CoapPacket()
|
|
pkt2.setObserve(1)
|
|
assert pkt2.getObserveValue() == 1
|
|
print("PASS: getObserveValue returns 1 (deregister)")
|
|
|
|
pkt3 = CoapPacket()
|
|
pkt3.setObserve(65000)
|
|
assert pkt3.getObserveValue() == 65000
|
|
print("PASS: getObserveValue returns 65000")
|
|
|
|
|
|
def test_packet_roundtrip_with_observe():
|
|
"""A packet with Observe option should survive encode -> decode."""
|
|
pkt = CoapPacket()
|
|
pkt.type = COAP_TYPE.COAP_CON
|
|
pkt.method = COAP_METHOD.COAP_GET
|
|
pkt.messageid = 0x1234
|
|
pkt.token = bytearray([0xAA, 0xBB])
|
|
pkt.setObserve(COAP_OBSERVE_REGISTER)
|
|
pkt.setUriPath("sensors/temperature")
|
|
|
|
# Encode
|
|
buffer = bytearray()
|
|
writePacketHeaderInfo(buffer, pkt)
|
|
writePacketOptions(buffer, pkt)
|
|
writePacketPayload(buffer, pkt)
|
|
|
|
# Decode
|
|
decoded = CoapPacket()
|
|
parsePacketHeaderInfo(buffer, decoded)
|
|
decoded.tokenLength = buffer[0] & 0x0F
|
|
decoded.token = buffer[4:4 + decoded.tokenLength]
|
|
assert parsePacketOptionsAndPayload(buffer, decoded)
|
|
|
|
# Verify Observe option survived
|
|
obs_val = decoded.getObserveValue()
|
|
assert obs_val == 0, "Expected Observe=0, got {}".format(obs_val)
|
|
print("PASS: packet roundtrip with Observe option")
|
|
|
|
# Verify URI path survived
|
|
uri = decoded.getUriPath()
|
|
assert uri == "sensors/temperature", "Expected 'sensors/temperature', got '{}'".format(uri)
|
|
print("PASS: packet roundtrip URI path preserved")
|
|
|
|
|
|
def test_max_age_option():
|
|
"""Max-Age option should encode correctly."""
|
|
pkt = CoapPacket()
|
|
pkt.setMaxAge(60)
|
|
|
|
max_age_opts = [o for o in pkt.options if o.number == COAP_OPTION_NUMBER.COAP_MAX_AGE]
|
|
assert len(max_age_opts) == 1
|
|
assert max_age_opts[0].buffer == bytearray([60])
|
|
print("PASS: Max-Age option encode (value=60)")
|
|
|
|
|
|
def test_observer_manager_register():
|
|
"""ObserveManager should register and retrieve observers."""
|
|
mgr = ObserveManager(debug=False)
|
|
|
|
assert not mgr.has_observers("test/resource")
|
|
assert mgr.observer_count() == 0
|
|
|
|
ok = mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2]))
|
|
assert ok
|
|
assert mgr.has_observers("test/resource")
|
|
assert mgr.observer_count("test/resource") == 1
|
|
assert mgr.observer_count() == 1
|
|
print("PASS: observer registration")
|
|
|
|
|
|
def test_observer_manager_reregister():
|
|
"""Re-registering the same observer should update token, not duplicate."""
|
|
mgr = ObserveManager(debug=False)
|
|
|
|
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1, 2]))
|
|
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([3, 4]))
|
|
|
|
assert mgr.observer_count("test/resource") == 1
|
|
|
|
observers = mgr.get_observers("test/resource")
|
|
assert observers[0]["token"] == bytearray([3, 4])
|
|
print("PASS: observer re-registration updates token")
|
|
|
|
|
|
def test_observer_manager_deregister():
|
|
"""Deregistering should remove the observer."""
|
|
mgr = ObserveManager(debug=False)
|
|
|
|
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([1]))
|
|
mgr.register("test/resource", "192.168.1.20", 5683, bytearray([2]))
|
|
assert mgr.observer_count("test/resource") == 2
|
|
|
|
mgr.deregister("test/resource", "192.168.1.10", 5683)
|
|
assert mgr.observer_count("test/resource") == 1
|
|
assert mgr.get_observers("test/resource")[0]["ip"] == "192.168.1.20"
|
|
print("PASS: observer deregistration")
|
|
|
|
|
|
def test_observer_manager_deregister_by_token():
|
|
"""Deregistering by token (RST handling) should work."""
|
|
mgr = ObserveManager(debug=False)
|
|
|
|
mgr.register("test/resource", "192.168.1.10", 5683, bytearray([0xAA]))
|
|
mgr.register("test/resource", "192.168.1.20", 5684, bytearray([0xBB]))
|
|
|
|
mgr.deregister_by_token("test/resource", bytearray([0xAA]))
|
|
assert mgr.observer_count("test/resource") == 1
|
|
assert mgr.get_observers("test/resource")[0]["token"] == bytearray([0xBB])
|
|
print("PASS: observer deregister by token (RST)")
|
|
|
|
|
|
def test_observer_manager_limits():
|
|
"""Observer limits should be enforced."""
|
|
mgr = ObserveManager(debug=False)
|
|
mgr.MAX_OBSERVERS_PER_RESOURCE = 2
|
|
mgr.MAX_TOTAL_OBSERVERS = 3
|
|
|
|
assert mgr.register("res1", "10.0.0.1", 5683, bytearray([1]))
|
|
assert mgr.register("res1", "10.0.0.2", 5683, bytearray([2]))
|
|
assert not mgr.register("res1", "10.0.0.3", 5683, bytearray([3])) # per-resource limit
|
|
print("PASS: per-resource observer limit enforced")
|
|
|
|
assert mgr.register("res2", "10.0.0.3", 5683, bytearray([3]))
|
|
assert not mgr.register("res2", "10.0.0.4", 5683, bytearray([4])) # total limit
|
|
print("PASS: total observer limit enforced")
|
|
|
|
|
|
def test_observer_manager_sequence():
|
|
"""Sequence numbers should increment and wrap at 24 bits."""
|
|
mgr = ObserveManager(debug=False)
|
|
mgr._sequence["test"] = 0xFFFFFE
|
|
|
|
assert mgr.next_sequence("test") == 0xFFFFFE
|
|
assert mgr.next_sequence("test") == 0xFFFFFF
|
|
assert mgr.next_sequence("test") == 0 # Wrap
|
|
print("PASS: sequence number wrap at 24 bits")
|
|
|
|
|
|
def test_get_uri_path():
|
|
"""getUriPath() should reconstruct the path from options."""
|
|
pkt = CoapPacket()
|
|
pkt.setUriPath("sensors/soil_moisture")
|
|
|
|
assert pkt.getUriPath() == "sensors/soil_moisture"
|
|
print("PASS: getUriPath reconstruction")
|
|
|
|
|
|
# ── Run all tests ──
|
|
|
|
if __name__ == "__main__":
|
|
tests = [
|
|
test_observe_option_encode_single_byte,
|
|
test_observe_option_encode_two_bytes,
|
|
test_observe_option_encode_three_bytes,
|
|
test_get_observe_value,
|
|
test_packet_roundtrip_with_observe,
|
|
test_max_age_option,
|
|
test_observer_manager_register,
|
|
test_observer_manager_reregister,
|
|
test_observer_manager_deregister,
|
|
test_observer_manager_deregister_by_token,
|
|
test_observer_manager_limits,
|
|
test_observer_manager_sequence,
|
|
test_get_uri_path,
|
|
]
|
|
|
|
passed = 0
|
|
failed = 0
|
|
for test in tests:
|
|
try:
|
|
test()
|
|
passed += 1
|
|
except Exception as e:
|
|
print("FAIL: {} - {}".format(test.__name__, e))
|
|
import traceback
|
|
traceback.print_exc()
|
|
failed += 1
|
|
|
|
print("\n" + "=" * 40)
|
|
print("Results: {} passed, {} failed".format(passed, failed))
|
|
print("=" * 40)
|