Skip to content

Commit 9ed747a

Browse files
committed
Add more detailed tests of settings
This has uncovered a bug in DirectThingClient (#165). For now, the test reflects current behaviour, but it will need an update when #165 is fixed.
1 parent f976ac1 commit 9ed747a

File tree

2 files changed

+163
-18
lines changed

2 files changed

+163
-18
lines changed

tests/__init__.py

Whitespace-only changes.

tests/test_settings.py

Lines changed: 163 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from fastapi.testclient import TestClient
99

1010
import labthings_fastapi as lt
11+
from .temp_client import poll_task
1112

1213

1314
class TestThing(lt.Thing):
@@ -17,6 +18,7 @@ def __init__(self) -> None:
1718
super().__init__()
1819
# Initialize functional settings with default values
1920
self._floatsetting: float = 1.0
21+
self._localonlysetting = "Local-only default."
2022

2123
boolsetting: bool = lt.setting(default=False)
2224
"A boolean setting"
@@ -37,15 +39,42 @@ def floatsetting(self, value: float):
3739
self._floatsetting = value
3840

3941
@lt.setting
40-
def readonlysetting(self) -> str:
41-
"""A read-only setting."""
42-
return "This is read-only"
42+
def localonlysetting(self) -> str:
43+
"""A setting that is not writeable from HTTP clients or DirectThingClients.
4344
44-
@readonlysetting.setter
45-
def readonlysetting(self, value: str):
46-
pass
45+
This setting has a setter, so may be written to from this Thing, or
46+
when settings are loaded. However, it's marked as read-only later, which
47+
means HTTP clients or DirectThingClient subclasses can't write to it.
48+
"""
49+
return self._localonlysetting
4750

48-
readonlysetting.readonly = True
51+
@localonlysetting.setter
52+
def localonlysetting(self, value: str):
53+
self._localonlysetting = value
54+
55+
localonlysetting.readonly = True
56+
57+
localonly_boolsetting: bool = lt.setting(default=False, readonly=True)
58+
59+
@lt.thing_action
60+
def write_localonly_setting(self, value: str) -> None:
61+
"""Change the value of the local-only setting.
62+
63+
This is allowed - the setting is only read-only for code running
64+
over HTTP or via a DirectThingClient. By using this action, we can
65+
check it's writeable for local code.
66+
"""
67+
self.localonlysetting = value
68+
69+
@lt.thing_action
70+
def toggle_localonly_boolsetting(self) -> None:
71+
"""Toggle the local-only bool setting.
72+
73+
Settings with `readonly=True` are read-only for client code via HTTP
74+
or a DirectThingClient. This action checks they are still writeable
75+
from within the Thing.
76+
"""
77+
self.localonly_boolsetting = not self.localonly_boolsetting
4978

5079
@lt.thing_action
5180
def toggle_boolsetting(self):
@@ -57,13 +86,66 @@ def toggle_boolsetting_from_thread(self):
5786
t.start()
5887

5988

89+
TestThingClientDep = lt.deps.direct_thing_client_dependency(TestThing, "/thing/")
90+
TestThingDep = lt.deps.raw_thing_dependency(TestThing)
91+
92+
93+
class ClientThing(lt.Thing):
94+
"""This Thing attempts to set read-only settings on TestThing.
95+
96+
Read-only settings may not be set by DirectThingClient wrappers,
97+
which is what this class tests.
98+
"""
99+
100+
@lt.thing_action
101+
def set_localonlysetting(
102+
self,
103+
client: TestThingClientDep,
104+
val: str,
105+
):
106+
"""Attempt to set a setting with a DirectThingClient."""
107+
client.localonlysetting = val
108+
109+
@lt.thing_action
110+
def set_localonly_boolsetting(
111+
self,
112+
client: TestThingClientDep,
113+
val: bool,
114+
):
115+
"""Attempt to set a setting with a DirectThingClient."""
116+
client.localonly_boolsetting = val
117+
118+
@lt.thing_action
119+
def directly_set_localonlysetting(
120+
self,
121+
test_thing: TestThingDep,
122+
val: str,
123+
):
124+
"""Attempt to set a setting directly."""
125+
test_thing.localonlysetting = val
126+
127+
@lt.thing_action
128+
def directly_set_localonly_boolsetting(
129+
self,
130+
test_thing: TestThingDep,
131+
val: bool,
132+
):
133+
"""Attempt to set a setting directly."""
134+
test_thing.localonly_boolsetting = val
135+
136+
60137
def _get_setting_file(server, thingpath):
61138
path = os.path.join(server.settings_folder, thingpath.lstrip("/"), "settings.json")
62139
return os.path.normpath(path)
63140

64141

65142
def _settings_dict(
66-
boolsetting=False, floatsetting=1.0, stringsetting="foo", dictsetting=None
143+
boolsetting=False,
144+
floatsetting=1.0,
145+
stringsetting="foo",
146+
dictsetting=None,
147+
localonlysetting="Local-only default.",
148+
localonly_boolsetting=False,
67149
):
68150
"""Return the expected settings dictionary
69151
@@ -76,7 +158,8 @@ def _settings_dict(
76158
"floatsetting": floatsetting,
77159
"stringsetting": stringsetting,
78160
"dictsetting": dictsetting,
79-
"readonlysetting": "This is read-only",
161+
"localonlysetting": localonlysetting,
162+
"localonly_boolsetting": localonly_boolsetting,
80163
}
81164

82165

@@ -85,6 +168,11 @@ def thing():
85168
return TestThing()
86169

87170

171+
@pytest.fixture
172+
def client_thing():
173+
return ClientThing()
174+
175+
88176
@pytest.fixture
89177
def server():
90178
with tempfile.TemporaryDirectory() as tempdir:
@@ -98,7 +186,7 @@ def test_setting_available(thing):
98186
assert not thing.boolsetting
99187
assert thing.stringsetting == "foo"
100188
assert thing.floatsetting == 1.0
101-
assert thing.readonlysetting == "This is read-only"
189+
assert thing.localonlysetting == "Local-only default."
102190

103191

104192
def test_functional_settings_save(thing, server):
@@ -153,20 +241,77 @@ def test_data_settings_save(thing, server):
153241
assert json.load(file_obj) == _settings_dict(boolsetting=True)
154242

155243

156-
def test_readonly_setting(thing, server):
157-
"""Check read-only settings cannot be set remotely."""
244+
@pytest.mark.parametrize(
245+
("endpoint", "value"),
246+
[
247+
("localonlysetting", "Other value"),
248+
("localonly_boolsetting", True),
249+
],
250+
)
251+
@pytest.mark.parametrize(
252+
"method",
253+
["http", "direct_thing_client", "direct"],
254+
)
255+
def test_readonly_setting(thing, client_thing, server, endpoint, value, method):
256+
"""Check read-only functional settings cannot be set remotely.
257+
258+
Functional settings must always have a setter, and will be
259+
writeable from within the Thing. However, they should not
260+
be settable remotely or via a DirectThingClient.
261+
262+
This test is a bit complicated, but it checks both a
263+
`.FunctionalSetting` and a `.DataSetting` via all three
264+
methods: HTTP, DirectThingClient, and directly on the Thing.
265+
Only the last method should work.
266+
267+
The test is parametrized so it will run 6 times, trying one
268+
block of code inside the ``with`` block each time.
269+
"""
158270
setting_file = _get_setting_file(server, "/thing")
159271
server.add_thing(thing, "/thing")
272+
server.add_thing(client_thing, "/client_thing")
160273
# No setting file created when first added
161274
assert not os.path.isfile(setting_file)
275+
276+
# Access it over "HTTP" with a TestClient
277+
# This doesn't actually serve over the network but will use
278+
# all the same codepaths.
162279
with TestClient(server.app) as client:
163-
# Check we can read it over HTTP
164-
r = client.get("/thing/readonlysetting")
165-
assert r.json() == "This is read-only"
280+
if method == "http":
281+
# Attempt to set read-only setting
282+
r = client.put(f"/thing/{endpoint}", json=value)
283+
assert r.status_code == 405
284+
285+
if method == "direct_thing_client":
286+
# Attempt to set read-only setting via a DirectThingClient
287+
r = client.post(f"/client_thing/set_{endpoint}", json={"val": value})
288+
assert r.status_code == 201
289+
invocation = poll_task(client, r.json())
290+
# The setting is not changed (that's tested later), but the action
291+
# does complete. It should fail with an error, but this is expected
292+
# behaviour - see #165.
293+
assert invocation["status"] == "completed"
294+
295+
# Check the setting hasn't changed over HTTP
296+
r = client.get(f"/thing/{endpoint}")
297+
assert r.json() == _settings_dict()[endpoint]
166298
assert r.status_code == 200
167-
# Attempt to set read-only setting
168-
r = client.put("/thing/readonlysetting", json="new value")
169-
assert r.status_code == 405
299+
300+
if method == "direct":
301+
# Actually set read-only setting via raw_thing_dependency
302+
r = client.post(
303+
f"/client_thing/directly_set_{endpoint}", json={"val": value}
304+
)
305+
invocation = poll_task(client, r.json())
306+
assert invocation["status"] == "completed"
307+
308+
if method == "direct":
309+
# Setting directly should succeed, so the file should exist.
310+
with open(setting_file, "r", encoding="utf-8") as file_obj:
311+
# Check settings on file match expected dictionary
312+
assert json.load(file_obj) == _settings_dict(**{endpoint: value})
313+
else:
314+
# Other methods fail, so there should be no file here.
170315
assert not os.path.isfile(setting_file) # No file created
171316

172317

0 commit comments

Comments
 (0)