88from fastapi .testclient import TestClient
99
1010import labthings_fastapi as lt
11+ from .temp_client import poll_task
1112
1213
1314class 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+
60137def _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
65142def _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
89177def 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
104192def 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