Skip to content

Commit 20d484a

Browse files
committed
Add tests for improved code coverage
1 parent 780b17f commit 20d484a

5 files changed

Lines changed: 215 additions & 0 deletions

File tree

.coverage

-28 KB
Binary file not shown.

tests/unit/test_client.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,88 @@ def sign(self, message_bytes, pad, algo):
291291
await client2.close()
292292

293293

294+
@pytest.mark.asyncio
295+
async def test_json_response_non_dict_logging():
296+
"""Test JSON response handling when response is non-dict (array, etc)."""
297+
client = FmdClient("https://fmd.example.com")
298+
client.access_token = "token"
299+
300+
await client._ensure_session()
301+
with aioresponses() as m:
302+
# Return JSON array instead of dict
303+
m.put("https://fmd.example.com/api/v1/pictures", payload=["item1", "item2"])
304+
305+
try:
306+
result = await client.get_pictures()
307+
# Should handle non-dict response gracefully
308+
assert result == ["item1", "item2"]
309+
finally:
310+
await client.close()
311+
312+
313+
@pytest.mark.asyncio
314+
async def test_pictures_non_list_response():
315+
"""Test get_pictures handles non-list response gracefully."""
316+
client = FmdClient("https://fmd.example.com")
317+
client.access_token = "token"
318+
319+
await client._ensure_session()
320+
with aioresponses() as m:
321+
# Return non-list response
322+
m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": "not-a-list"})
323+
324+
try:
325+
result = await client.get_pictures()
326+
# Should return empty list for unexpected type
327+
assert result == []
328+
finally:
329+
await client.close()
330+
331+
332+
@pytest.mark.asyncio
333+
async def test_export_data_zip_with_png(monkeypatch, tmp_path):
334+
"""Test export_data_zip detects PNG format correctly."""
335+
client = FmdClient("https://fmd.example.com")
336+
client.access_token = "token"
337+
client._fmd_id = "test-device"
338+
339+
class DummyKey:
340+
def decrypt(self, packet, padding_obj):
341+
return b"\x00" * 32
342+
343+
client.private_key = DummyKey()
344+
345+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
346+
347+
session_key = b"\x00" * 32
348+
aesgcm = AESGCM(session_key)
349+
iv = b"\x01" * 12
350+
351+
# Create a PNG image (magic bytes: \x89PNG)
352+
png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
353+
png_b64 = base64.b64encode(png_bytes).decode("utf-8")
354+
ciphertext = aesgcm.encrypt(iv, png_b64.encode("utf-8"), None)
355+
blob = b"\xaa" * 384 + iv + ciphertext
356+
blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=")
357+
358+
with aioresponses() as m:
359+
m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "0"})
360+
m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": [blob_b64]})
361+
362+
out_file = tmp_path / "export_png.zip"
363+
try:
364+
await client.export_data_zip(str(out_file))
365+
import zipfile
366+
367+
with zipfile.ZipFile(out_file, "r") as zipf:
368+
names = zipf.namelist()
369+
# Should detect PNG and use .png extension
370+
png_files = [n for n in names if n.endswith(".png")]
371+
assert len(png_files) == 1
372+
finally:
373+
await client.close()
374+
375+
294376
@pytest.mark.asyncio
295377
async def test_set_ringer_mode_validation():
296378
"""Test set_ringer_mode validates mode parameter."""

tests/unit/test_coverage_improvements.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,30 @@ async def test_send_command_with_missing_private_key():
522522
await client.close()
523523

524524

525+
@pytest.mark.asyncio
526+
async def test_device_fetch_pictures_deprecated():
527+
"""Test fetch_pictures() deprecated wrapper emits warning."""
528+
client = FmdClient("https://fmd.example.com")
529+
client.access_token = "token"
530+
device = Device(client, "test-device")
531+
532+
with aioresponses() as m:
533+
m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": ["blob1", "blob2"]})
534+
535+
try:
536+
import warnings
537+
538+
with warnings.catch_warnings(record=True) as w:
539+
warnings.simplefilter("always")
540+
result = await device.fetch_pictures(2)
541+
assert len(w) == 1
542+
assert issubclass(w[0].category, DeprecationWarning)
543+
assert "fetch_pictures() is deprecated" in str(w[0].message)
544+
assert len(result) == 2
545+
finally:
546+
await client.close()
547+
548+
525549
@pytest.mark.asyncio
526550
async def test_client_error_generic():
527551
"""Test generic ClientError handling."""

