agnes-the-ai-analyst/connectors/jira/tests/test_file_lock.py
Petr 86edd27655 Extract Jira into connectors/jira module
Move all Jira-specific code into a self-contained connector module:
- 22 files moved via git mv (transform, service, webhook, scripts,
  systemd units, tests, docs, bin helper)
- All imports updated to use connectors.jira.* paths
- Jira is now conditional: auto-detected via JIRA_DOMAIN env var
- Webapp registers Jira blueprint only when available
- Health service monitors Jira timers only when enabled
- Profiler loads Jira tables dynamically from filesystem
- Sync settings uses config-driven dependency validation
- Renamed keboola_platform_url -> custom_url in transform
- Updated deploy.sh, sudoers-deploy, backfill_gap.sh paths
- Fixed pytest.ini to skip live tests by default
2026-03-09 11:17:50 +01:00

226 lines
8.3 KiB
Python

"""Tests for per-issue advisory file locking (connectors/jira/file_lock.py).
Verifies that issue_json_lock correctly:
- Acquires and releases locks via context manager
- Auto-creates the .locks/ directory
- Provides mutual exclusion for the same issue key (threading)
- Allows concurrent locks on different issue keys
"""
import threading
import time
from pathlib import Path
import pytest
from connectors.jira.file_lock import issue_json_lock
class TestBasicLockUnlock:
"""Context manager acquires and releases the lock cleanly."""
def test_lock_creates_lock_file(self, tmp_path: Path) -> None:
"""Lock file is created when the context manager is entered."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
with issue_json_lock(issues_dir, "SUPPORT-100"):
lock_file = issues_dir / ".locks" / "SUPPORT-100.lock"
assert lock_file.exists(), "Lock file should exist while lock is held"
def test_lock_file_persists_after_release(self, tmp_path: Path) -> None:
"""Lock file remains on disk after context manager exits (only the advisory lock is released)."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
with issue_json_lock(issues_dir, "SUPPORT-200"):
pass
lock_file = issues_dir / ".locks" / "SUPPORT-200.lock"
assert lock_file.exists(), "Lock file should persist after release"
def test_lock_can_be_reacquired(self, tmp_path: Path) -> None:
"""The same issue key can be locked again after the previous lock is released."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
with issue_json_lock(issues_dir, "SUPPORT-300"):
pass
# Should not block - the lock was released
with issue_json_lock(issues_dir, "SUPPORT-300"):
pass
class TestLocksDirectoryCreation:
"""The .locks/ subdirectory is created automatically."""
def test_locks_dir_created_when_missing(self, tmp_path: Path) -> None:
"""If .locks/ does not exist, it is created on first lock acquisition."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
locks_dir = issues_dir / ".locks"
assert not locks_dir.exists(), "Precondition: .locks/ should not exist yet"
with issue_json_lock(issues_dir, "TEST-1"):
assert locks_dir.is_dir(), ".locks/ directory should be created"
def test_locks_dir_already_exists(self, tmp_path: Path) -> None:
"""If .locks/ already exists, no error is raised."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
(issues_dir / ".locks").mkdir()
with issue_json_lock(issues_dir, "TEST-2"):
pass # Should not raise
def test_nested_parent_creation(self, tmp_path: Path) -> None:
"""If issues_dir itself does not exist, parents are created (parents=True)."""
issues_dir = tmp_path / "deep" / "nested" / "issues"
# issues_dir does not exist yet
with issue_json_lock(issues_dir, "TEST-3"):
assert (issues_dir / ".locks").is_dir()
class TestConcurrentLockExclusion:
"""Two threads cannot hold the same issue lock simultaneously."""
def test_mutual_exclusion_same_issue(self, tmp_path: Path) -> None:
"""Prove that two threads holding the same lock do not overlap.
Each thread appends "enter:<name>" and "exit:<name>" to a shared list
with a sleep in between. If locking works, the entries must appear
in non-interleaved pairs: [enter:A, exit:A, enter:B, exit:B] or
[enter:B, exit:B, enter:A, exit:A].
"""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
order: list[str] = []
barrier = threading.Barrier(2)
def worker(name: str) -> None:
barrier.wait() # Ensure both threads start at the same time
with issue_json_lock(issues_dir, "SUPPORT-999"):
order.append(f"enter:{name}")
time.sleep(0.1)
order.append(f"exit:{name}")
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))
t1.start()
t2.start()
t1.join(timeout=5)
t2.join(timeout=5)
assert len(order) == 4, f"Expected 4 events, got {order}"
# Verify non-interleaved ordering:
# Either [enter:A, exit:A, enter:B, exit:B]
# or [enter:B, exit:B, enter:A, exit:A]
first_entrant = order[0].split(":")[1]
second_entrant = order[2].split(":")[1]
assert order[0] == f"enter:{first_entrant}"
assert order[1] == f"exit:{first_entrant}"
assert order[2] == f"enter:{second_entrant}"
assert order[3] == f"exit:{second_entrant}"
assert first_entrant != second_entrant, "Both threads should be different"
def test_counter_integrity_under_contention(self, tmp_path: Path) -> None:
"""Multiple threads incrementing a shared counter must not lose updates.
Without locking, concurrent read-modify-write would cause lost updates.
With locking, the final counter value must equal the number of increments.
"""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
counter_file = tmp_path / "counter.txt"
counter_file.write_text("0")
num_threads = 4
increments_per_thread = 20
barrier = threading.Barrier(num_threads)
def increment_worker() -> None:
barrier.wait()
for _ in range(increments_per_thread):
with issue_json_lock(issues_dir, "COUNTER-ISSUE"):
value = int(counter_file.read_text())
value += 1
counter_file.write_text(str(value))
threads = [
threading.Thread(target=increment_worker)
for _ in range(num_threads)
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=30)
expected = num_threads * increments_per_thread
actual = int(counter_file.read_text())
assert actual == expected, (
f"Counter should be {expected} but got {actual} "
f"(indicates lost updates due to missing mutual exclusion)"
)
class TestDifferentIssuesNotBlocked:
"""Locks on different issue keys do not block each other."""
def test_different_keys_lock_concurrently(self, tmp_path: Path) -> None:
"""Two threads locking different issue keys can hold locks at the same time.
Both threads record the time they enter and exit the critical section.
If different keys are truly independent, their time intervals must overlap.
"""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
timings: dict[str, dict[str, float]] = {}
barrier = threading.Barrier(2)
def worker(issue_key: str) -> None:
barrier.wait()
with issue_json_lock(issues_dir, issue_key):
timings[issue_key] = {"enter": time.monotonic()}
time.sleep(0.15)
timings[issue_key]["exit"] = time.monotonic()
t1 = threading.Thread(target=worker, args=("ALPHA-1",))
t2 = threading.Thread(target=worker, args=("BETA-2",))
t1.start()
t2.start()
t1.join(timeout=5)
t2.join(timeout=5)
assert "ALPHA-1" in timings and "BETA-2" in timings, (
"Both threads should have recorded timings"
)
alpha = timings["ALPHA-1"]
beta = timings["BETA-2"]
# Overlap check: alpha entered before beta exited AND beta entered before alpha exited
overlap = alpha["enter"] < beta["exit"] and beta["enter"] < alpha["exit"]
assert overlap, (
f"Different issue locks should allow concurrent access. "
f"ALPHA-1: {alpha}, BETA-2: {beta}"
)
def test_separate_lock_files_created(self, tmp_path: Path) -> None:
"""Each issue key gets its own lock file."""
issues_dir = tmp_path / "issues"
issues_dir.mkdir()
with issue_json_lock(issues_dir, "FOO-1"):
with issue_json_lock(issues_dir, "BAR-2"):
locks_dir = issues_dir / ".locks"
lock_files = sorted(f.name for f in locks_dir.iterdir())
assert "BAR-2.lock" in lock_files
assert "FOO-1.lock" in lock_files