From 0184c166db28526c22dfed084e9d8204195c7b4d Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 12 Jul 2026 03:56:15 -0600 Subject: [PATCH] test: make delete and awareness deflakes deterministic Test-only determinism work with zero production diff. Deflakes: - solstone/apps/transcripts/tests/test_segment_routes.py::test_cancel_delete_segment_within_window_keeps_directory - tests/test_awareness_call_parity.py::test_log_read_fetches_all_entries_past_default_cap_and_page_boundary - tests/test_awareness_routes.py::test_awareness_imports_post_record - tests/test_awareness_routes.py::test_awareness_imports_post_nudge Removes two root causes: the real threading.Timer path from the transcripts delete cluster, and 175 fsync'd append_log calls plus whole-Convey create_app startup from awareness tests. Real timer and TTL semantics remain owned by tests/test_deferred_deletes.py. The faked awareness owner functions are now covered directly by persistence tests in tests/test_awareness.py. --- .../transcripts/tests/test_segment_routes.py | 109 +++++++++++----- tests/_awareness_harness.py | 14 ++ tests/test_awareness.py | 41 ++++++ tests/test_awareness_call_parity.py | 28 ++-- tests/test_awareness_routes.py | 122 ++++++++++-------- 5 files changed, 216 insertions(+), 98 deletions(-) create mode 100644 tests/_awareness_harness.py diff --git a/solstone/apps/transcripts/tests/test_segment_routes.py b/solstone/apps/transcripts/tests/test_segment_routes.py index 32b9f3410..b66c4bfe9 100644 --- a/solstone/apps/transcripts/tests/test_segment_routes.py +++ b/solstone/apps/transcripts/tests/test_segment_routes.py @@ -11,12 +11,14 @@ import shutil import subprocess import sys import time +from collections.abc import Callable from datetime import datetime from pathlib import Path import av import pytest +import solstone.apps.transcripts.routes as routes from solstone.apps.transcripts.routes import ( _attach_streams_to_ranges, _segment_modality_signals, @@ -39,6 +41,39 @@ FIXTURE_STREAM = "default" FIXTURE_SEGMENT = "090000_300" +class _FakeDeferredDeletes: + def __init__(self) -> None: + self.scheduled: list[tuple[str, Callable[[], None], float]] = [] + self._pending: dict[str, tuple[Callable[[], None], float]] = {} + + def schedule_with_id( + self, + pending_id: str, + commit_fn: Callable[[], None], + ttl_seconds: float = 10.0, + ) -> str: + self.scheduled.append((pending_id, commit_fn, ttl_seconds)) + self._pending[pending_id] = (commit_fn, ttl_seconds) + return pending_id + + def cancel(self, pending_id: str) -> bool: + if pending_id not in self._pending: + return False + self._pending.pop(pending_id) + return True + + def fire(self, pending_id: str) -> None: + commit_fn, _ttl_seconds = self._pending.pop(pending_id) + commit_fn() + + +@pytest.fixture +def fake_deferred_deletes(monkeypatch): + fake = _FakeDeferredDeletes() + monkeypatch.setattr(routes, "deferred_deletes", fake) + return fake + + def _assert_reason(response, *, error: str, reason_code: str, detail: str) -> None: payload = response.get_json() assert payload["error"] == error @@ -1853,12 +1888,15 @@ def test_reprocess_segment_isolates_streams(client, journal_copy, monkeypatch): def test_delete_segment_happy_path_removes_segment_directory( - client, journal_copy, monkeypatch + client, journal_copy, monkeypatch, fake_deferred_deletes ): + monkeypatch.setattr(routes, "is_supervisor_up", lambda: True) + emit_calls = [] monkeypatch.setattr( - "solstone.apps.transcripts.routes.is_supervisor_up", lambda: True + routes, + "emit", + lambda tract, event, **payload: emit_calls.append((tract, event, payload)), ) - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) segment_dir = ( journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT ) @@ -1868,15 +1906,18 @@ def test_delete_segment_happy_path_removes_segment_directory( ) assert response.status_code == 200 + pending_id = response.get_json()["pending"] assert response.get_json()["deleted"] == FIXTURE_SEGMENT - time.sleep(0.2) + fake_deferred_deletes.fire(pending_id) assert not segment_dir.exists() + assert emit_calls == [ + ("supervisor", "request", {"cmd": ["journal", "indexer", "--rescan-full"]}) + ] def test_delete_segment_includes_search_index_warning_when_supervisor_is_down( - client, monkeypatch + client, fake_deferred_deletes ): - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" ) @@ -1886,31 +1927,29 @@ def test_delete_segment_includes_search_index_warning_when_supervisor_is_down( assert data["success"] is True assert data["deleted"] == FIXTURE_SEGMENT assert data["search_index_warning"] is True - time.sleep(0.2) + assert fake_deferred_deletes.scheduled[0][0] == data["pending"] def test_delete_segment_omits_search_index_warning_when_supervisor_is_up( - client, monkeypatch + client, monkeypatch, fake_deferred_deletes ): - monkeypatch.setattr( - "solstone.apps.transcripts.routes.is_supervisor_up", lambda: True - ) - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) + monkeypatch.setattr(routes, "is_supervisor_up", lambda: True) response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" ) assert response.status_code == 200 - assert response.get_json()["deleted"] == FIXTURE_SEGMENT - time.sleep(0.2) + data = response.get_json() + assert data["deleted"] == FIXTURE_SEGMENT + assert "search_index_warning" not in data + assert fake_deferred_deletes.scheduled[0][0] == data["pending"] -def test_delete_segment_returns_pending_response_shape(client, monkeypatch): - monkeypatch.setattr( - "solstone.apps.transcripts.routes.is_supervisor_up", lambda: True - ) - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) +def test_delete_segment_returns_pending_response_shape( + client, monkeypatch, fake_deferred_deletes +): + monkeypatch.setattr(routes, "is_supervisor_up", lambda: True) before_ms = int(time.time() * 1000) response = client.delete( @@ -1922,15 +1961,17 @@ def test_delete_segment_returns_pending_response_shape(client, monkeypatch): assert data["success"] is True assert data["deleted"] == FIXTURE_SEGMENT assert re.fullmatch(r"[0-9a-f]{32}", data["pending"]) - assert data["ttl_seconds"] == 0.05 + assert data["ttl_seconds"] == routes.SEGMENT_DELETE_TTL assert data["commit_at_ms"] >= before_ms - time.sleep(0.2) + scheduled_id, commit_fn, ttl_seconds = fake_deferred_deletes.scheduled[0] + assert scheduled_id == data["pending"] + assert callable(commit_fn) + assert ttl_seconds == routes.SEGMENT_DELETE_TTL def test_cancel_delete_segment_within_window_keeps_directory( - client, journal_copy, monkeypatch + client, journal_copy, fake_deferred_deletes ): - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 5.0) segment_dir = ( journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT ) @@ -1944,12 +1985,13 @@ def test_cancel_delete_segment_within_window_keeps_directory( assert cancel_response.status_code == 200 assert cancel_response.get_json() == {"cancelled": pending_id} - time.sleep(0.3) assert segment_dir.exists() + assert fake_deferred_deletes.cancel(pending_id) is False -def test_cancel_delete_segment_too_late_after_commit(client, journal_copy, monkeypatch): - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) +def test_cancel_delete_segment_too_late_after_commit( + client, journal_copy, fake_deferred_deletes +): segment_dir = ( journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT ) @@ -1959,7 +2001,7 @@ def test_cancel_delete_segment_too_late_after_commit(client, journal_copy, monke ) pending_id = delete_response.get_json()["pending"] - time.sleep(0.2) + fake_deferred_deletes.fire(pending_id) cancel_response = client.post(f"/app/transcripts/api/cancel-delete/{pending_id}") assert cancel_response.status_code == 410 @@ -1997,12 +2039,9 @@ def test_cancel_delete_segment_malformed_pending_id_returns_410(client): def test_delete_segment_writes_pending_and_committed_audit_rows( - client, journal_copy, monkeypatch + client, journal_copy, monkeypatch, fake_deferred_deletes ): - monkeypatch.setattr( - "solstone.apps.transcripts.routes.is_supervisor_up", lambda: True - ) - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) + monkeypatch.setattr(routes, "is_supervisor_up", lambda: True) delete_response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" @@ -2017,7 +2056,7 @@ def test_delete_segment_writes_pending_and_committed_audit_rows( for row in day_rows ) - time.sleep(0.2) + fake_deferred_deletes.fire(pending_id) day_rows = _action_log_rows(journal_copy, FIXTURE_DAY) assert any( row["action"] == "segment_delete" @@ -2028,9 +2067,8 @@ def test_delete_segment_writes_pending_and_committed_audit_rows( def test_cancel_delete_segment_writes_cancelled_audit_row( - client, journal_copy, monkeypatch + client, journal_copy, fake_deferred_deletes ): - monkeypatch.setattr("solstone.apps.transcripts.routes.SEGMENT_DELETE_TTL", 5.0) cancel_response = client.delete( f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" ) @@ -2048,3 +2086,4 @@ def test_cancel_delete_segment_writes_cancelled_audit_row( and row["params"].get("phase") == "cancelled" for row in cancel_rows ) + assert fake_deferred_deletes.cancel(cancel_pending_id) is False diff --git a/tests/_awareness_harness.py b/tests/_awareness_harness.py new file mode 100644 index 000000000..e40958361 --- /dev/null +++ b/tests/_awareness_harness.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from flask import Flask +from flask.testing import FlaskClient + +from solstone.apps.awareness.routes import awareness_bp + + +def make_awareness_test_client() -> FlaskClient: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(awareness_bp) + return app.test_client() diff --git a/tests/test_awareness.py b/tests/test_awareness.py index 6dfd5b578..7f4ca6e08 100644 --- a/tests/test_awareness.py +++ b/tests/test_awareness.py @@ -141,6 +141,47 @@ class TestDailyLog: assert entries[0]["detail"] == "meeting detected" +class TestImportTracking: + def test_record_import_persists_state(self): + from solstone.think.awareness import get_imports, record_import + + record_import("chatgpt") + + imports = get_imports() + assert imports["has_imported"] is True + assert imports["import_count"] == 1 + assert imports["sources_used"] == ["chatgpt"] + assert imports["offer_declined"] is None + assert imports["last_nudge"] is None + + def test_record_import_offer_declined_persists_state(self): + from solstone.think.awareness import ( + get_imports, + record_import_offer_declined, + ) + + record_import_offer_declined() + + imports = get_imports() + assert imports["has_imported"] is False + assert imports["import_count"] == 0 + assert imports["sources_used"] == [] + assert re.fullmatch(r"\d{8}T\d{2}:\d{2}:\d{2}", imports["offer_declined"]) + assert imports["last_nudge"] is None + + def test_record_import_nudge_persists_state(self): + from solstone.think.awareness import get_imports, record_import_nudge + + record_import_nudge() + + imports = get_imports() + assert imports["has_imported"] is False + assert imports["import_count"] == 0 + assert imports["sources_used"] == [] + assert imports["offer_declined"] is None + assert re.fullmatch(r"\d{8}T\d{2}:\d{2}:\d{2}", imports["last_nudge"]) + + class TestJournalState: def test_first_daily_ready_via_update_state(self): from solstone.think.awareness import get_current, update_state diff --git a/tests/test_awareness_call_parity.py b/tests/test_awareness_call_parity.py index 149885299..ebfb3d3f1 100644 --- a/tests/test_awareness_call_parity.py +++ b/tests/test_awareness_call_parity.py @@ -13,7 +13,7 @@ from solstone.apps.awareness.call import app from solstone.convey.reasons import AWARENESS_BUSY from solstone.think.convey_client import ConveyClient from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_test_client, mark_setup_complete +from tests._awareness_harness import make_awareness_test_client FROZEN_MS = 1700000000000 FROZEN_ISO = "20260415T12:00:00" @@ -25,13 +25,12 @@ def journal(tmp_path, monkeypatch): # Env must point at the tmp journal so BOTH the seed helpers (append_log/ # update_state) and the in-process route handlers resolve get_journal() to it. monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - mark_setup_complete(tmp_path) return tmp_path @pytest.fixture def runner(journal, monkeypatch): - client = ConveyClient(session=make_test_client(journal), base_url="") + client = ConveyClient(session=make_awareness_test_client(), base_url="") monkeypatch.setattr("solstone.apps.awareness.call.get_client", lambda: client) return CliRunner() @@ -51,6 +50,19 @@ def _write_current(journal, state): (awareness_dir / "current.json").write_text(json.dumps(state), encoding="utf-8") +def _write_awareness_log(journal: Path, day: str, count: int) -> None: + awareness_dir = journal / "awareness" + awareness_dir.mkdir(exist_ok=True) + entries = [ + json.dumps({"ts": FROZEN_MS + i, "kind": "observation", "message": f"m{i}"}) + for i in range(count) + ] + (awareness_dir / f"{day}.jsonl").write_text( + "\n".join(entries) + "\n", + encoding="utf-8", + ) + + def test_status_full_state_json(runner): awareness_mod.update_state("onboarding", {"status": "observing"}) @@ -166,11 +178,11 @@ def test_log_read_empty_journal_human_line(runner): assert result.stdout == "No entries found.\n" -def test_log_read_fetches_all_entries_past_default_cap_and_page_boundary(runner): - for i in range(25): - awareness_mod.append_log("observation", message=f"m{i}", day="20260101") - for i in range(150): - awareness_mod.append_log("observation", message=f"m{i}", day="20260102") +def test_log_read_fetches_all_entries_past_default_cap_and_page_boundary( + runner, journal +): + _write_awareness_log(journal, "20260101", 25) + _write_awareness_log(journal, "20260102", 150) capped = runner.invoke(app, ["log-read", "20260101"]) paged = runner.invoke(app, ["log-read", "20260102"]) diff --git a/tests/test_awareness_routes.py b/tests/test_awareness_routes.py index 580d7a2c5..e4a38f9b1 100644 --- a/tests/test_awareness_routes.py +++ b/tests/test_awareness_routes.py @@ -3,15 +3,24 @@ from __future__ import annotations -import json +from unittest.mock import Mock + +import pytest from solstone.apps import AppRegistry from solstone.think.awareness import append_log +from tests._awareness_harness import make_awareness_test_client from tests._baseline_harness import make_test_client PREFIX = "/app/awareness" +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + return make_awareness_test_client() + + def _assert_error(response, status: int) -> dict: assert response.status_code == status data = response.get_json() @@ -21,6 +30,31 @@ def _assert_error(response, status: int) -> dict: return data +def _fake_import_owners(monkeypatch): + sentinels = { + "record": {"sentinel": "record-return", "payload": ["verbatim-record"]}, + "declined": {"sentinel": "declined-return", "payload": ["verbatim-declined"]}, + "nudge": {"sentinel": "nudge-return", "payload": ["verbatim-nudge"]}, + } + owners = { + "record": Mock(return_value=sentinels["record"]), + "declined": Mock(return_value=sentinels["declined"]), + "nudge": Mock(return_value=sentinels["nudge"]), + } + monkeypatch.setattr( + "solstone.apps.awareness.routes.record_import", owners["record"] + ) + monkeypatch.setattr( + "solstone.apps.awareness.routes.record_import_offer_declined", + owners["declined"], + ) + monkeypatch.setattr( + "solstone.apps.awareness.routes.record_import_nudge", + owners["nudge"], + ) + return owners, sentinels + + def test_awareness_api_only_discovery_registers_blueprint_outside_menu(): registry = AppRegistry() registry.discover() @@ -37,16 +71,7 @@ def test_awareness_index_404(journal_copy): assert response.status_code == 404 -def test_awareness_state_empty_journal_returns_empty_dict(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - config_dir = tmp_path / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps({"setup": {"completed_at": 1}}), - encoding="utf-8", - ) - client = make_test_client(tmp_path) - +def test_awareness_state_empty_journal_returns_empty_dict(client): response = client.get(f"{PREFIX}/api/state") assert response.status_code == 200 @@ -54,7 +79,7 @@ def test_awareness_state_empty_journal_returns_empty_dict(tmp_path, monkeypatch) def test_awareness_state_full_state_includes_journal(journal_copy): - client = make_test_client(journal_copy) + client = make_awareness_test_client() response = client.get(f"{PREFIX}/api/state") @@ -63,7 +88,7 @@ def test_awareness_state_full_state_includes_journal(journal_copy): def test_awareness_state_known_section(journal_copy): - client = make_test_client(journal_copy) + client = make_awareness_test_client() response = client.get(f"{PREFIX}/api/state?section=journal") @@ -71,56 +96,57 @@ def test_awareness_state_known_section(journal_copy): assert response.get_json()["first_daily_ready"] is True -def test_awareness_state_unknown_section(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_state_unknown_section(client): response = client.get(f"{PREFIX}/api/state?section=nope") data = _assert_error(response, 404) assert data["reason_code"] == "awareness_section_not_found" -def test_awareness_imports_get_defaults(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_imports_get_defaults(client): response = client.get(f"{PREFIX}/api/imports") assert response.status_code == 200 assert response.get_json()["has_imported"] is False -def test_awareness_imports_post_record(journal_copy): - client = make_test_client(journal_copy) +def test_awareness_imports_post_record(client, monkeypatch): + owners, sentinels = _fake_import_owners(monkeypatch) response = client.post(f"{PREFIX}/api/imports", json={"record": "chatgpt"}) assert response.status_code == 200 - data = response.get_json() - assert data["has_imported"] is True - assert "chatgpt" in data["sources_used"] + assert response.get_json() == sentinels["record"] + owners["record"].assert_called_once_with("chatgpt") + owners["declined"].assert_not_called() + owners["nudge"].assert_not_called() -def test_awareness_imports_post_declined(journal_copy): - client = make_test_client(journal_copy) +def test_awareness_imports_post_declined(client, monkeypatch): + owners, sentinels = _fake_import_owners(monkeypatch) response = client.post(f"{PREFIX}/api/imports", json={"declined": True}) assert response.status_code == 200 - assert response.get_json()["offer_declined"] is not None + assert response.get_json() == sentinels["declined"] + owners["declined"].assert_called_once_with() + owners["record"].assert_not_called() + owners["nudge"].assert_not_called() -def test_awareness_imports_post_nudge(journal_copy): - client = make_test_client(journal_copy) +def test_awareness_imports_post_nudge(client, monkeypatch): + owners, sentinels = _fake_import_owners(monkeypatch) response = client.post(f"{PREFIX}/api/imports", json={"nudge": True}) assert response.status_code == 200 - assert response.get_json()["last_nudge"] is not None + assert response.get_json() == sentinels["nudge"] + owners["nudge"].assert_called_once_with() + owners["record"].assert_not_called() + owners["declined"].assert_not_called() -def test_awareness_imports_post_multi_action_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_imports_post_multi_action_400(client): response = client.post( f"{PREFIX}/api/imports", json={"record": "x", "nudge": True}, @@ -129,19 +155,16 @@ def test_awareness_imports_post_multi_action_400(journal_copy): _assert_error(response, 400) -def test_awareness_imports_post_zero_action_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_imports_post_zero_action_400(client): response = client.post(f"{PREFIX}/api/imports", json={}) _assert_error(response, 400) -def test_awareness_log_collection_limit_and_kind_filter(journal_copy): +def test_awareness_log_collection_limit_and_kind_filter(client): append_log("observation", message="a") append_log("observation", message="b") append_log("nudge", message="c") - client = make_test_client(journal_copy) response = client.get(f"{PREFIX}/api/log?limit=2") @@ -158,9 +181,8 @@ def test_awareness_log_collection_limit_and_kind_filter(journal_copy): assert all(item["kind"] == "observation" for item in data["items"]) -def test_awareness_log_day_param_uses_requested_day(journal_copy): +def test_awareness_log_day_param_uses_requested_day(client): append_log("observation", message="old", day="20260101") - client = make_test_client(journal_copy) response = client.get(f"{PREFIX}/api/log?day=20260101") @@ -175,9 +197,7 @@ def test_awareness_log_day_param_uses_requested_day(journal_copy): assert all(item.get("message") != "old" for item in response.get_json()["items"]) -def test_awareness_log_post_creates_201(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_log_post_creates_201(client): response = client.post( f"{PREFIX}/api/log", json={"kind": "observation", "message": "hi"}, @@ -189,25 +209,19 @@ def test_awareness_log_post_creates_201(journal_copy): assert "ts" in data -def test_awareness_log_post_missing_kind_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_log_post_missing_kind_400(client): response = client.post(f"{PREFIX}/api/log", json={}) _assert_error(response, 400) -def test_awareness_log_post_empty_kind_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_log_post_empty_kind_400(client): response = client.post(f"{PREFIX}/api/log", json={"kind": ""}) _assert_error(response, 400) -def test_awareness_post_endpoints_no_body_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_post_endpoints_no_body_400(client): imports_response = client.post(f"{PREFIX}/api/imports") log_response = client.post(f"{PREFIX}/api/log") @@ -217,9 +231,7 @@ def test_awareness_post_endpoints_no_body_400(journal_copy): assert log_data["reason_code"] == "missing_request_body" -def test_awareness_post_endpoints_non_json_400(journal_copy): - client = make_test_client(journal_copy) - +def test_awareness_post_endpoints_non_json_400(client): imports_response = client.post( f"{PREFIX}/api/imports", data="not json", -- 2.51.2