Skip to content

Commit 48f4019

Browse files
Add Unit Tests (#6)
* Use UV as package manager (from remote-server@codeStandards) * Add coverage * Add initial test * Ensure creating users starts from a known baseline * Add tests for server state * Add test for single password generation * More test for key generation * Minor fixes to tests * Add .py,cover files to gitignore * Add coverage config to pyproject * Addd channel lifecycle tests * Refactor tests * Add tests for broadcast * Up test coverage threshold since we've passed 80% coverage * Add tests for channel.add_user * Add tests for channel * Add docstrings and rearrange * Exclude main, __name__=__main__, and tcpnodelay from coverage * Increase fail under to 95 as 90% coverage has been achieved * Add tests for ping_connected_clients * Add several tests * Slight refactor of tests * Coverage workflow * Update test workflow * Up the fail under to test failure for insufficient coverage * Set fail-under to 0 when generateing artifacts * Fix typo * Try github step summary * Apply suggestions from code review Co-authored-by: Sean Budd <seanbudd123@gmail.com> * Update .gitignore * Fix typo * Lint and format with ruff * Lower fail under to 95 * Fix non dict message test case * Add more tests for user * Add test for MOTD * Branch coverage for channel join * Ignore intentionally untested code and partial branches * Up fail under to 100 again * Add comment * Clarify comment --------- Co-authored-by: Sean Budd <seanbudd123@gmail.com>
1 parent 4478302 commit 48f4019

File tree

6 files changed

+771
-59
lines changed

6 files changed

+771
-59
lines changed

.github/workflows/coverage.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Run automated tests
2+
3+
on: push
4+
5+
jobs:
6+
coverage:
7+
name: Check coverage with coverage.py
8+
runs-on: ubuntu-latest
9+
permissions:
10+
pull-requests: write
11+
steps:
12+
- uses: actions/checkout@v4.2.2
13+
- name: Install the latest version of uv
14+
uses: astral-sh/setup-uv@v6.1.0
15+
- name: Setup environment
16+
run: uv sync --dev
17+
- name: Run unit tests
18+
run: uv run coverage run
19+
- name: Report coverage
20+
run: uv run coverage report --format markdown >> $GITHUB_STEP_SUMMARY
21+
- name: Generate coverage artifacts
22+
if: ${{ failure() }}
23+
run: |
24+
uv run coverage html --fail-under 0
25+
uv run coverage xml --fail-under 0
26+
uv run coverage json --fail-under 0
27+
uv run coverage lcov --fail-under 0
28+
uv run coverage annotate
29+
- name: Upload artifacts
30+
if: ${{ failure() }}
31+
uses: actions/upload-artifact@v4
32+
with:
33+
name: Coverage reports
34+
path: |
35+
coverage.json
36+
coverage.lcov
37+
coverage.xml
38+
htmlcov/
39+
*.py,cover

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.venv/
2+
# Temporary unit testing artefacts
3+
_trial_temp/
4+
__pycache__/
5+
.coverage
6+
*.py,cover

pyproject.toml

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@ name = "remote-server"
33
version = "0.1.0"
44
description = "NVDA Remote Access remote relay server."
55
readme = "README.md"
6-
requires-python = ">=3.13.3"
6+
requires-python = "~=3.13"
77
dependencies = [
8-
"pyopenssl>=25.1.0",
9-
"service-identity>=24.2.0",
10-
"twisted>=24.11.0",
8+
"pyopenssl~=25.1",
9+
"service-identity~=24.2",
10+
"twisted~=24.11",
1111
]
1212

1313
[dependency-groups]
1414
dev = [
15-
"pre-commit>=4.2.0",
16-
"pyright>=1.1.401",
17-
"ruff>=0.11.12",
15+
"coverage~=7.8",
16+
"pre-commit~=4.2",
17+
"pyright~=1.1",
18+
"ruff~=0.11",
1819
]
1920

2021
[tool.pyright]
@@ -38,20 +39,15 @@ exclude = [
3839
]
3940

4041
# General config
41-
analyzeUnannotatedFunctions = false
42+
analyzeUnannotatedFunctions = true
4243
deprecateTypingAliases = true
4344

44-
reportMissingTypeArgument=false
45-
reportUnknownVariableType=false
46-
reportAttributeAccessIssue=false
47-
reportUnknownMemberType=false
48-
reportUnknownParameterType=false
49-
reportUnknownArgumentType=false
50-
reportMissingParameterType=false
51-
reportUnknownLambdaType=false
52-
reportUnusedVariable=false
53-
reportOptionalMemberAccess=false
54-
reportDeprecated=false
45+
# The following options cause problems due to polymorphism in Twisted
46+
reportAttributeAccessIssue = false
47+
reportUnknownMemberType = false
48+
reportOptionalMemberAccess = false
49+
# The following option causes problems due to dynamic member access
50+
reportUnknownArgumentType = false
5551

5652
[tool.ruff]
5753
line-length = 110
@@ -78,3 +74,13 @@ ignore = [
7874
# indentation contains tabs
7975
"W191",
8076
]
77+
78+
[tool.coverage.run]
79+
branch = true
80+
command_line = "-m twisted.trial ./test.py"
81+
82+
[tool.coverage.report]
83+
fail_under = 100
84+
exclude_also = [
85+
'if __name__ == .__main__.:',
86+
]

server.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from OpenSSL import crypto
1212
from twisted.internet import reactor, ssl
13+
from twisted.internet.interfaces import ITCPTransport
1314
from twisted.internet.protocol import Factory, defer
1415
from twisted.internet.task import LoopingCall
1516
from twisted.protocols.basic import LineReceiver
@@ -30,15 +31,15 @@ def __init__(self, key, server_state=None):
3031
self.server_state = server_state
3132

3233
def add_client(self, client):
33-
if client.protocol.protocol_version == 1:
34+
if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested
3435
ids = [c.user_id for c in self.clients.values()]
3536
msg = dict(type="channel_joined", channel=self.key, user_ids=ids, origin=client.user_id)
3637
else:
3738
clients = [i.as_dict() for i in self.clients.values()]
3839
msg = dict(type="channel_joined", channel=self.key, origin=client.user_id, clients=clients)
3940
client.send(**msg)
4041
for existing_client in self.clients.values():
41-
if existing_client.protocol.protocol_version == 1:
42+
if existing_client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested
4243
existing_client.send(type="client_joined", user_id=client.user_id)
4344
else:
4445
existing_client.send(type="client_joined", client=client.as_dict())
@@ -48,7 +49,7 @@ def remove_connection(self, con):
4849
if con.user_id in self.clients:
4950
del self.clients[con.user_id]
5051
for client in self.clients.values():
51-
if client.protocol.protocol_version == 1:
52+
if client.protocol.protocol_version == 1: # pragma: no cover - protocol v1 is not tested
5253
client.send(type="client_left", user_id=con.user_id)
5354
else:
5455
client.send(type="client_left", client=con.as_dict())
@@ -77,7 +78,10 @@ def __init__(self):
7778

7879
def connectionMade(self):
7980
logger.info("Connection %d from %s" % (self.connection_id, self.transport.getPeer()))
80-
self.transport.setTcpNoDelay(True)
81+
# We use a non-tcp transport for unit testing,
82+
# which doesn't support setTcpNoDelay.
83+
if isinstance(self.transport, ITCPTransport): # pragma: no cover
84+
self.transport.setTcpNoDelay(True)
8185
self.bytes_sent = 0
8286
self.bytes_received = 0
8387
self.user = User(protocol=self)
@@ -90,7 +94,9 @@ def connectionLost(self, reason):
9094
% (self.connection_id, self.bytes_sent, self.bytes_received),
9195
)
9296
self.user.connection_lost()
93-
if self.cleanup_timer is not None and not self.cleanup_timer.cancelled:
97+
if (
98+
self.cleanup_timer is not None and not self.cleanup_timer.cancelled
99+
): # pragma: no cover - not sure how to trigger this
94100
self.cleanup_timer.cancel()
95101

