Skip to content

Commit

Permalink
[#37] refactor: modify socket.io server and test
Browse files Browse the repository at this point in the history
- remove unnecessary forward slash character in event name
- check fields `msg` and `nickname` in the event data `chat`
- logger to record server errors

Signed-off-by: T.H. <[email protected]>
  • Loading branch information
metalalive committed Dec 9, 2023
1 parent d648e36 commit f0fcb9c
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 75 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/backend_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Python Package build test

on:
push:
branches: ["main", "experiment/backend/socketio"]
branches: ["main"]
pull_request:
branches: ["main"]

Expand Down
5 changes: 5 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ poetry install
```

### Run
#### web application server for REST endpoints
```bash
poetry run webapp-dev
```
#### socket.io server
```bash
poetry run socketio-app-dev
```

Once the server started, you can send HTTP request to the server.

Expand Down
37 changes: 27 additions & 10 deletions backend/app/adapter/sio_srv.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import asyncio
import os

Expand All @@ -8,38 +9,54 @@

toplvl_namespace = "/game"
srv = socketio.AsyncServer(async_mode="asgi")
_logger = logging.getLogger(__name__)


@srv.on("/chat", namespace=toplvl_namespace)
@srv.on("chat", namespace=toplvl_namespace)
async def _forward_chat_msg(sid, data: Dict):
if data.get("msg"):
required = ["msg", "nickname", "gameID"]

def field_check(name) -> tuple:
return (name, data.get(name, None))

def field_filter(item) -> bool:
return item[1] is None

not_exist = filter(field_filter, map(field_check, required))
not_exist = list(not_exist)
if len(not_exist) == 0:
await srv.emit(
"/chat", data, namespace=toplvl_namespace, room=data["gameID"], skip_sid=sid
"chat", data, namespace=toplvl_namespace, room=data["gameID"], skip_sid=sid
)
else:
print("missing message, %s" % sid) # TODO, logger

def extract_field(item) -> str:
return item[0]

error = {"missing_fields": list(map(extract_field, not_exist))}
await srv.emit("chat", data=error, namespace=toplvl_namespace, to=sid)


@srv.on("/init", namespace=toplvl_namespace)
@srv.on("init", namespace=toplvl_namespace)
async def init_communication(sid, data: Dict):
try:
await srv.enter_room(sid, room=data["room"], namespace=toplvl_namespace)
data["succeed"] = True
except (ValueError, KeyError) as e:
print("%s" % e) # TODO, logger
_logger.error("%s", e)
data["succeed"] = False
await srv.emit("/init", data, namespace=toplvl_namespace, room=data["room"])
await srv.emit("init", data, namespace=toplvl_namespace, room=data["room"])


@srv.on("/deinit", namespace=toplvl_namespace)
@srv.on("deinit", namespace=toplvl_namespace)
async def deinit_communication(sid, data: Dict):
try:
await srv.leave_room(sid, room=data["room"], namespace=toplvl_namespace)
data["succeed"] = True
except KeyError as e:
print("%s" % e) # TODO, logger
_logger.error("%s", e)
data["succeed"] = False
await srv.emit("/deinit", data, namespace=toplvl_namespace, room=data["room"])
await srv.emit("deinit", data, namespace=toplvl_namespace, room=data["room"])


def gen_srv_task(host: str):
Expand Down
169 changes: 105 additions & 64 deletions backend/tests/e2e/test_socketio.py
Original file line number Diff line number Diff line change
@@ -1,97 +1,138 @@
from typing import List
from typing import List, Dict

import pytest
import socketio

SERVER_URL = "http://localhost:8082"

toplvl_namespace = "/game"

class MockClient:
def __init__(self, nickname: str, toplvl_namespace="/game"):
self._sio_client = socketio.AsyncSimpleClient(logger=False)
self._toplvl_namespace = toplvl_namespace
self._nickname = nickname
self._msg_log: List[Dict] = []

@property
def sio(self):
return self._sio_client

@property
def nickname(self):
return self._nickname

@property
def messages_log(self):
return self._msg_log

async def connect(self, url: str):
await self._sio_client.connect(url, namespace=self._toplvl_namespace)

async def disconnect(self):
await self._sio_client.disconnect()

async def join(self, room_id: str):
await self._sio_client.emit(
"init", data={"client": self._sio_client.sid, "room": room_id}
)

async def leave(self, room_id: str):
await self._sio_client.emit(
"deinit", data={"client": self._sio_client.sid, "room": room_id}
)

async def chat(self, room_id: str, msg: str):
item = {"nickname": self._nickname, "msg": msg}
data = {"client": self._sio_client.sid, "gameID": room_id}
data.update(item)
await self._sio_client.emit("chat", data=data)
self._msg_log.append(item)

async def verify_join(self, expect_clients: List):
expect_client_sid = [v.sio.sid for v in expect_clients]
actual_client_sid = []
for expect_sid in expect_client_sid:
evts = await self._sio_client.receive(timeout=1)
assert len(evts) == 2
assert evts[0] == "init"
assert evts[1]["succeed"]
actual_client_sid.append(evts[1]["client"])
assert set(actual_client_sid) == set(expect_client_sid)

async def verify_chat(self, expect_sender, expect_error: Dict = None):
evts: List = await self._sio_client.receive(timeout=3)
assert len(evts) == 2
assert evts[0] == "chat"
if expect_error:
assert evts[1] == expect_error
self.messages_log.pop()
else:
assert evts[1]["client"] == expect_sender.sio.sid
assert evts[1]["nickname"] == expect_sender.nickname
item = {"nickname": evts[1].pop("nickname"), "msg": evts[1].pop("msg")}
self._msg_log.append(item)
assert self.messages_log == expect_sender.messages_log


class TestRealTimeComm:
@pytest.mark.asyncio
async def test_event_subscription(self):
players = [socketio.AsyncSimpleClient(logger=False) for _ in range(0, 5)]
game_rooms = {"a001": players[:2], "b073": players[2:]}
async def test_chat(self):
clients = [
MockClient(nickname="Veronika"),
MockClient(nickname="Satoshi"),
MockClient(nickname="Mehlin"),
MockClient(nickname="Jose"),
MockClient(nickname="Raj"),
]
game_rooms = {"a001": clients[:2], "b073": clients[2:]}

for clients in game_rooms.values():
for client in clients:
await client.connect(SERVER_URL, namespace=toplvl_namespace)
await client.connect(SERVER_URL)

for g_id, clients in game_rooms.items():
for c in clients:
await c.emit("/init", data={"client": c.sid, "room": g_id})
await c.join(room_id=g_id)
# receive the result right after emitting `init` event
# Note `socket.io` does not gurantee the order of completion
clients = game_rooms["a001"]
await self.verify_init_response(
receiver=clients[0], expect_client_sid=[clients[1].sid, clients[0].sid]
)
await self.verify_init_response(
receiver=clients[1], expect_client_sid=[clients[1].sid]
)
clients = game_rooms["b073"]
await self.verify_init_response(
receiver=clients[0],
expect_client_sid=[clients[2].sid, clients[1].sid, clients[0].sid],
)
await self.verify_init_response(
receiver=clients[1], expect_client_sid=[clients[1].sid, clients[2].sid]
)
await self.verify_init_response(
receiver=clients[2], expect_client_sid=[clients[2].sid]
)
await clients[0].verify_join([clients[2], clients[1], clients[0]])
await clients[1].verify_join(clients[1:])
await clients[2].verify_join([clients[2]])
clients = game_rooms["a001"]
await clients[0].verify_join([clients[1], clients[0]])
await clients[1].verify_join([clients[1]])

# one client sends chat event to others in the same room
for g_id, clients in game_rooms.items():
await clients[0].emit(
"/chat", data={"client": clients[0].sid, "gameID": g_id, "msg": "Hello"}
)
with pytest.raises(socketio.exceptions.TimeoutError):
await clients[0].receive(timeout=1)
clients = game_rooms["a001"]
await clients[0].chat(room_id="a001", msg="Bonjour")
with pytest.raises(socketio.exceptions.TimeoutError):
await clients[0].sio.receive(timeout=1)
clients = game_rooms["b073"]
await clients[0].chat(room_id="b073", msg="Merhaba")

clients = game_rooms["a001"]
await self.verify_received_chat(receiver=clients[1], expect_sender=clients[0])
await clients[1].emit(
"/chat", data={"client": clients[1].sid, "gameID": "a001", "msg": "Halo"}

await clients[0].chat(room_id="a001", msg=None)
await clients[0].verify_chat(
clients[0], expect_error={"missing_fields": ["msg"]}
)

await clients[1].verify_chat(clients[0])
await clients[1].chat(room_id="a001", msg="Halo")

clients = game_rooms["b073"]
await self.verify_received_chat(receiver=clients[2], expect_sender=clients[0])
await self.verify_received_chat(receiver=clients[1], expect_sender=clients[0])
await clients[2].emit(
"/chat", data={"client": clients[2].sid, "gameID": "b073", "msg": "Hey"}
)
await self.verify_received_chat(receiver=clients[1], expect_sender=clients[2])
await self.verify_received_chat(receiver=clients[0], expect_sender=clients[2])
await clients[2].verify_chat(clients[0])
await clients[1].verify_chat(clients[0])
await clients[2].chat(room_id="b073", msg="Salam")
await clients[1].verify_chat(clients[2])
await clients[0].verify_chat(clients[2])

clients = game_rooms["a001"]
await self.verify_received_chat(receiver=clients[0], expect_sender=clients[1])
await clients[0].verify_chat(clients[1])

for g_id, clients in game_rooms.items():
for c in clients:
await c.emit("/deinit", data={"client": c.sid, "room": g_id})
await c.leave(room_id=g_id)
await c.disconnect()
# end of test_event_subscription

async def verify_init_response(
self, receiver: socketio.AsyncSimpleClient, expect_client_sid: List[str]
):
actual_client_sid = []
for expect_sid in expect_client_sid:
evts = await receiver.receive(timeout=1)
assert len(evts) == 2
assert evts[0] == "/init"
assert evts[1]["succeed"]
actual_client_sid.append(evts[1]["client"])
assert set(actual_client_sid) == set(expect_client_sid)

async def verify_received_chat(
self,
receiver: socketio.AsyncSimpleClient,
expect_sender: socketio.AsyncSimpleClient,
):
evts = await receiver.receive(timeout=3)
assert len(evts) == 2
assert evts[0] == "/chat"
assert evts[1]["client"] == expect_sender.sid

0 comments on commit f0fcb9c

Please sign in to comment.