"""Tests for src/ralph_flow.py — short-lived per-user UX state.""" from datetime import datetime, timedelta, timezone import pytest from src import ralph_flow @pytest.fixture(autouse=True) def _isolate_state_file(tmp_path, monkeypatch): """Redirect state file to a tmp location for each test.""" monkeypatch.setattr(ralph_flow, "_STATE_FILE", tmp_path / "ralph_flow.json") monkeypatch.setattr(ralph_flow, "SESSIONS_DIR", tmp_path) def test_get_state_returns_none_when_absent(): assert ralph_flow.get_state("discord", "c1", "u1") is None def test_set_then_get_round_trip(): ralph_flow.set_state( "discord", "c1", "u1", step=ralph_flow.STEP_INPUT_DESCRIPTION, project="roa2web", ) state = ralph_flow.get_state("discord", "c1", "u1") assert state is not None assert state["step"] == ralph_flow.STEP_INPUT_DESCRIPTION assert state["project"] == "roa2web" assert "expires_at" in state def test_clear_state_removes_entry(): ralph_flow.set_state("telegram", "42", "7", step="input_description") assert ralph_flow.clear_state("telegram", "42", "7") is True assert ralph_flow.get_state("telegram", "42", "7") is None # Second clear is a no-op assert ralph_flow.clear_state("telegram", "42", "7") is False def test_state_keyed_by_adapter_chat_user(): """Different adapters / chats / users have isolated state.""" ralph_flow.set_state("discord", "c1", "u1", step="input_description", project="A") ralph_flow.set_state("telegram", "c1", "u1", step="input_description", project="B") ralph_flow.set_state("discord", "c2", "u1", step="input_description", project="C") ralph_flow.set_state("discord", "c1", "u2", step="input_description", project="D") assert ralph_flow.get_state("discord", "c1", "u1")["project"] == "A" assert ralph_flow.get_state("telegram", "c1", "u1")["project"] == "B" assert ralph_flow.get_state("discord", "c2", "u1")["project"] == "C" assert ralph_flow.get_state("discord", "c1", "u2")["project"] == "D" def test_expired_state_returns_none_and_self_cleans(monkeypatch): """get_state on an expired entry should return None and drop the entry.""" # Set with 0s TTL — already expired ralph_flow.set_state( "discord", "c1", "u1", step="input_description", ttl_seconds=0, ) assert ralph_flow.get_state("discord", "c1", "u1") is None # Verify entry was dropped from disk assert ralph_flow._load() == {} def test_cleanup_expired_drops_only_expired(): ralph_flow.set_state("discord", "c1", "u1", step="x", ttl_seconds=0) # expired ralph_flow.set_state("discord", "c2", "u2", step="y", ttl_seconds=600) # fresh dropped = ralph_flow.cleanup_expired() assert dropped == 1 assert ralph_flow.get_state("discord", "c1", "u1") is None assert ralph_flow.get_state("discord", "c2", "u2") is not None def test_set_state_overwrites_previous(): ralph_flow.set_state("discord", "c1", "u1", step="step_a", project="P1") ralph_flow.set_state("discord", "c1", "u1", step="step_b", project="P2") state = ralph_flow.get_state("discord", "c1", "u1") assert state["step"] == "step_b" assert state["project"] == "P2" def test_set_state_extras_propagate(): ralph_flow.set_state( "discord", "c1", "u1", step="x", custom_field="hello", nested={"a": 1}, ) state = ralph_flow.get_state("discord", "c1", "u1") assert state["custom_field"] == "hello" assert state["nested"] == {"a": 1} def test_corrupted_state_file_returns_empty(tmp_path): """If state file is corrupt JSON, _load returns {} so get_state stays robust.""" ralph_flow._STATE_FILE.write_text("not json {") assert ralph_flow.get_state("discord", "c1", "u1") is None def test_atomic_write_does_not_leave_temp_files(tmp_path): ralph_flow.set_state("discord", "c1", "u1", step="x") leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".ralph_flow_")] assert leftovers == []