Add NETfishing discovery service
This commit is contained in:
commit
b5b3cc0211
13 changed files with 1013 additions and 0 deletions
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
103
tests/test_http_api.py
Normal file
103
tests/test_http_api.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from http.client import HTTPConnection
|
||||
import json
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from netfishing_discovery.registry import RoomRegistry
|
||||
from netfishing_discovery.server import DiscoveryHTTPServer, ServerConfig
|
||||
|
||||
|
||||
VALID_ROOM = {
|
||||
"room_name": "Pond Friends",
|
||||
"port": 7777,
|
||||
"current_players": 1,
|
||||
"max_players": 8,
|
||||
"game_version": "0.6.4-alpha",
|
||||
"protocol_version": 3,
|
||||
}
|
||||
|
||||
|
||||
class DiscoveryHTTPTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
config = ServerConfig(bind_host="127.0.0.1", bind_port=0, room_ttl_seconds=30.0)
|
||||
cls.server = DiscoveryHTTPServer(config, RoomRegistry(ttl_seconds=30.0))
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.port = cls.server.server_address[1]
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2.0)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, object] | None = None,
|
||||
token: str | None = None,
|
||||
) -> tuple[int, dict[str, object] | None]:
|
||||
connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0)
|
||||
headers: dict[str, str] = {}
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload)
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token is not None:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
response = connection.getresponse()
|
||||
response_body = response.read()
|
||||
connection.close()
|
||||
decoded = json.loads(response_body) if response_body else None
|
||||
return response.status, decoded
|
||||
|
||||
def test_room_lease_lifecycle(self) -> None:
|
||||
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
|
||||
self.assertEqual(status, 201)
|
||||
assert created is not None
|
||||
room = created["room"]
|
||||
assert isinstance(room, dict)
|
||||
room_id = room["room_id"]
|
||||
token = created["lease_token"]
|
||||
self.assertIsInstance(room_id, str)
|
||||
self.assertIsInstance(token, str)
|
||||
|
||||
status, listing = self.request("GET", "/v1/rooms?protocol_version=3")
|
||||
self.assertEqual(status, 200)
|
||||
assert listing is not None
|
||||
listed_rooms = listing["rooms"]
|
||||
self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms))
|
||||
|
||||
status, updated = self.request(
|
||||
"PUT",
|
||||
f"/v1/rooms/{room_id}",
|
||||
dict(VALID_ROOM, current_players=2),
|
||||
str(token),
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
assert updated is not None
|
||||
self.assertEqual(updated["room"]["current_players"], 2)
|
||||
|
||||
status, _body = self.request("DELETE", f"/v1/rooms/{room_id}", token=str(token))
|
||||
self.assertEqual(status, 204)
|
||||
|
||||
def test_invalid_payload_returns_structured_error(self) -> None:
|
||||
status, body = self.request("POST", "/v1/rooms", dict(VALID_ROOM, port=70_000))
|
||||
self.assertEqual(status, 400)
|
||||
assert body is not None
|
||||
self.assertEqual(body["error"]["code"], "invalid_room")
|
||||
|
||||
def test_health_endpoint(self) -> None:
|
||||
status, body = self.request("GET", "/health")
|
||||
self.assertEqual(status, 200)
|
||||
assert body is not None
|
||||
self.assertEqual(body["status"], "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
95
tests/test_registry.py
Normal file
95
tests/test_registry.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from netfishing_discovery.registry import (
|
||||
LeaseAuthorizationError,
|
||||
RoomLimitError,
|
||||
RoomNotFoundError,
|
||||
RoomRegistry,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
VALID_ROOM = {
|
||||
"room_name": "Pond Friends",
|
||||
"port": 7777,
|
||||
"current_players": 1,
|
||||
"max_players": 8,
|
||||
"game_version": "0.6.4-alpha",
|
||||
"protocol_version": 3,
|
||||
}
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1_000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
class RoomRegistryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.clock = FakeClock()
|
||||
self.registry = RoomRegistry(ttl_seconds=30.0, clock=self.clock)
|
||||
|
||||
def test_create_list_update_and_delete(self) -> None:
|
||||
room, token = self.registry.create("203.0.113.10", VALID_ROOM)
|
||||
self.assertEqual(room.address, "203.0.113.10")
|
||||
self.assertEqual(self.registry.list_rooms(), [room])
|
||||
|
||||
self.clock.now += 5.0
|
||||
updated_payload = dict(VALID_ROOM, current_players=3)
|
||||
updated = self.registry.update(room.room_id, token, room.address, updated_payload)
|
||||
self.assertEqual(updated.current_players, 3)
|
||||
self.assertEqual(updated.created_at, room.created_at)
|
||||
self.assertEqual(updated.expires_at, self.clock.now + 30.0)
|
||||
|
||||
self.registry.delete(room.room_id, token)
|
||||
self.assertEqual(self.registry.list_rooms(), [])
|
||||
|
||||
def test_expired_room_is_removed(self) -> None:
|
||||
room, token = self.registry.create("203.0.113.10", VALID_ROOM)
|
||||
self.clock.now += 30.0
|
||||
self.assertEqual(self.registry.list_rooms(), [])
|
||||
with self.assertRaises(RoomNotFoundError):
|
||||
self.registry.update(room.room_id, token, room.address, VALID_ROOM)
|
||||
|
||||
def test_wrong_token_cannot_mutate_room(self) -> None:
|
||||
room, _token = self.registry.create("203.0.113.10", VALID_ROOM)
|
||||
with self.assertRaises(LeaseAuthorizationError):
|
||||
self.registry.delete(room.room_id, "wrong")
|
||||
|
||||
def test_invalid_room_is_rejected(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
self.registry.create("203.0.113.10", dict(VALID_ROOM, current_players=9))
|
||||
with self.assertRaises(ValidationError):
|
||||
self.registry.create("203.0.113.10", dict(VALID_ROOM, room_name="bad\nname"))
|
||||
|
||||
def test_filters_use_authored_versions(self) -> None:
|
||||
expected, _token = self.registry.create("203.0.113.10", VALID_ROOM)
|
||||
self.registry.create(
|
||||
"203.0.113.11",
|
||||
dict(VALID_ROOM, room_name="Older Room", protocol_version=2),
|
||||
)
|
||||
self.assertEqual(self.registry.list_rooms(protocol_version=3), [expected])
|
||||
self.assertEqual(self.registry.list_rooms(game_version="missing"), [])
|
||||
|
||||
def test_room_limits_bound_untrusted_advertisements(self) -> None:
|
||||
registry = RoomRegistry(
|
||||
ttl_seconds=30.0,
|
||||
max_rooms=2,
|
||||
max_rooms_per_address=1,
|
||||
clock=self.clock,
|
||||
)
|
||||
registry.create("203.0.113.10", VALID_ROOM)
|
||||
with self.assertRaises(RoomLimitError):
|
||||
registry.create("203.0.113.10", dict(VALID_ROOM, room_name="Duplicate"))
|
||||
registry.create("203.0.113.11", dict(VALID_ROOM, room_name="Second"))
|
||||
with self.assertRaises(RoomLimitError):
|
||||
registry.create("203.0.113.12", dict(VALID_ROOM, room_name="Full"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue