Repository: xconnio/wampproto-python (language: python)

Issue:
Session Messages
Need Hello and Welcome messages defined. 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index fca00bc..ad268c3 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -16,6 +16,9 @@ jobs:
       - name: Setup environment & install dependencies
         run: make setup
 
+      - name: Run tests
+        run: make test
+
   ruff:
     runs-on: ubuntu-latest
     steps:
diff --git a/Makefile b/Makefile
index 402f4e6..a91cd85 100644
--- a/Makefile
+++ b/Makefile
@@ -6,10 +6,17 @@ install_uv:
 setup:
 	make install_uv
 	uv venv
-	uv pip install -r requirements.txt
+	uv pip install -r requirements-dev.txt
 
 lint:
 	. .venv/bin/activate; ruff format .
 
 check-lint:
 	. .venv/bin/activate; ruff check .
+
+
+test:
+	. .venv/bin/activate; pytest -v tests/
+
+coverage:
+	. .venv/bin/activate; coverage run -m pytest -v tests && coverage html && open htmlcov/index.html
diff --git a/main.py b/main.py
index 40a3ec6..5b09e22 100644
--- a/main.py
+++ b/main.py
@@ -1,3 +1,2 @@
-
 if __name__ == "__main__":
     print("Hello World!")
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..2e177ff
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+ruff
+pytest
+coverage
diff --git a/requirements-pinned.txt b/requirements-pinned.txt
index 83028ae..ee20484 100644
--- a/requirements-pinned.txt
+++ b/requirements-pinned.txt
@@ -1,1 +1,8 @@
-ruff==0.2.2
+coverage==7.4.3
+exceptiongroup==1.2.0
+iniconfig==2.0.0
+packaging==23.2
+pluggy==1.4.0
+pytest==8.0.2
+ruff==0.3.0
+tomli==2.0.1
diff --git a/ruff.toml b/ruff.toml
index 4bbc7df..140330e 100644
--- a/ruff.toml
+++ b/ruff.toml
@@ -37,7 +37,7 @@ target-version = "py38"
 
 [lint]
 # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`)  codes by default.
-select = ["E4", "E7", "E9", "F"]
+select = ["E4", "E7", "E9", "F", "E501"]
 ignore = []
 
 # Allow fix for all enabled rules (when `--fix`) is provided.
