@@ -68,9 +68,14 @@ def _get_data(api_token, node_url):
6868
6969
7070def _node_name_from_filename (file_name : str ) -> str :
71- """Mirror of the node-name extraction in sync.main()."""
71+ """Mirror of the node-name extraction in sync.main() (Mode 3).
72+
73+ Real file formats produced by the system:
74+ PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
75+ PULL: last_result.NodeName.log → parts[1]
76+ """
7277 parts = file_name .split ("." )
73- return parts [2 ] if ("decoded" in file_name or "encoded" in file_name ) else parts [1 ]
78+ return parts [3 ] if ("decoded" in file_name or "encoded" in file_name ) else parts [1 ]
7479
7580
7681def _should_delete_after_process (filename : str ) -> bool :
@@ -306,23 +311,33 @@ def test_returns_empty_string_on_non_200(self):
306311
307312class TestNodeNameExtraction :
308313
309- def test_simple_filename (self ):
310- # last_result.MyNode.log → "MyNode"
314+ def test_pull_mode_filename (self ):
315+ # PULL mode: last_result.MyNode.log → "MyNode"
311316 assert _node_name_from_filename ("last_result.MyNode.log" ) == "MyNode"
312317
313- def test_decoded_filename (self ):
314- # last_result.decoded.MyNode.1.log → "MyNode"
315- assert _node_name_from_filename ("last_result.decoded.MyNode.1.log" ) == "MyNode"
318+ def test_push_decoded_filename (self ):
319+ # PUSH mode (post-decode): last_result.ARPSCAN .decoded.MyNode.1.log → "MyNode"
320+ assert _node_name_from_filename ("last_result.ARPSCAN. decoded.MyNode.1.log" ) == "MyNode"
316321
317- def test_encoded_filename (self ):
318- # last_result.encoded.MyNode.1.log → "MyNode"
319- assert _node_name_from_filename ("last_result.encoded.MyNode.1.log" ) == "MyNode"
322+ def test_push_encoded_filename (self ):
323+ # PUSH mode (pre-decode): last_result.ARPSCAN .encoded.MyNode.1.log → "MyNode"
324+ assert _node_name_from_filename ("last_result.ARPSCAN. encoded.MyNode.1.log" ) == "MyNode"
320325
321- def test_node_name_with_underscores (self ):
326+ def test_pull_node_name_with_underscores (self ):
322327 assert _node_name_from_filename ("last_result.Wladek_Site.log" ) == "Wladek_Site"
323328
324- def test_decoded_node_name_with_underscores (self ):
325- assert _node_name_from_filename ("last_result.decoded.Wladek_Site.1.log" ) == "Wladek_Site"
329+ def test_push_decoded_node_name_with_underscores (self ):
330+ assert _node_name_from_filename ("last_result.ARPSCAN.decoded.Wladek_Site.1.log" ) == "Wladek_Site"
331+
332+ def test_push_decoded_node_name_with_counter_gt_1 (self ):
333+ # Counter increments when multiple pushes arrive before SYNC runs
334+ assert _node_name_from_filename ("last_result.ARPSCAN.decoded.Node_Vlan01.3.log" ) == "Node_Vlan01"
335+
336+ def test_push_decoded_different_plugins (self ):
337+ for plugin in ("NMAP" , "PIHOLE" , "DHCPLEASES" ):
338+ fname = f"last_result.{ plugin } .decoded.HubNode.1.log"
339+ assert _node_name_from_filename (fname ) == "HubNode" , \
340+ f"Expected 'HubNode' from { fname } "
326341
327342
328343# ===========================================================================
@@ -465,3 +480,60 @@ def test_decoded_files_with_various_node_names_flagged(self):
465480
466481 def test_empty_device_list_returns_zero (self , conn ):
467482 assert sync_insert_devices (conn , [], existing_macs = set ()) == 0
483+
484+
485+ # ===========================================================================
486+ # Mode 3 JSON-skip behaviour
487+ # Regression: local plugin result files (pipe-delimited) must not crash Mode 3.
488+ # ===========================================================================
489+
490+ def _parse_sync_payload (file_path : str ) -> list :
491+ """Mirror of the json.load + data['data'] block in sync.main() Mode 3.
492+
493+ Returns the list of device dicts on success, or raises nothing on invalid
494+ input — callers should catch JSONDecodeError / KeyError and skip the file.
495+ """
496+ import json as _json
497+ with open (file_path , "r" ) as f :
498+ data = _json .load (f )
499+ return data ["data" ]
500+
501+
502+ class TestMode3JsonSkip :
503+ """Regression for the crash when Mode 3 encountered pipe-delimited plugin files.
504+
505+ Before the fix, sync.py called json.load() on every last_result.*.log file
506+ returned by decode_and_rename_files(), including local plugin result files
507+ (e.g. last_result.DIGSCAN.log) which are pipe-delimited and not JSON. The
508+ fix wraps the load in try/except(JSONDecodeError, KeyError) and continues.
509+ """
510+
511+ def test_valid_sync_payload_is_parsed (self , tmp_path ):
512+ import json
513+ payload = {"data" : [{"devMac" : "aa:bb:cc:dd:ee:01" , "devName" : "TestDevice" }]}
514+ f = tmp_path / "last_result.ARPSCAN.decoded.Node1.1.log"
515+ f .write_text (json .dumps (payload ))
516+ result = _parse_sync_payload (str (f ))
517+ assert len (result ) == 1
518+ assert result [0 ]["devMac" ] == "aa:bb:cc:dd:ee:01"
519+
520+ def test_pipe_delimited_file_raises_json_error (self , tmp_path ):
521+ """Pipe-delimited plugin file must raise JSONDecodeError so callers can skip it."""
522+ f = tmp_path / "last_result.DIGSCAN.log"
523+ f .write_text ("aa:bb:cc:dd:ee:01|192.168.1.1|2026-01-01 00:00:00|hostname||subnet||DIGSCAN|||||\n " )
524+ with pytest .raises (json .JSONDecodeError ):
525+ _parse_sync_payload (str (f ))
526+
527+ def test_json_without_data_key_raises_key_error (self , tmp_path ):
528+ """JSON that lacks the 'data' key must raise KeyError so callers can skip it."""
529+ import json
530+ f = tmp_path / "last_result.UNKNOWN.log"
531+ f .write_text (json .dumps ({"result" : []}))
532+ with pytest .raises (KeyError ):
533+ _parse_sync_payload (str (f ))
534+
535+ def test_empty_file_raises_json_error (self , tmp_path ):
536+ f = tmp_path / "last_result.EMPTY.log"
537+ f .write_text ("" )
538+ with pytest .raises (json .JSONDecodeError ):
539+ _parse_sync_payload (str (f ))
0 commit comments