Skip to content

Commit b9d4d39

Browse files
committed
test(export): add unit tests for load_export_state_from_disk validation
1 parent a4171f4 commit b9d4d39

2 files changed

Lines changed: 125 additions & 2 deletions

File tree

tests/test_export_state_store.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for utils/export_state_store.load_export_state_from_disk validation."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
from utils.export_state_store import load_export_state_from_disk
9+
10+
11+
def test_load_rejects_non_object_json(tmp_path: Path):
12+
p = tmp_path / "export_state.json"
13+
p.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
14+
assert load_export_state_from_disk(str(p)) == {}
15+
16+
17+
def test_load_rejects_null_json(tmp_path: Path):
18+
p = tmp_path / "export_state.json"
19+
p.write_text("null", encoding="utf-8")
20+
assert load_export_state_from_disk(str(p)) == {}
21+
22+
23+
def test_load_sanitizes_non_dict_sessions(tmp_path: Path):
24+
p = tmp_path / "export_state.json"
25+
p.write_text(
26+
json.dumps(
27+
{
28+
"lastExportTime": "2026-01-01T00:00:00",
29+
"exportedCount": 1,
30+
"sessions": [],
31+
}
32+
),
33+
encoding="utf-8",
34+
)
35+
out = load_export_state_from_disk(str(p))
36+
assert out["sessions"] == {}
37+
assert out["lastExportTime"] == "2026-01-01T00:00:00"
38+
assert out["exportedCount"] == 1
39+
40+
41+
def test_load_adds_sessions_when_missing_but_has_last_export(tmp_path: Path):
42+
p = tmp_path / "export_state.json"
43+
p.write_text(
44+
json.dumps({"lastExportTime": "2026-01-01T00:00:00", "exportedCount": 0}),
45+
encoding="utf-8",
46+
)
47+
out = load_export_state_from_disk(str(p))
48+
assert out["sessions"] == {}
49+
assert out["lastExportTime"] == "2026-01-01T00:00:00"
50+
51+
52+
def test_load_legacy_flat_dict_unchanged_shape(tmp_path: Path):
53+
p = tmp_path / "export_state.json"
54+
legacy = {"uuid-one": 1740000000.0}
55+
p.write_text(json.dumps(legacy), encoding="utf-8")
56+
out = load_export_state_from_disk(str(p))
57+
assert out == {"sessions": legacy}
58+
59+
60+
def test_export_state_lock_windows_branch_uses_msvcrt_when_no_fcntl(
61+
monkeypatch, tmp_path: Path
62+
):
63+
"""When ``fcntl`` is absent, use ``msvcrt.locking`` (cross-process on Windows)."""
64+
import utils.export_state_store as mod
65+
66+
monkeypatch.setattr(mod, "fcntl", None)
67+
calls: list[tuple[int, int]] = []
68+
69+
class FakeMsvcrt:
70+
LK_LOCK = 1
71+
LK_UNLCK = 2
72+
73+
@staticmethod
74+
def locking(fd, mode, nbytes):
75+
calls.append((mode, nbytes))
76+
77+
monkeypatch.setattr(mod, "msvcrt", FakeMsvcrt)
78+
79+
state_file = tmp_path / "export_state.json"
80+
state_file.write_text("{}", encoding="utf-8")
81+
with mod.export_state_lock(str(state_file)):
82+
assert (FakeMsvcrt.LK_LOCK, 1) in calls
83+
assert calls[-1] == (FakeMsvcrt.LK_UNLCK, 1)

utils/export_state_store.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@
1111
try:
1212
import fcntl
1313
except ImportError:
14-
fcntl = None # Windows: fall back to threading lock (same process only)
14+
fcntl = None
1515

16+
try:
17+
import msvcrt
18+
except ImportError:
19+
msvcrt = None
20+
21+
# Only when neither fcntl nor msvcrt exists (very rare): same-process only.
1622
_fallback_locks: dict[str, threading.Lock] = {}
1723
_fallback_locks_guard = threading.Lock()
1824

@@ -30,7 +36,12 @@ def _fallback_lock_for(path: str) -> threading.Lock:
3036

3137
@contextmanager
3238
def export_state_lock(state_path: str | None = None):
33-
"""Serialize export_state.json reads/writes (POSIX: flock; else threading)."""
39+
"""Serialize export_state.json reads/writes across processes.
40+
41+
POSIX: ``flock`` on a sidecar ``*.lock`` file. Windows: ``msvcrt.locking`` on
42+
the same sidecar (byte-range lock). If neither is available, falls back to
43+
a per-path ``threading.Lock`` (same process only).
44+
"""
3445
path = EXPORT_STATE_FILE if state_path is None else state_path
3546
if fcntl is not None:
3647
lock_path = path + ".lock"
@@ -44,6 +55,28 @@ def export_state_lock(state_path: str | None = None):
4455
finally:
4556
fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
4657
lock_fp.close()
58+
elif msvcrt is not None:
59+
lock_path = path + ".lock"
60+
dir_name = os.path.dirname(lock_path)
61+
if dir_name:
62+
os.makedirs(dir_name, exist_ok=True)
63+
if not os.path.exists(lock_path):
64+
with open(lock_path, "wb") as f:
65+
f.write(b"\x00")
66+
lock_fp = open(lock_path, "r+b")
67+
try:
68+
if os.path.getsize(lock_path) == 0:
69+
lock_fp.write(b"\x00")
70+
lock_fp.flush()
71+
lock_fp.seek(0)
72+
msvcrt.locking(lock_fp.fileno(), msvcrt.LK_LOCK, 1)
73+
try:
74+
yield
75+
finally:
76+
lock_fp.seek(0)
77+
msvcrt.locking(lock_fp.fileno(), msvcrt.LK_UNLCK, 1)
78+
finally:
79+
lock_fp.close()
4780
else:
4881
with _fallback_lock_for(path):
4982
yield
@@ -53,6 +86,8 @@ def load_export_state_from_disk(state_path: str | None = None) -> dict:
5386
"""Load state from disk (call under :func:`export_state_lock` for consistency).
5487
5588
Migrates legacy flat ``{session_id: mtime, ...}`` to ``{"sessions": ...}``.
89+
Returns a dict with a mapping ``sessions``; malformed top-level values or
90+
a non-dict ``sessions`` entry are sanitized so callers always see a dict.
5691
"""
5792
path = EXPORT_STATE_FILE if state_path is None else state_path
5893
if not os.path.isfile(path):
@@ -62,8 +97,13 @@ def load_export_state_from_disk(state_path: str | None = None) -> dict:
6297
data = json.load(f)
6398
except Exception:
6499
return {}
100+
if not isinstance(data, dict):
101+
return {}
65102
if "sessions" not in data and "lastExportTime" not in data:
66103
return {"sessions": data}
104+
if not isinstance(data.get("sessions"), dict):
105+
data = dict(data)
106+
data["sessions"] = {}
67107
return data
68108

69109

0 commit comments

Comments
 (0)