diff --git a/wamp/__init__.py b/wamp/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/wamp/messages/__init__.py b/wamp/messages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/wamp/messages/error.py b/wamp/messages/error.py
new file mode 100644
index 0000000..79a86e7
--- /dev/null
+++ b/wamp/messages/error.py
@@ -0,0 +1,10 @@
+class ProtocolError(Exception):
+    pass
+
+
+class InvalidRealmError(Exception):
+    pass
+
+
+class InvalidDetailsError(Exception):
+    pass
diff --git a/wamp/messages/hello.py b/wamp/messages/hello.py
new file mode 100644
index 0000000..c79078c
--- /dev/null
+++ b/wamp/messages/hello.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Any
+
+from wamp.messages import error, util
+from wamp.messages.message import Message
+
+
+class Hello(Message):
+    HELLO_TEXT = "HELLO"
+    MESSAGE_TYPE = 1
+
+    def __init__(
+        self,
+        realm: str,
+        roles: dict[str, Any],
+        authid: str | None = None,
+        authrole: str | None = None,
+    ):
+        super().__init__()
+        self.realm = realm
+        self.roles = roles
+        self.authid = authid
+        self.authrole = authrole
+
+    @staticmethod
+    def parse(msg: list) -> Hello:
+        if not isinstance(msg, list):
+            raise error.ProtocolError(
+                f"invalid message type '{type(msg)}' for {Hello.HELLO_TEXT}, type should be a list"
+            )
+
+        if len(msg) != 3:
+            raise error.ProtocolError(
+                f"invalid message length '{len(msg)}' for {Hello.HELLO_TEXT}, length should be equal to three"
+            )
+
+        if msg[0] != Hello.MESSAGE_TYPE:
+            raise error.ProtocolError(f"invalid message type for {Hello.HELLO_TEXT}")
+
+        realm = util.validate_realm_or_raise(msg[1], Hello.HELLO_TEXT)
+        details = util.validate_details_or_raise(msg[2], Hello.HELLO_TEXT)
+
+        roles = details.get("roles", {})
+        if not isinstance(roles, dict):
+            raise error.ProtocolError(f"invalid type for 'roles' in details for {Hello.HELLO_TEXT}")
+
+        if len(roles) == 0:
+            raise error.ProtocolError(f"roles are missing in details for {Hello.HELLO_TEXT}")
+
+        for role in roles.keys():
+            if role not in util.AllowedRoles.__members__.values():
+                raise error.ProtocolError(f"invalid role '{role}' in 'roles' details for {Hello.HELLO_TEXT}")
+
+        authid = details.get("authid", None)
+        if authid is not None:
+            if not isinstance(authid, str):
+                raise error.ProtocolError(f"authid must be a type string for {Hello.HELLO_TEXT}")
+
+        authrole = details.get("authrole", None)
+        if authrole is not None:
+            if not isinstance(authrole, str):
+                raise error.ProtocolError(f"authrole must be a type string for {Hello.HELLO_TEXT}")
+
+        return Hello(realm=realm, roles=roles, authid=authid, authrole=authrole)
+
+    def marshal(self):
+        details: dict[str, Any] = {"roles": self.roles}
+
+        if self.authid is not None:
+            details["authid"] = self.authid
+
+        if self.authrole is not None:
+            details["authrole"] = self.authrole
+
+        return [self.MESSAGE_TYPE, self.realm, details]
diff --git a/wamp/messages/message.py b/wamp/messages/message.py
new file mode 100644
index 0000000..efe3a0e
--- /dev/null
+++ b/wamp/messages/message.py
@@ -0,0 +1,9 @@
+class Message:
+    MESSAGE_TYPE = None
+
+    @staticmethod
+    def parse(msg: list):
+        raise NotImplementedError()
+
+    def marshal(self):
+        raise NotImplementedError()
diff --git a/wamp/messages/util.py b/wamp/messages/util.py
new file mode 100644
index 0000000..75c75a2
--- /dev/null
+++ b/wamp/messages/util.py
@@ -0,0 +1,48 @@
+from enum import Enum
+from typing import Any
+
+from wamp.messages import error
+
+
+class AllowedRoles(str, Enum):
+    CALLEE = "callee"
+    CALLER = "caller"
+    PUBLISHER = "publisher"
+    SUBSCRIBER = "subscriber"
+
+    @classmethod
+    def get_allowed_roles(cls) -> list[str]:
+        return [e.value for e in AllowedRoles]
+
+
+def validate_realm_or_raise(realm: str, error_msg: str) -> str:
+    if realm is None:
+        raise error.InvalidRealmError(f"realm cannot be null for {error_msg}")
+
+    if not isinstance(realm, str):
+        raise error.InvalidRealmError(f"realm must be of type string for {error_msg}")
+
+    return realm
+
+
+def validate_details_or_raise(details: dict[str, Any], error_msg: str) -> dict[str, Any]:
+    if not isinstance(details, dict):
+        raise error.InvalidDetailsError(f"details must be of type dictionary for {error_msg}")
+
+    for key in details.keys():
+        if not isinstance(key, str):
+            raise error.InvalidDetailsError(f"invalid type for key '{key}' in extra details for {error_msg}")
+
+    return details
+
+
+def validate_session_id_or_raise(session_id: int, error_msg: str) -> int:
+    if not isinstance(session_id, int):
+        raise error.ProtocolError(f"session ID must be an integer for {error_msg}")
+
+    # session id values lie between 1 and 2**53
+    # https://wamp-proto.org/wamp_bp_latest_ietf.html#section-2.1.2-3
+    if session_id < 0 or session_id > 9007199254740992:
+        raise error.ProtocolError(f"invalid Session ID value for {error_msg}")
+
+    return session_id
diff --git a/wamp/messages/welcome.py b/wamp/messages/welcome.py
new file mode 100644
index 0000000..522a221
--- /dev/null
+++ b/wamp/messages/welcome.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Any
+
+from wamp.messages import error, util
+from wamp.messages.message import Message
+
+
+class Welcome(Message):
+    WELCOME_TEXT = "WELCOME"
+    MESSAGE_TYPE = 2
+
+    def __init__(
+        self,
+        session_id: int,
+        roles: dict[str, Any],
+        authid: str | None = None,
+        authrole: str | None = None,
+    ):
+        super().__init__()
+        self.session_id = session_id
+        self.roles = roles
+        self.authid = authid
+        self.authrole = authrole
+
+    @staticmethod
+    def parse(msg: list) -> Welcome:
+        if not isinstance(msg, list):
+            raise error.ProtocolError(
+                f"invalid message type '{type(msg)}' for {Welcome.WELCOME_TEXT}, type should be a list"
+            )
+
+        if len(msg) != 3:
+            raise error.ProtocolError(
+                f"invalid message length '{len(msg)}' for {Welcome.WELCOME_TEXT}, length should be equal to three"
+            )
+
+        if msg[0] != Welcome.MESSAGE_TYPE:
+            raise error.ProtocolError("invalid message type for WELCOME")
+
+        session_id = util.validate_session_id_or_raise(msg[1], Welcome.WELCOME_TEXT)
+        details = util.validate_details_or_raise(msg[2], Welcome.WELCOME_TEXT)
+
+        roles = details.get("roles", {})
+        if not isinstance(roles, dict):
+            raise error.ProtocolError(f"invalid type for 'roles' in details for {Welcome.WELCOME_TEXT}")
+
+        if len(roles) == 0:
+            raise error.ProtocolError(f"roles are missing in details for {Welcome.WELCOME_TEXT}")
+
+        for role in roles.keys():
+            if role not in util.AllowedRoles.__members__.values():
+                raise error.ProtocolError(f"invalid role '{role}' in 'roles' details for {Welcome.WELCOME_TEXT}")
+
+        authid = details.get("authid", None)
+        if authid is not None:
+            if not isinstance(authid, str):
+                raise error.ProtocolError(f"authid must be a type string for {Welcome.WELCOME_TEXT}")
+
+        authrole = details.get("authrole", None)
+        if authrole is not None:
+            if not isinstance(authrole, str):
+                raise error.ProtocolError(f"authrole must be a type string for {Welcome.WELCOME_TEXT}")
+
+        return Welcome(session_id=session_id, roles=roles, authid=authid, authrole=authrole)
+
+    def marshal(self):
+        details: dict[str, Any] = {"roles": self.roles}
+
+        if self.authid is not None:
+            details["authid"] = self.authid
+
+        if self.authrole is not None:
+            details["authrole"] = self.authrole
+
+        return [self.MESSAGE_TYPE, self.session_id, details]