96102
def lineReceived(self, line):
@@ -169,12 +175,14 @@ def generate_key(self):
169175
self.server_state.generated_keys.add(key)
170176
self.server_state.generated_ips[ip] = time.time()
171177
reactor.callLater(GENERATED_KEY_EXPIRATION_TIME, lambda: self.server_state.generated_keys.remove(key))
172-
if key:
178+
if key: # pragma: no cover - I can't work out why this branch is here. When would this be False?
173179
self.send(type="generate_key", key=key)
174180
return key
175181

176182
def connection_lost(self):
177-
if self.channel is not None:
183+
if (
184+
self.channel is not None
185+
): # pragma: no branch - we don't care about the alternative, as it's a no-op
178186
self.channel.remove_connection(self)
179187

180188
def join(self, channel, connection_type):
@@ -185,7 +193,8 @@ def join(self, channel, connection_type):
185193
self.channel = self.server_state.find_or_create_channel(channel)
186194
self.channel.add_client(self)
187195

188-
def do_generate_key(self):
196+
# TODO: Work out if this is ever called.
197+
def do_generate_key(self): # pragma: no cover
189198
key = self.generate_key()
190199
if key:
191200
self.send(type="generate_key", key=key)
@@ -214,6 +223,7 @@ def __init__(self):
214223
self.generated_keys = set()
215224
# Dictionary of ips to generated time for people who have generated keys.
216225
self.generated_ips = {}
226+
self.motd: str | None = None
217227

218228
def remove_channel(self, channel):
219229
del self.channels[channel]
@@ -238,7 +248,8 @@ class Options(usage.Options):
238248
]
239249

240250

241-
def main():
251+
# Exclude from coverage as it's hard to unit test.
252+
def main(): # pragma: no cover
242253
config = Options()
243254
config.parseOptions()
244255
privkey = open(config["privkey"]).read()

0 commit comments

Comments
 (0)