|
4 | 4 | """ |
5 | 5 | import logging |
6 | 6 | import os |
| 7 | +import threading |
7 | 8 | import time |
8 | 9 | from queue import Empty, Queue |
9 | | -from typing import Any, Dict, List, Optional, Tuple |
| 10 | +from typing import Any, Dict, List, Optional, Set, Tuple |
10 | 11 |
|
11 | 12 | import pytest |
12 | 13 |
|
@@ -164,6 +165,18 @@ def test_builder_clamps_poll_interval_to_the_minimum(caplog): |
164 | 165 | assert source._poll_interval == 2.5 |
165 | 166 |
|
166 | 167 |
|
| 168 | +@pytest.mark.parametrize('seconds', ['1', None, True], ids=['string', 'none', 'bool']) |
| 169 | +def test_builder_rejects_a_poll_interval_that_is_not_a_number(seconds): |
| 170 | + with pytest.raises(TypeError): |
| 171 | + FileOverrideSourceBuilder(['a']).poll_interval(seconds) |
| 172 | + |
| 173 | + |
| 174 | +@pytest.mark.parametrize('seconds', [float('nan'), float('inf'), float('-inf')], ids=['nan', 'inf', '-inf']) |
| 175 | +def test_builder_rejects_a_poll_interval_that_is_not_finite(seconds): |
| 176 | + with pytest.raises(ValueError): |
| 177 | + FileOverrideSourceBuilder(['a']).poll_interval(seconds) |
| 178 | + |
| 179 | + |
167 | 180 | def test_builder_rejects_watching_without_watchdog(monkeypatch): |
168 | 181 | import ldclient.integrations.overrides as module |
169 | 182 | monkeypatch.setattr(module, 'have_watchdog', False) |
@@ -369,6 +382,56 @@ def test_source_retries_a_failed_load_without_a_change_signal(tmp_path, sources, |
369 | 382 | assert flag_values(sink.require_snapshot()) == {'flag1': False} |
370 | 383 |
|
371 | 384 |
|
| 385 | +@pytest.mark.parametrize("mode", [ChangeDetection.POLLING, pytest.param(ChangeDetection.WATCHING, marks=watchdog_required)]) |
| 386 | +def test_source_detects_an_edit_made_during_the_initial_load(tmp_path, sources, monkeypatch, mode): |
| 387 | + path = os.path.join(str(tmp_path), 'overrides.json') |
| 388 | + write_file(path, '{"flagValues": {"flag1": "initial"}}') |
| 389 | + original_reload_now = filedata.Reloader.reload_now |
| 390 | + |
| 391 | + def load_then_edit(reloader: filedata.Reloader) -> None: |
| 392 | + # The edit lands after the files were read and before start returns. |
| 393 | + original_reload_now(reloader) |
| 394 | + write_file(path, '{"flagValues": {"flag1": "edited-after-the-first-read"}}') |
| 395 | + |
| 396 | + monkeypatch.setattr(filedata.Reloader, 'reload_now', load_then_edit) |
| 397 | + _, sink = build_source(sources, [path], lambda b: b.change_detection(mode).poll_interval(FileOverrideSourceBuilder.MINIMUM_POLL_INTERVAL)) |
| 398 | + |
| 399 | + # The initial snapshot holds what the first read saw. The edit is detected and applied |
| 400 | + # without any further change to the file. |
| 401 | + assert flag_values(sink.require_snapshot()) == {'flag1': 'initial'} |
| 402 | + assert flag_values(sink.require_snapshot()) == {'flag1': 'edited-after-the-first-read'} |
| 403 | + |
| 404 | + |
| 405 | +def reloader_threads() -> Set[threading.Thread]: |
| 406 | + return {t for t in threading.enumerate() if t.name == 'ldclient.filedata.reloader'} |
| 407 | + |
| 408 | + |
| 409 | +def test_source_close_stops_the_reloader_when_the_change_detector_fails_to_close(tmp_path, sources, monkeypatch): |
| 410 | + path = os.path.join(str(tmp_path), 'overrides.json') |
| 411 | + write_file(path, '{}') |
| 412 | + before = reloader_threads() |
| 413 | + source, sink = build_source(sources, [path]) |
| 414 | + sink.require_snapshot() |
| 415 | + started = reloader_threads() - before |
| 416 | + assert len(started) == 1 |
| 417 | + |
| 418 | + detector: Any = source._change_detector |
| 419 | + original_close = detector.close |
| 420 | + |
| 421 | + def failing_close() -> None: |
| 422 | + original_close() |
| 423 | + raise RuntimeError("detector close failure") |
| 424 | + |
| 425 | + monkeypatch.setattr(detector, 'close', failing_close) |
| 426 | + |
| 427 | + # The failure propagates, and the reloader is stopped all the same: its worker ends. |
| 428 | + with pytest.raises(RuntimeError): |
| 429 | + source.close() |
| 430 | + worker = started.pop() |
| 431 | + worker.join(TEST_TIMEOUT) |
| 432 | + assert not worker.is_alive() |
| 433 | + |
| 434 | + |
372 | 435 | def test_source_does_not_supply_snapshots_after_close(tmp_path, sources): |
373 | 436 | path = os.path.join(str(tmp_path), 'overrides.json') |
374 | 437 | write_file(path, '{"flagValues": {"flag1": true}}') |
|
0 commit comments