tests/unit/test_device_wipe_validation.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,20 @@ def sign(self, message_bytes, pad, algo):
7777
await client.close()
7878

7979

80+
@pytest.mark.asyncio
81+
async def test_wipe_spaces_specific_error():
82+
"""Test that spaces in PIN trigger the alphanumeric error message."""
83+
client = FmdClient("https://fmd.example.com")
84+
device = Device(client, "test-device")
85+
86+
try:
87+
# Space causes isalnum() to fail, hitting the alphanumeric check first
88+
with pytest.raises(OperationError, match="alphanumeric ASCII"):
89+
await device.wipe(pin="my pin", confirm=True)
90+
finally:
91+
await client.close()
92+
93+
8094
@pytest.mark.asyncio
8195
async def test_wipe_rejects_empty_pin():
8296
"""Test that wipe rejects empty PIN."""

tests/unit/test_resume.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,98 @@ def decrypt(self, packet, padding_obj):
4646

4747
await resumed.close()
4848
await client.close()
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_resume_with_der_key():
53+
"""Test resume() with DER-encoded private key (fallback path)."""
54+
from cryptography.hazmat.primitives.asymmetric import rsa
55+
from cryptography.hazmat.primitives import serialization
56+
57+
# Generate a real RSA key and encode as DER
58+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
59+
der_bytes = key.private_bytes(
60+
encoding=serialization.Encoding.DER,
61+
format=serialization.PrivateFormat.PKCS8,
62+
encryption_algorithm=serialization.NoEncryption(),
63+
)
64+
65+
# Resume with DER bytes (should trigger ValueError in PEM load, then succeed with DER)
66+
client = await FmdClient.resume(
67+
"https://fmd.example.com",
68+
"alice",
69+
"token123",
70+
der_bytes,
71+
password_hash="$argon2id$v=19$m=131072,t=1,p=4$dummy$hash",
72+
)
73+
74+
try:
75+
assert client.access_token == "token123"
76+
assert client.private_key is not None
77+
finally:
78+
await client.close()
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_401_without_password_or_hash():
83+
"""Test 401 response when neither password nor hash available raises error."""
84+
from fmd_api.exceptions import FmdApiException
85+
86+
client = FmdClient("https://fmd.example.com")
87+
client.access_token = "old_token"
88+
client._fmd_id = "alice"
89+
client._password = None
90+
client._password_hash = None
91+
92+
await client._ensure_session()
93+
with aioresponses() as m:
94+
# Returns 401 and no password/hash available
95+
m.put("https://fmd.example.com/api/v1/locationDataSize", status=401)
96+
97+
try:
98+
with pytest.raises(FmdApiException, match="401"):
99+
await client.get_locations()
100+
finally:
101+
await client.close()
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_reauth_with_hash_missing_fields():
106+
"""Test _reauth_with_hash raises when ID or hash missing."""
107+
from fmd_api.exceptions import FmdApiException
108+
109+
client = FmdClient("https://fmd.example.com")
110+
client._fmd_id = None
111+
client._password_hash = None
112+
113+
try:
114+
with pytest.raises(FmdApiException, match="Hash-based reauth not possible"):
115+
await client._reauth_with_hash()
116+
finally:
117+
await client.close()
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_from_auth_artifacts_missing_fields():
122+
"""Test from_auth_artifacts raises on missing required fields."""
123+
incomplete = {"base_url": "https://fmd.example.com", "fmd_id": "alice"}
124+
125+
with pytest.raises(ValueError, match="Missing artifact fields"):
126+
await FmdClient.from_auth_artifacts(incomplete)
127+
128+
129+
@pytest.mark.asyncio
130+
async def test_export_artifacts_without_private_key():
131+
"""Test export_auth_artifacts raises when private key not loaded."""
132+
from fmd_api.exceptions import FmdApiException
133+
134+
client = FmdClient("https://fmd.example.com")
135+
client._fmd_id = "alice"
136+
client.access_token = "token"
137+
client.private_key = None
138+
139+
try:
140+
with pytest.raises(FmdApiException, match="Cannot export artifacts"):
141+
await client.export_auth_artifacts()
142+
finally:
143+
await client.close()

0 commit comments

Comments
 (0)