Test patch:
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..1ceab94
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+addopts = -p no:warnings
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/messages/__init__.py b/tests/messages/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/messages/test_hello.py b/tests/messages/test_hello.py
new file mode 100644
index 0000000..9847451
--- /dev/null
+++ b/tests/messages/test_hello.py
@@ -0,0 +1,286 @@
+import pytest
+
+from wamp.messages import error, util
+from wamp.messages.hello import Hello
+
+
+def test_marshal_with_no_roles_and_details():
+    realm = "realm"
+    roles = {}
+    details = {}
+    message = Hello(realm, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Hello.MESSAGE_TYPE
+
+    assert isinstance(message[1], str)
+    assert message[1] == realm
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": {}}
+
+
+def test_marshal_with_role_and_no_details():
+    realm = "realm"
+    roles = {"callee": {}}
+    details = {}
+    message = Hello(realm, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Hello.MESSAGE_TYPE
+
+    assert isinstance(message[1], str)
+    assert message[1] == realm
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles}
+
+
+def test_marshal_with_authid():
+    realm = "realm"
+    roles = {"callee": {}}
+    details = {"authid": "mahad"}
+    message = Hello(realm, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Hello.MESSAGE_TYPE
+
+    assert isinstance(message[1], str)
+    assert message[1] == realm
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authid": "mahad"}
+
+
+def test_marshal_with_authrole():
+    realm = "realm"
+    roles = {"callee": {}}
+    details = {"authrole": "admin"}
+    message = Hello(realm, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Hello.MESSAGE_TYPE
+
+    assert isinstance(message[1], str)
+    assert message[1] == realm
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authrole": "admin"}
+
+
+def test_marshal_with_role_authid_and_authrole():
+    realm = "realm"
+    roles = {"callee": {}}
+    details = {"authid": "mahad", "authrole": "admin"}
+    message = Hello(realm, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Hello.MESSAGE_TYPE
+
+    assert isinstance(message[1], str)
+    assert message[1] == realm
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authid": "mahad", "authrole": "admin"}
+
+
+def test_parse_with_string():
+    message = "msg"
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert (
+        str(exc_info.value) == f"invalid message type '{type(message)}' for {Hello.HELLO_TEXT}, type should be a list"
+    )
+
+
+def test_parse_with_invalid_list_length():
+    message = [1]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert (
+        str(exc_info.value)
+        == f"invalid message length '{len(message)}' for {Hello.HELLO_TEXT}, length should be equal to three"
+    )
+
+
+def test_parse_with_invalid_message_type():
+    message = [2, "realm", {}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"invalid message type for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_realm_none():
+    message = [1, None, {}]
+    with pytest.raises(error.InvalidRealmError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"realm cannot be null for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_realm_type():
+    message = [1, {"realm": "realm1"}, {}]
+    with pytest.raises(error.InvalidRealmError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"realm must be of type string for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_details_type():
+    message = [1, "realm1", "details"]
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"details must be of type dictionary for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_details_dict_key():
+    message = [1, "realm1", {1: "v"}]
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"invalid type for key '1' in extra details for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_role_type():
+    message = [1, "realm1", {"roles": "new_role"}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"invalid type for 'roles' in details for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_empty_role():
+    message = [1, "realm1", {"roles": {}}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"roles are missing in details for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_role_key():
+    message = [1, "realm1", {"roles": {"new_role": {}}}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"invalid role 'new_role' in 'roles' details for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_authid():
+    message = [1, "realm1", {"roles": {"callee": {}}, "authid": []}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"authid must be a type string for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_invalid_authrole():
+    message = [1, "realm1", {"roles": {"callee": {}}, "authrole": []}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Hello.parse(message)
+
+    assert str(exc_info.value) == f"authrole must be a type string for {Hello.HELLO_TEXT}"
+
+
+def test_parse_with_valid_roles():
+    realm = "realm1"
+    for role in util.AllowedRoles.get_allowed_roles():
+        details = {"roles": {role: {}}}
+        hello = Hello.parse([Hello.MESSAGE_TYPE, realm, details])
+
+        assert isinstance(hello, Hello)
+        assert isinstance(hello.realm, str)
+        assert hello.realm == realm
+
+        assert isinstance(hello.roles, dict)
+        assert hello.roles == details["roles"]
+
+        assert hello.authid is None
+        assert hello.authrole is None
+
+
+def test_parse_with_multiple_roles():
+    realm = "realm1"
+    details = {"roles": {"callee": {}, "caller": {}}}
+    hello = Hello.parse([Hello.MESSAGE_TYPE, realm, details])
+
+    assert isinstance(hello, Hello)
+    assert isinstance(hello.realm, str)
+    assert hello.realm == realm
+
+    assert isinstance(hello.roles, dict)
+    assert hello.roles == details["roles"]
+
+    assert hello.authid is None
+    assert hello.authrole is None
+
+
+def test_parse_with_authid():
+    realm = "realm1"
+    details = {"roles": {"callee": {}}, "authid": "mahad"}
+    hello = Hello.parse([Hello.MESSAGE_TYPE, realm, details])
+
+    assert isinstance(hello, Hello)
+    assert isinstance(hello.realm, str)
+    assert hello.realm == realm
+
+    assert isinstance(hello.roles, dict)
+    assert hello.roles == details["roles"]
+
+    assert isinstance(hello.authid, str)
+    assert hello.authid == details["authid"]
+    assert hello.authrole is None
+
+
+def test_parse_with_authrole():
+    realm = "realm1"
+    details = {"roles": {"callee": {}}, "authrole": "admin"}
+    hello = Hello.parse([Hello.MESSAGE_TYPE, realm, details])
+
+    assert isinstance(hello, Hello)
+    assert isinstance(hello.realm, str)
+    assert hello.realm == realm
+
+    assert isinstance(hello.roles, dict)
+    assert hello.roles == details["roles"]
+
+    assert isinstance(hello.authrole, str)
+    assert hello.authrole == details["authrole"]
+    assert hello.authid is None
+
+
+def test_parse_with_authid_and_authrole():
+    realm = "realm1"
+    details = {"roles": {"callee": {}}, "authid": "mahad", "authrole": "admin"}
+    hello = Hello.parse([Hello.MESSAGE_TYPE, realm, details])
+
+    assert isinstance(hello, Hello)
+    assert isinstance(hello.realm, str)
+    assert hello.realm == realm
+
+    assert isinstance(hello.roles, dict)
+    assert hello.roles == details["roles"]
+
+    assert isinstance(hello.authid, str)
+    assert hello.authid == details["authid"]
+
+    assert isinstance(hello.authrole, str)
+    assert hello.authrole == details["authrole"]
diff --git a/tests/messages/test_util.py b/tests/messages/test_util.py
new file mode 100644
index 0000000..e5bef94
--- /dev/null
+++ b/tests/messages/test_util.py
@@ -0,0 +1,75 @@
+import pytest
+
+from wamp.messages import util, error
+
+
+def test_validate_realm_or_raise_with_realm_none():
+    error_message = "error"
+    with pytest.raises(error.InvalidRealmError) as exc_info:
+        util.validate_realm_or_raise(None, error_message)
+
+    assert str(exc_info.value) == f"realm cannot be null for {error_message}"
+
+
+def test_validate_realm_or_raise_with_invalid_realm_type():
+    error_message = "error"
+    with pytest.raises(error.InvalidRealmError) as exc_info:
+        util.validate_realm_or_raise(1, error_message)
+
+    assert str(exc_info.value) == f"realm must be of type string for {error_message}"
+
+
+def test_validate_realm_or_raise_correctly():
+    realm = "realm1"
+    result = util.validate_realm_or_raise(realm, "")
+    assert result == realm
+
+
+def test_validate_details_or_raise_with_invalid_detail_type():
+    error_message = "Ping"
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        util.validate_details_or_raise(["detail"], error_message)
+
+    assert str(exc_info.value) == f"details must be of type dictionary for {error_message}"
+
+
+def test_validate_details_or_raise_with_invalid_detail_key_type():
+    error_message = "Ping"
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        util.validate_details_or_raise({23: "v"}, error_message)
+
+    assert str(exc_info.value) == f"invalid type for key '23' in extra details for {error_message}"
+
+
+@pytest.mark.parametrize("details", [{}, {"authid": "mahad"}])
+def test_validate_details_or_raise_correctly(details):
+    result = util.validate_details_or_raise(details, "")
+    assert isinstance(details, dict)
+    assert result == details
+
+
+def test_validate_session_id_or_raise_with_invalid_session_type():
+    error_message = "Pong"
+    invalid_values = [None, 1.0, {"k": "v"}]
+    for session_id in invalid_values:
+        with pytest.raises(error.ProtocolError) as exc_info:
+            util.validate_session_id_or_raise(session_id, error_message)
+
+        assert str(exc_info.value) == f"session ID must be an integer for {error_message}"
+
+
+def test_validate_session_id_or_raise_with_out_of_range_session_id():
+    error_message = "Pong"
+    invalid_values = [-10, 9007199254740993]
+    for session_id in invalid_values:
+        with pytest.raises(error.ProtocolError) as exc_info:
+            util.validate_session_id_or_raise(session_id, error_message)
+
+        assert str(exc_info.value) == f"invalid Session ID value for {error_message}"
+
+
+@pytest.mark.parametrize("session_id", [1, 30, 9007199254740992])
+def test_validate_session_id_or_raise_correctly(session_id):
+    result = util.validate_session_id_or_raise(session_id, "")
+    assert isinstance(session_id, int)
+    assert result == session_id
diff --git a/tests/messages/test_welcome.py b/tests/messages/test_welcome.py
new file mode 100644
index 0000000..45dfb8a
--- /dev/null
+++ b/tests/messages/test_welcome.py
@@ -0,0 +1,311 @@
+import pytest
+
+from wamp.messages import error, util
+from wamp.messages.welcome import Welcome
+
+TEST_SESSION_ID = 25631
+
+
+@pytest.mark.parametrize(
+    "session_id, roles, details, expected_details",
+    [
+        (TEST_SESSION_ID, {}, {}, {"roles": {}}),
+        (TEST_SESSION_ID, {"caller": ""}, {"authid": "mahad"}, {"roles": {"caller": ""}, "authid": "mahad"}),
+        (TEST_SESSION_ID, {"caller": ""}, {"authrole": "callee"}, {"roles": {"caller": ""}, "authrole": "callee"}),
+        (
+            TEST_SESSION_ID,
+            {"publisher": ""},
+            {"authrole": "callee", "authid": "mahad"},
+            {"roles": {"publisher": ""}, "authrole": "callee", "authid": "mahad"},
+        ),
+    ],
+)
+def test_welcome(session_id, roles, details, expected_details):
+    message = Welcome(session_id, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+    assert message[0] == Welcome.MESSAGE_TYPE
+    assert message[1] == session_id
+    assert message[2] == expected_details
+
+
+def test_marshal_with_no_roles_and_details():
+    roles = {}
+    details = {}
+    message = Welcome(TEST_SESSION_ID, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Welcome.MESSAGE_TYPE
+
+    assert isinstance(message[1], int)
+    assert message[1] == TEST_SESSION_ID
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": {}}
+
+
+def test_marshal_with_role_and_no_details():
+    roles = {"callee": {}}
+    details = {}
+    message = Welcome(TEST_SESSION_ID, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Welcome.MESSAGE_TYPE
+
+    assert isinstance(message[1], int)
+    assert message[1] == TEST_SESSION_ID
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles}
+
+
+def test_marshal_with_authid():
+    roles = {"callee": {}}
+    details = {"authid": "mahad"}
+    message = Welcome(TEST_SESSION_ID, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Welcome.MESSAGE_TYPE
+
+    assert isinstance(message[1], int)
+    assert message[1] == TEST_SESSION_ID
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authid": "mahad"}
+
+
+def test_marshal_with_authrole():
+    roles = {"callee": {}}
+    details = {"authrole": "admin"}
+    message = Welcome(TEST_SESSION_ID, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Welcome.MESSAGE_TYPE
+
+    assert isinstance(message[1], int)
+    assert message[1] == TEST_SESSION_ID
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authrole": "admin"}
+
+
+def test_marshal_with_role_authid_and_authrole():
+    roles = {"callee": {}}
+    details = {"authid": "mahad", "authrole": "admin"}
+    message = Welcome(TEST_SESSION_ID, roles, **details).marshal()
+
+    assert isinstance(message, list)
+    assert len(message) == 3
+
+    assert isinstance(message[0], int)
+    assert message[0] == Welcome.MESSAGE_TYPE
+
+    assert isinstance(message[1], int)
+    assert message[1] == TEST_SESSION_ID
+
+    assert isinstance(message[2], dict)
+    assert message[2] == {"roles": roles, "authid": "mahad", "authrole": "admin"}
+
+
+def test_parse_with_string():
+    message = "msg"
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert (
+        str(exc_info.value)
+        == f"invalid message type '{type(message)}' for {Welcome.WELCOME_TEXT}, type should be a list"
+    )
+
+
+def test_parse_with_invalid_list_length():
+    message = [2]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert (
+        str(exc_info.value)
+        == f"invalid message length '{len(message)}' for {Welcome.WELCOME_TEXT}, length should be equal to three"
+    )
+
+
+def test_parse_with_invalid_message_type():
+    message = [1, TEST_SESSION_ID, {}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid message type for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_session_type():
+    message = [2, ["session"], {}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"session ID must be an integer for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_negative_session_value():
+    message = [2, -1, {}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid Session ID value for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_out_of_range_session_value():
+    message = [1, 9007199254740993, "details"]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid message type for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_details_type():
+    message = [2, TEST_SESSION_ID, "details"]
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"details must be of type dictionary for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_details_dict_key():
+    message = [2, TEST_SESSION_ID, {1: "v"}]
+    with pytest.raises(error.InvalidDetailsError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid type for key '1' in extra details for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_role_type():
+    message = [2, TEST_SESSION_ID, {"roles": "new_role"}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid type for 'roles' in details for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_empty_role():
+    message = [2, TEST_SESSION_ID, {"roles": {}}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"roles are missing in details for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_role_key():
+    message = [2, TEST_SESSION_ID, {"roles": {"new_role": {}}}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"invalid role 'new_role' in 'roles' details for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_authid():
+    message = [2, TEST_SESSION_ID, {"roles": {"callee": {}}, "authid": []}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"authid must be a type string for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_invalid_authrole():
+    message = [2, TEST_SESSION_ID, {"roles": {"callee": {}}, "authrole": []}]
+    with pytest.raises(error.ProtocolError) as exc_info:
+        Welcome.parse(message)
+
+    assert str(exc_info.value) == f"authrole must be a type string for {Welcome.WELCOME_TEXT}"
+
+
+def test_parse_with_valid_roles():
+    for role in util.AllowedRoles.get_allowed_roles():
+        details = {"roles": {role: {}}}
+        welcome = Welcome.parse([Welcome.MESSAGE_TYPE, TEST_SESSION_ID, details])
+
+        assert isinstance(welcome, Welcome)
+        assert isinstance(welcome.session_id, int)
+        assert welcome.session_id == TEST_SESSION_ID
+
+        assert isinstance(welcome.roles, dict)
+        assert welcome.roles == details["roles"]
+
+        assert welcome.authid is None
+        assert welcome.authrole is None
+
+
+def test_parse_with_multiple_roles():
+    details = {"roles": {"callee": {}, "caller": {}}}
+    welcome = Welcome.parse([Welcome.MESSAGE_TYPE, TEST_SESSION_ID, details])
+
+    assert isinstance(welcome, Welcome)
+    assert isinstance(welcome.session_id, int)
+    assert welcome.session_id == TEST_SESSION_ID
+
+    assert isinstance(welcome.roles, dict)
+    assert welcome.roles == details["roles"]
+
+    assert welcome.authid is None
+    assert welcome.authrole is None
+
+
+def test_parse_with_authid():
+    details = {"roles": {"callee": {}}, "authid": "mahad"}
+    welcome = Welcome.parse([Welcome.MESSAGE_TYPE, TEST_SESSION_ID, details])
+
+    assert isinstance(welcome, Welcome)
+    assert isinstance(welcome.session_id, int)
+    assert welcome.session_id == TEST_SESSION_ID
+
+    assert isinstance(welcome.roles, dict)
+    assert welcome.roles == details["roles"]
+
+    assert isinstance(welcome.authid, str)
+    assert welcome.authid == details["authid"]
+    assert welcome.authrole is None
+
+
+def test_parse_with_authrole():
+    details = {"roles": {"callee": {}}, "authrole": "admin"}
+    welcome = Welcome.parse([Welcome.MESSAGE_TYPE, TEST_SESSION_ID, details])
+
+    assert isinstance(welcome, Welcome)
+    assert isinstance(welcome.session_id, int)
+    assert welcome.session_id == TEST_SESSION_ID
+
+    assert isinstance(welcome.roles, dict)
+    assert welcome.roles == details["roles"]
+
+    assert isinstance(welcome.authrole, str)
+    assert welcome.authrole == details["authrole"]
+    assert welcome.authid is None
+
+
+def test_parse_with_authid_and_authrole():
+    details = {"roles": {"callee": {}}, "authid": "mahad", "authrole": "admin"}
+    welcome = Welcome.parse([Welcome.MESSAGE_TYPE, TEST_SESSION_ID, details])
+
+    assert isinstance(welcome, Welcome)
+    assert isinstance(welcome.session_id, int)
+    assert welcome.session_id == TEST_SESSION_ID
+
+    assert isinstance(welcome.roles, dict)
+    assert welcome.roles == details["roles"]
+
+    assert isinstance(welcome.authid, str)
+    assert welcome.authid == details["authid"]
+
+    assert isinstance(welcome.authrole, str)
+    assert welcome.authrole == details["authrole"]