Skip to content

Commit d8c37d2

Browse files
committed
Fix bug prevented fetching locations when only 1 location exists; add test coverage
1 parent 2f8fb5c commit d8c37d2

File tree

3 files changed

+369
-4
lines changed

3 files changed

+369
-4
lines changed

fmd_api/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ async def get_locations(self, num_to_get: int = -1, skip_empty: bool = True, max
251251
start_index = size - 1
252252

253253
if skip_empty:
254-
indices = range(start_index, max(0, start_index - max_attempts), -1)
254+
indices = range(start_index, max(-1, start_index - max_attempts), -1)
255255
log.info(f"Will search for {num_to_download} non-empty location(s) starting from index {start_index}")
256256
else:
257257
end_index = size - num_to_download

tests/unit/test_client.py

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ def decrypt(self, packet, padding_obj):
3939
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
4040

4141
# Mock the endpoints used by get_locations:
42+
client.access_token = "dummy-token"
43+
# Ensure session is created before entering aioresponses context
44+
await client._ensure_session()
45+
4246
with aioresponses() as m:
4347
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"})
4448
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64})
45-
client.access_token = "dummy-token"
4649
try:
4750
locations = await client.get_locations(num_to_get=1)
4851
assert len(locations) == 1
@@ -94,3 +97,207 @@ async def test_export_data_zip_stream(monkeypatch, tmp_path):
9497
assert content.startswith(b'PK\x03\x04')
9598
finally:
9699
await client.close()
100+
101+
@pytest.mark.asyncio
102+
async def test_take_picture_validation():
103+
"""Test take_picture validates camera parameter."""
104+
client = FmdClient("https://fmd.example.com")
105+
client.access_token = "token"
106+
class DummySigner:
107+
def sign(self, message_bytes, pad, algo):
108+
return b"\xAB" * 64
109+
client.private_key = DummySigner()
110+
111+
with aioresponses() as m:
112+
# Valid cameras should work
113+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
114+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
115+
try:
116+
assert await client.take_picture("front") is True
117+
assert await client.take_picture("back") is True
118+
finally:
119+
await client.close()
120+
121+
# Invalid camera should raise ValueError
122+
client2 = FmdClient("https://fmd.example.com")
123+
client2.access_token = "token"
124+
client2.private_key = DummySigner()
125+
try:
126+
with pytest.raises(ValueError, match="Invalid camera.*Must be 'front' or 'back'"):
127+
await client2.take_picture("rear")
128+
finally:
129+
await client2.close()
130+
131+
@pytest.mark.asyncio
132+
async def test_set_ringer_mode_validation():
133+
"""Test set_ringer_mode validates mode parameter."""
134+
client = FmdClient("https://fmd.example.com")
135+
client.access_token = "token"
136+
class DummySigner:
137+
def sign(self, message_bytes, pad, algo):
138+
return b"\xAB" * 64
139+
client.private_key = DummySigner()
140+
141+
with aioresponses() as m:
142+
# Valid modes should work
143+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
144+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
145+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
146+
try:
147+
assert await client.set_ringer_mode("normal") is True
148+
assert await client.set_ringer_mode("vibrate") is True
149+
assert await client.set_ringer_mode("silent") is True
150+
finally:
151+
await client.close()
152+
153+
# Invalid mode should raise ValueError
154+
client2 = FmdClient("https://fmd.example.com")
155+
client2.access_token = "token"
156+
client2.private_key = DummySigner()
157+
try:
158+
with pytest.raises(ValueError, match="Invalid ringer mode.*Must be"):
159+
await client2.set_ringer_mode("loud")
160+
finally:
161+
await client2.close()
162+
163+
@pytest.mark.asyncio
164+
async def test_request_location_providers():
165+
"""Test request_location with different providers."""
166+
client = FmdClient("https://fmd.example.com")
167+
client.access_token = "token"
168+
class DummySigner:
169+
def sign(self, message_bytes, pad, algo):
170+
return b"\xAB" * 64
171+
client.private_key = DummySigner()
172+
173+
with aioresponses() as m:
174+
# Mock all provider requests
175+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
176+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
177+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
178+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
179+
try:
180+
assert await client.request_location("all") is True
181+
assert await client.request_location("gps") is True
182+
assert await client.request_location("cell") is True
183+
assert await client.request_location("last") is True
184+
finally:
185+
await client.close()
186+
187+
@pytest.mark.asyncio
188+
async def test_set_bluetooth_and_dnd():
189+
"""Test set_bluetooth and set_do_not_disturb commands."""
190+
client = FmdClient("https://fmd.example.com")
191+
client.access_token = "token"
192+
class DummySigner:
193+
def sign(self, message_bytes, pad, algo):
194+
return b"\xAB" * 64
195+
client.private_key = DummySigner()
196+
197+
with aioresponses() as m:
198+
# Mock commands
199+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
200+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
201+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
202+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
203+
try:
204+
assert await client.set_bluetooth(True) is True
205+
assert await client.set_bluetooth(False) is True
206+
assert await client.set_do_not_disturb(True) is True
207+
assert await client.set_do_not_disturb(False) is True
208+
finally:
209+
await client.close()
210+
211+
@pytest.mark.asyncio
212+
async def test_get_device_stats():
213+
"""Test get_device_stats sends stats command."""
214+
client = FmdClient("https://fmd.example.com")
215+
client.access_token = "token"
216+
class DummySigner:
217+
def sign(self, message_bytes, pad, algo):
218+
return b"\xAB" * 64
219+
client.private_key = DummySigner()
220+
221+
with aioresponses() as m:
222+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
223+
try:
224+
assert await client.get_device_stats() is True
225+
finally:
226+
await client.close()
227+
228+
@pytest.mark.asyncio
229+
async def test_decrypt_data_blob_too_small():
230+
"""Test decrypt_data_blob raises FmdApiException for small blobs."""
231+
from fmd_api.exceptions import FmdApiException
232+
233+
client = FmdClient("https://fmd.example.com")
234+
class DummyKey:
235+
def decrypt(self, packet, padding_obj):
236+
return b"\x00" * 32
237+
client.private_key = DummyKey()
238+
239+
# Blob must be at least RSA_KEY_SIZE_BYTES (384) + AES_GCM_IV_SIZE_BYTES (12) = 396 bytes
240+
too_small = base64.b64encode(b"x" * 100).decode('utf-8')
241+
242+
with pytest.raises(FmdApiException, match="Blob too small for decryption"):
243+
client.decrypt_data_blob(too_small)
244+
245+
@pytest.mark.asyncio
246+
async def test_get_pictures_direct():
247+
"""Test get_pictures endpoint directly."""
248+
client = FmdClient("https://fmd.example.com")
249+
client.access_token = "token"
250+
251+
with aioresponses() as m:
252+
# Mock pictures endpoint returning list of blobs
253+
m.put("https://fmd.example.com/api/v1/pictures", payload=["blob1", "blob2", "blob3"])
254+
try:
255+
pics = await client.get_pictures(num_to_get=2)
256+
assert len(pics) == 2
257+
# Should get the 2 most recent (last 2 in reverse)
258+
assert pics == ["blob3", "blob2"]
259+
finally:
260+
await client.close()
261+
262+
@pytest.mark.asyncio
263+
async def test_http_error_handling():
264+
"""Test client handles various HTTP errors."""
265+
from fmd_api.exceptions import FmdApiException
266+
267+
client = FmdClient("https://fmd.example.com")
268+
client.access_token = "token"
269+
270+
# Test 404
271+
with aioresponses() as m:
272+
m.put("https://fmd.example.com/api/v1/locationDataSize", status=404)
273+
try:
274+
with pytest.raises(FmdApiException):
275+
await client.get_locations()
276+
finally:
277+
await client.close()
278+
279+
# Test 500
280+
client2 = FmdClient("https://fmd.example.com")
281+
client2.access_token = "token"
282+
with aioresponses() as m:
283+
m.put("https://fmd.example.com/api/v1/locationDataSize", status=500)
284+
try:
285+
with pytest.raises(FmdApiException):
286+
await client2.get_locations()
287+
finally:
288+
await client2.close()
289+
290+
@pytest.mark.asyncio
291+
async def test_empty_location_response():
292+
"""Test handling of empty location data."""
293+
client = FmdClient("https://fmd.example.com")
294+
client.access_token = "token"
295+
296+
with aioresponses() as m:
297+
# Server reports 0 locations
298+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "0"})
299+
try:
300+
locs = await client.get_locations()
301+
assert locs == []
302+
finally:
303+
await client.close()

0 commit comments

Comments
 (0)