Skip to content

Commit 6a0bda6

Browse files
committed
Add code test coverage
1 parent d8c37d2 commit 6a0bda6

3 files changed

Lines changed: 357 additions & 1 deletion

File tree

fmd_api/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,18 @@ async def get_pictures(self, num_to_get: int = -1) -> List[Any]:
282282
await self._ensure_session()
283283
async with self._session.put(f"{self.base_url}/api/v1/pictures", json={"IDT": self.access_token, "Data": ""}) as resp:
284284
resp.raise_for_status()
285-
all_pictures = await resp.json()
285+
json_data = await resp.json()
286+
# Extract the Data field if it exists, otherwise use the response as-is
287+
all_pictures = json_data.get("Data", json_data) if isinstance(json_data, dict) else json_data
286288
except aiohttp.ClientError as e:
287289
log.warning(f"Failed to get pictures: {e}. The endpoint may not exist or requires a different method.")
288290
return []
289291

292+
# Ensure all_pictures is a list
293+
if not isinstance(all_pictures, list):
294+
log.warning(f"Unexpected pictures response type: {type(all_pictures)}")
295+
return []
296+
290297
if num_to_get == -1:
291298
log.info(f"Found {len(all_pictures)} pictures to download.")
292299
return all_pictures

tests/unit/test_client.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,174 @@ async def test_empty_location_response():
301301
assert locs == []
302302
finally:
303303
await client.close()
304+
305+
@pytest.mark.asyncio
306+
async def test_get_all_locations():
307+
"""Test get_locations with num_to_get=-1 fetches all locations."""
308+
client = FmdClient("https://fmd.example.com")
309+
client.access_token = "token"
310+
311+
class DummyKey:
312+
def decrypt(self, packet, padding_obj):
313+
return b'\x00' * 32
314+
client.private_key = DummyKey()
315+
316+
# Create 3 location blobs
317+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
318+
session_key = b'\x00' * 32
319+
aesgcm = AESGCM(session_key)
320+
321+
blobs = []
322+
for i in range(3):
323+
iv = bytes([i+1] * 12)
324+
plaintext = json.dumps({"lat": float(i), "lon": float(i*10), "date": 1600000000000, "bat": 80}).encode('utf-8')
325+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
326+
blob = b'\xAA' * 384 + iv + ciphertext
327+
blobs.append(base64.b64encode(blob).decode('utf-8').rstrip('='))
328+
329+
await client._ensure_session()
330+
331+
with aioresponses() as m:
332+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "3"})
333+
# When fetching all, indices are 0, 1, 2
334+
for i, blob_b64 in enumerate(blobs):
335+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64})
336+
337+
try:
338+
locs = await client.get_locations(num_to_get=-1)
339+
assert len(locs) == 3
340+
finally:
341+
await client.close()
342+
343+
@pytest.mark.asyncio
344+
async def test_skip_empty_locations():
345+
"""Test that skip_empty skips over empty blobs to find valid ones."""
346+
client = FmdClient("https://fmd.example.com")
347+
client.access_token = "token"
348+
349+
class DummyKey:
350+
def decrypt(self, packet, padding_obj):
351+
return b'\x00' * 32
352+
client.private_key = DummyKey()
353+
354+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
355+
session_key = b'\x00' * 32
356+
aesgcm = AESGCM(session_key)
357+
iv = b'\x05' * 12
358+
plaintext = b'{"lat":5.0,"lon":10.0,"date":1600000000000,"bat":90}'
359+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
360+
blob = b'\xAA' * 384 + iv + ciphertext
361+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
362+
363+
await client._ensure_session()
364+
365+
with aioresponses() as m:
366+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "3"})
367+
# Index 2 (most recent): empty
368+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""})
369+
# Index 1: empty
370+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""})
371+
# Index 0: valid
372+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64})
373+
374+
try:
375+
locs = await client.get_locations(num_to_get=1, skip_empty=True)
376+
assert len(locs) == 1
377+
assert locs[0] == blob_b64
378+
finally:
379+
await client.close()
380+
381+
@pytest.mark.asyncio
382+
async def test_picture_endpoint_error():
383+
"""Test error handling when pictures endpoint fails."""
384+
client = FmdClient("https://fmd.example.com")
385+
client.access_token = "token"
386+
387+
await client._ensure_session()
388+
389+
with aioresponses() as m:
390+
# pictures endpoint returns 500 error
391+
m.put("https://fmd.example.com/api/v1/pictures", status=500)
392+
393+
try:
394+
# Should return empty list on error (client logs warning)
395+
pictures = await client.get_pictures()
396+
assert pictures == []
397+
finally:
398+
await client.close()
399+
400+
@pytest.mark.asyncio
401+
async def test_multiple_commands_sequence():
402+
"""Test sending multiple commands in sequence."""
403+
client = FmdClient("https://fmd.example.com")
404+
client.access_token = "token"
405+
406+
class DummySigner:
407+
def sign(self, message_bytes, pad, algo):
408+
return b"\xAB" * 64
409+
client.private_key = DummySigner()
410+
411+
await client._ensure_session()
412+
413+
with aioresponses() as m:
414+
# Mock multiple command requests
415+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
416+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
417+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
418+
419+
try:
420+
result1 = await client.send_command("ring")
421+
assert result1 is True
422+
423+
result2 = await client.send_command("lock")
424+
assert result2 is True
425+
426+
result3 = await client.send_command("locate")
427+
assert result3 is True
428+
finally:
429+
await client.close()
430+
431+
@pytest.mark.asyncio
432+
async def test_get_pictures_pagination():
433+
"""Test get_pictures with num_to_get limit."""
434+
client = FmdClient("https://fmd.example.com")
435+
client.access_token = "token"
436+
437+
await client._ensure_session()
438+
439+
with aioresponses() as m:
440+
# Mock pictures response with multiple pictures (already in order)
441+
mock_pictures = [
442+
{"id": 2, "date": 1600000002000},
443+
{"id": 1, "date": 1600000001000},
444+
{"id": 0, "date": 1600000000000}
445+
]
446+
m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": mock_pictures})
447+
448+
try:
449+
pictures = await client.get_pictures(num_to_get=2)
450+
assert len(pictures) == 2
451+
# Should get the 2 most recent (last 2, reversed)
452+
# [-2:][::-1] on [2,1,0] gives [1,0] then reverses to [0,1]
453+
assert pictures[0]["id"] == 0
454+
assert pictures[1]["id"] == 1
455+
finally:
456+
await client.close()
457+
458+
@pytest.mark.asyncio
459+
async def test_authenticate_error_handling():
460+
"""Test authenticate with invalid credentials."""
461+
client = FmdClient("https://fmd.example.com")
462+
463+
await client._ensure_session()
464+
465+
with aioresponses() as m:
466+
# Mock authentication endpoints - first call is to /api/v1/salt
467+
m.put("https://fmd.example.com/api/v1/salt", status=401)
468+
469+
try:
470+
from fmd_api.exceptions import FmdApiException
471+
with pytest.raises(FmdApiException, match="API request failed for /api/v1/salt"):
472+
await client.authenticate("bad_id", "bad_password", session_duration=3600)
473+
finally:
474+
await client.close()

