55
66from __future__ import annotations
77
8- import logging
8+ import dataclasses
99import os
1010import re
11- import tempfile
1211
1312import pytest
1413
@@ -56,7 +55,7 @@ def test_log_config_defaults():
5655
5756def test_log_config_frozen ():
5857 cfg = LogConfig ()
59- with pytest .raises (Exception ): # frozen dataclass raises FrozenInstanceError
58+ with pytest .raises (dataclasses . FrozenInstanceError ):
6059 cfg .log_folder = "/other" # type: ignore[misc]
6160
6261
@@ -69,8 +68,8 @@ def test_log_file_created(tmp_path):
6968 _make_logger (tmp_path )
7069 log_files = [f for f in os .listdir (tmp_path ) if f .endswith (".log" )]
7170 assert len (log_files ) == 1
72- # File should match: <prefix>_YYYYMMDD_HHMMSS .log
73- assert re .match (r"dataverse_\d{8}_\d{6}\.log" , log_files [0 ])
71+ # File should match: <prefix>_YYYYMMDD_HHMMSS_microseconds .log
72+ assert re .match (r"dataverse_\d{8}_\d{6}_\d+ \.log" , log_files [0 ])
7473
7574
7675def test_log_file_custom_prefix (tmp_path ):
@@ -284,8 +283,8 @@ def test_http_client_with_logger_logs_request_and_response(tmp_path):
284283# ---------------------------------------------------------------------------
285284
286285
287- def test_dataverse_config_log_config_field ():
288- cfg = LogConfig (log_folder = "/tmp/test_logs" )
286+ def test_dataverse_config_log_config_field (tmp_path ):
287+ cfg = LogConfig (log_folder = str ( tmp_path ) )
289288 dc = DataverseConfig (log_config = cfg )
290289 assert dc .log_config is cfg
291290
@@ -298,3 +297,122 @@ def test_dataverse_config_log_config_default_is_none():
298297def test_dataverse_config_from_env_log_config_none ():
299298 dc = DataverseConfig .from_env ()
300299 assert dc .log_config is None
300+
301+
302+ # ---------------------------------------------------------------------------
303+ # Fix #2: empty dict body must be logged, not silently dropped
304+ # ---------------------------------------------------------------------------
305+
306+
307+ def test_http_client_logs_empty_dict_body (tmp_path ):
308+ """An empty JSON body {} is falsy but must still be logged (not skipped via `or`)."""
309+ from unittest .mock import MagicMock
310+
311+ from PowerPlatform .Dataverse .core ._http import _HttpClient
312+
313+ mock_resp = MagicMock ()
314+ mock_resp .status_code = 200
315+ mock_resp .headers = {}
316+ mock_resp .text = ""
317+
318+ session = MagicMock ()
319+ session .request .return_value = mock_resp
320+
321+ cfg = LogConfig (log_folder = str (tmp_path ))
322+ http_logger = _HttpLogger (cfg )
323+ client = _HttpClient (session = session , logger = http_logger )
324+ client ._request ("POST" , "https://example.com/accounts" , json = {})
325+
326+ content = _read_log (tmp_path )
327+ assert ">>> REQUEST" in content
328+ assert "{}" in content
329+
330+
331+ # ---------------------------------------------------------------------------
332+ # Fix #3: resp.text not decoded when body logging disabled
333+ # ---------------------------------------------------------------------------
334+
335+
336+ def test_http_client_does_not_decode_response_body_when_logging_disabled (tmp_path ):
337+ """When max_body_bytes=0, resp.text must not be accessed (no unnecessary decoding)."""
338+ from unittest .mock import MagicMock , PropertyMock
339+
340+ from PowerPlatform .Dataverse .core ._http import _HttpClient
341+
342+ mock_resp = MagicMock ()
343+ mock_resp .status_code = 200
344+ mock_resp .headers = {}
345+ # If resp.text is accessed, the test will fail
346+ type(mock_resp ).text = PropertyMock (side_effect = AssertionError ("resp.text should not be accessed" ))
347+
348+ session = MagicMock ()
349+ session .request .return_value = mock_resp
350+
351+ cfg = LogConfig (log_folder = str (tmp_path ), max_body_bytes = 0 )
352+ http_logger = _HttpLogger (cfg )
353+ client = _HttpClient (session = session , logger = http_logger )
354+ # Should not raise
355+ client ._request ("GET" , "https://example.com" )
356+
357+
358+ # ---------------------------------------------------------------------------
359+ # Fix #4: _HttpLogger.close() releases file handle
360+ # ---------------------------------------------------------------------------
361+
362+
363+ def test_http_logger_close_releases_handler (tmp_path ):
364+ """close() flushes and removes the handler so the file handle is released."""
365+ logger = _make_logger (tmp_path )
366+ logger .log_request ("GET" , "https://example.com" )
367+ logger .close ()
368+ # After close the internal logger should have no handlers
369+ assert len (logger ._logger .handlers ) == 0
370+
371+
372+ def test_http_logger_close_is_idempotent (tmp_path ):
373+ """Calling close() twice must not raise."""
374+ logger = _make_logger (tmp_path )
375+ logger .close ()
376+ logger .close () # should not raise
377+
378+
379+ # ---------------------------------------------------------------------------
380+ # Fix #5: filename uses microsecond precision (no collision)
381+ # ---------------------------------------------------------------------------
382+
383+
384+ def test_log_filenames_unique_for_rapid_creation (tmp_path ):
385+ """Two loggers created back-to-back get distinct filenames."""
386+ l1 = _make_logger (tmp_path )
387+ l2 = _make_logger (tmp_path )
388+ log_files = [f for f in os .listdir (tmp_path ) if f .endswith (".log" )]
389+ l1 .close ()
390+ l2 .close ()
391+ assert len (log_files ) == 2
392+ assert log_files [0 ] != log_files [1 ]
393+
394+
395+ # ---------------------------------------------------------------------------
396+ # Fix #6: byte-correct truncation for Unicode bodies
397+ # ---------------------------------------------------------------------------
398+
399+
400+ def test_body_truncation_unicode_byte_accurate (tmp_path ):
401+ """Truncation respects byte budget even for multi-byte Unicode characters."""
402+ # Each '€' is 3 UTF-8 bytes; 10 bytes limit should cut within a few chars
403+ logger = _make_logger (tmp_path , max_body_bytes = 10 )
404+ body = "€" * 20 # 60 bytes total
405+ logger .log_request ("POST" , "https://example.com" , body = body )
406+ content = _read_log (tmp_path )
407+ assert "truncated" in content
408+ assert "60 bytes total" in content
409+
410+
411+ def test_body_truncation_reports_byte_count_not_char_count (tmp_path ):
412+ """The truncation message reports UTF-8 byte length, not character count."""
413+ # 5 chars × 3 bytes each = 15 bytes; limit 5 bytes → should report 15 bytes
414+ logger = _make_logger (tmp_path , max_body_bytes = 5 )
415+ body = "€€€€€" # 5 chars, 15 bytes
416+ logger .log_request ("POST" , "https://example.com" , body = body )
417+ content = _read_log (tmp_path )
418+ assert "15 bytes total" in content
0 commit comments