tests/unit/test_device.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,181 @@ def decrypt(self, packet, padding_obj):
235235
assert abs(loc3.lat - 15.0) < 1e-6
236236
finally:
237237
await client.close()
238+
239+
@pytest.mark.asyncio
240+
async def test_device_cached_location_property():
241+
"""Test Device.cached_location property access."""
242+
client = FmdClient("https://fmd.example.com")
243+
client.access_token = "token"
244+
class DummyKey:
245+
def decrypt(self, packet, padding_obj):
246+
return b'\x00' * 32
247+
client.private_key = DummyKey()
248+
249+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
250+
session_key = b'\x00' * 32
251+
aesgcm = AESGCM(session_key)
252+
iv = b'\x06' * 12
253+
plaintext = b'{"lat":20.0,"lon":30.0,"date":1600000000000,"bat":75}'
254+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
255+
blob = b'\xAA' * 384 + iv + ciphertext
256+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
257+
258+
client.access_token = "token"
259+
device = Device(client, "test-device")
260+
261+
# Initially None
262+
assert device.cached_location is None
263+
264+
with aioresponses() as m:
265+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"})
266+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64})
267+
try:
268+
await device.refresh()
269+
# Now should have cached location
270+
assert device.cached_location is not None
271+
assert abs(device.cached_location.lat - 20.0) < 1e-6
272+
finally:
273+
await client.close()
274+
275+
@pytest.mark.asyncio
276+
async def test_device_refresh_without_force():
277+
"""Test Device.refresh with force=False doesn't re-fetch if cached."""
278+
client = FmdClient("https://fmd.example.com")
279+
client.access_token = "token"
280+
class DummyKey:
281+
def decrypt(self, packet, padding_obj):
282+
return b'\x00' * 32
283+
client.private_key = DummyKey()
284+
285+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
286+
session_key = b'\x00' * 32
287+
aesgcm = AESGCM(session_key)
288+
iv = b'\x07' * 12
289+
plaintext = b'{"lat":25.0,"lon":35.0,"date":1600000000000,"bat":85}'
290+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
291+
blob = b'\xAA' * 384 + iv + ciphertext
292+
blob_b64 = base64.b64encode(blob).decode('utf-8').rstrip('=')
293+
294+
client.access_token = "token"
295+
device = Device(client, "test-device")
296+
297+
with aioresponses() as m:
298+
# Only one set of mocks
299+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"})
300+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64})
301+
try:
302+
await device.refresh()
303+
loc1 = device.cached_location
304+
305+
# Second refresh without force should not make HTTP calls (mocks would fail if it did)
306+
await device.refresh(force=False)
307+
loc2 = device.cached_location
308+
309+
assert loc1 is loc2 # Same cached object
310+
finally:
311+
await client.close()
312+
313+
@pytest.mark.asyncio
314+
async def test_device_picture_commands():
315+
"""Test Device picture-related command shortcuts."""
316+
client = FmdClient("https://fmd.example.com")
317+
client.access_token = "token"
318+
class DummySigner:
319+
def sign(self, message_bytes, pad, algo):
320+
return b"\xAB" * 64
321+
client.private_key = DummySigner()
322+
323+
await client._ensure_session()
324+
device = Device(client, "test-device")
325+
326+
with aioresponses() as m:
327+
# take_front_photo
328+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
329+
# take_rear_photo
330+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
331+
332+
try:
333+
result1 = await device.take_front_photo()
334+
assert result1 is True
335+
336+
result2 = await device.take_rear_photo()
337+
assert result2 is True
338+
finally:
339+
await client.close()
340+
341+
@pytest.mark.asyncio
342+
async def test_device_lock_with_message():
343+
"""Test Device.lock with custom message."""
344+
client = FmdClient("https://fmd.example.com")
345+
client.access_token = "token"
346+
class DummySigner:
347+
def sign(self, message_bytes, pad, algo):
348+
return b"\xAB" * 64
349+
client.private_key = DummySigner()
350+
351+
await client._ensure_session()
352+
device = Device(client, "test-device")
353+
354+
with aioresponses() as m:
355+
m.post("https://fmd.example.com/api/v1/command", status=200, body="OK")
356+
357+
try:
358+
result = await device.lock("Please return this device")
359+
assert result is True
360+
finally:
361+
await client.close()
362+
363+
@pytest.mark.asyncio
364+
async def test_device_multiple_history_calls():
365+
"""Test Device.get_history can be called multiple times."""
366+
client = FmdClient("https://fmd.example.com")
367+
client.access_token = "token"
368+
class DummyKey:
369+
def decrypt(self, packet, padding_obj):
370+
return b'\x00' * 32
371+
client.private_key = DummyKey()
372+
373+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
374+
session_key = b'\x00' * 32
375+
aesgcm = AESGCM(session_key)
376+
377+
blobs = []
378+
for i in range(2):
379+
iv = bytes([i+10] * 12)
380+
plaintext = json.dumps({"lat": float(30+i), "lon": float(40+i), "date": 1600000000000 + i*1000, "bat": 80}).encode('utf-8')
381+
ciphertext = aesgcm.encrypt(iv, plaintext, None)
382+
blob = b'\xAA' * 384 + iv + ciphertext
383+
blobs.append(base64.b64encode(blob).decode('utf-8').rstrip('='))
384+
385+
client.access_token = "token"
386+
device = Device(client, "test-device")
387+
388+
with aioresponses() as m:
389+
# First call to get_history
390+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"})
391+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[0]})
392+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[1]})
393+
394+
# Second call to get_history
395+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"})
396+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[0]})
397+
m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[1]})
398+
399+
try:
400+
# First iteration
401+
locs1 = []
402+
async for loc in device.get_history(limit=2):
403+
locs1.append(loc)
404+
assert len(locs1) == 2
405+
406+
# Second iteration (should work independently)
407+
locs2 = []
408+
async for loc in device.get_history(limit=2):
409+
locs2.append(loc)
410+
assert len(locs2) == 2
411+
412+
# Both should have same data (different Location objects)
413+
assert abs(locs1[0].lat - locs2[0].lat) < 1e-6
414+
finally:
415+
await client.close()

0 commit comments

Comments
 (0)