"""Focused HUX-09 server-authoritative suggestion tests.""" from __future__ import annotations import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] FOUNDATION = ROOT / "dockerfiles" / "hermes-hux-foundation" if str(FOUNDATION) not in sys.path: sys.path.insert(0, str(FOUNDATION)) from hux import contracts, identity, privacy, store, suggestions from hux.errors import Invalid from hux.server import build_router HEADERS = {"X-Hermes-Tenant-Identity": "slot-3", "X-Hux-Subject": "usr_0123456789abcdef", "X-Hux-Surface": "chat", "X-Hux-Relay-Key": "rk"} OTHER = {**HEADERS, "X-Hux-Subject": "usr_fedcba9876543210"} ALL_ON = ",".join(card["flag"] for card in contracts.load_flags()["cards"]) START = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc) def call(router, method, path, body=None, headers=None): raw = b"" if body is None else json.dumps(body).encode() response = router.dispatch(method, path, {**HEADERS, **(headers or {})}, raw) return response.status, response.body, response.headers def tenant(router): return store.TenantStore(router.data_root, identity.resolve(HEADERS, {"HUX_ROUTER_KEY": "rk"})) @pytest.fixture def setup(tmp_path, monkeypatch): moment = {"value": START} monkeypatch.setattr(suggestions, "clock", lambda: moment["value"]) monkeypatch.setattr(suggestions, "now_iso", lambda: moment["value"].strftime("%Y-%m-%dT%H:%M:%SZ")) router = build_router(tmp_path, {"HUX_FLAGS": ALL_ON, "HUX_ROUTER_KEY": "rk"}) _, project, _ = call(router, "POST", "/hux/v1/projects", {"name": "P"}) _, conversation, _ = call(router, "POST", "/hux/v1/conversations", {"title": "C", "project_id": project["id"]}) base = f"/hux/v1/projects/{project['id']}/conversations/{conversation['id']}/suggestions" return router, project, conversation, base, moment def evaluate(setup, context="first_session", key="suggest-key-001", **extra): router, _, _, base, _ = setup return call(router, "POST", base + "/evaluate", {"context": context, **extra}, {"Idempotency-Key": key}) def test_every_context_returns_a_canonical_server_definition(setup): for index, context in enumerate(suggestions.CONTEXTS): status, result, headers = evaluate(setup, context, f"context-key-{index:02}") assert status == 200 and result["stored"] is True and headers["Cache-Control"] == "no-store" suggestion = result["suggestion"] assert suggestion["trigger"] == {"surface": "chat", "context": context} assert suggestion["suppression"] == {"dismissable": True, "max_shows": 3, "cooldown_seconds": 86400, "never_again_supported": True} assert contracts.validate("suggestion.schema.json", suggestion, pointer="/$defs/suggestion") == [] assert contracts.validate("suggestion.schema.json", result["state"], pointer="/$defs/state") == [] def test_idempotency_cooldown_and_max_show_are_authoritative(setup): _, _, _, _, moment = setup status, first, _ = evaluate(setup) assert status == 200 and first["state"]["shows"] == 1 and first["revision"] == 1 status, replay, headers = evaluate(setup) assert status == 200 and replay == first and headers["HUX-Replayed"] == "true" assert evaluate(setup, context="idle")[0] == 409 status, cooled, _ = evaluate(setup, key="suggest-key-002") assert status == 200 and cooled == {"suggestion": None, "reason": "cooldown", "stored": False} moment["value"] += timedelta(days=1, seconds=1) second = evaluate(setup, key="suggest-key-003")[1] assert second["state"]["shows"] == 2 and second["revision"] == 2 moment["value"] += timedelta(days=1, seconds=1) third = evaluate(setup, key="suggest-key-004")[1] assert third["state"]["shows"] == 3 moment["value"] += timedelta(days=1, seconds=1) exhausted = evaluate(setup, key="suggest-key-005")[1] assert exhausted == {"suggestion": None, "reason": "max_shows", "stored": False} def test_decisions_require_click_if_match_and_idempotency(setup): _, _, _, base, moment = setup shown = evaluate(setup)[1] suggestion_id = shown["suggestion"]["id"] path = base + f"/{suggestion_id}/decisions" headers = {"If-Match": "1", "Idempotency-Key": "decision-key-001"} status, result, response_headers = call(setup[0], "POST", path, {"decision": "dismissed", "clicked": True}, headers) assert status == 200 and result["decision"] == "dismissed" and result["revision"] == 2 assert response_headers["ETag"] == "2" and "dismissed_at" in result["state"] status, replay, replay_headers = call(setup[0], "POST", path, {"decision": "dismissed", "clicked": True}, headers) assert status == 200 and replay == result and replay_headers["HUX-Replayed"] == "true" assert call(setup[0], "POST", path, {"decision": "acted", "clicked": True}, headers)[0] == 409 moment["value"] += timedelta(days=1, seconds=1) shown_again = evaluate(setup, key="suggest-key-after-dismiss")[1] assert shown_again["suggestion"]["id"] == suggestion_id and shown_again["state"]["shows"] == 2 invalid = [ ({"decision": "acted", "clicked": False}, {"If-Match": "3", "Idempotency-Key": "decision-key-002"}), ({"decision": "silent", "clicked": True}, {"If-Match": "3", "Idempotency-Key": "decision-key-003"}), ({"decision": "acted", "clicked": True}, {"Idempotency-Key": "decision-key-004"}), ({"decision": "acted", "clicked": True}, {"If-Match": "3"}), ] for body, bad_headers in invalid: assert call(setup[0], "POST", path, body, bad_headers)[0] == 400 def test_never_again_is_explicit_and_permanent(setup): _, _, _, base, moment = setup shown = evaluate(setup)[1] path = base + f"/{shown['suggestion']['id']}/decisions" status, result, _ = call(setup[0], "POST", path, {"decision": "never_again", "clicked": True}, {"If-Match": "1", "Idempotency-Key": "never-key-0001"}) assert status == 200 and result["state"]["never_again"] is True moment["value"] += timedelta(days=365) blocked = evaluate(setup, key="suggest-key-never")[1] assert blocked == {"suggestion": None, "reason": "never_again", "stored": False} @pytest.mark.parametrize(("gate", "reason"), [ ("no_store", "client_no_store"), ("sensitive", "client_no_store"), ("restricted", "client_no_store"), ]) def test_client_privacy_gates_do_not_write_state(setup, gate, reason): body = {"no_store": True} if gate == "no_store" else {"sensitivity": gate} status, result, _ = evaluate(setup, key=f"privacy-{gate}-1", **body) assert status == 200 and result == {"suggestion": None, "reason": reason, "stored": False} assert tenant(setup[0]).count(suggestions.FAMILY) == 0 assert tenant(setup[0]).read(suggestions.FAMILY, "idempotency") == [] def test_server_privacy_state_overrides_client_claims(setup): router, _, conversation, base, _ = setup privacy.mark_topic(tenant(router), conversation["id"], "health") result = evaluate(setup, key="privacy-topic-01", sensitivity="public")[1] assert result["reason"] == "sensitive_topic" and tenant(router).count(suggestions.FAMILY) == 0 privacy.set_flag(tenant(router), conversation["id"], "memory_disabled", True) assert evaluate(setup, key="privacy-disabled", sensitivity="public")[1]["reason"] == "conversation_no_store" privacy.set_flag(tenant(router), conversation["id"], "forgotten", True) assert evaluate(setup, key="privacy-forgotten", sensitivity="public")[1]["reason"] == "conversation_no_store" privacy.set_flag(tenant(router), conversation["id"], "forgotten", False) privacy.set_flag(tenant(router), conversation["id"], "memory_disabled", False) doc = tenant(router).get("privacy", privacy.TOPICS_DOC) doc["items"][conversation["id"]]["topics"] = [] tenant(router).put("privacy", doc) current = tenant(router).get("conversations", conversation["id"]) tenant(router).put("conversations", {**current, "mode": "private"}, current["revision"]) result = call(router, "POST", base + "/evaluate", {"context": "idle", "sensitivity": "public"}, {"Idempotency-Key": "privacy-private1"})[1] assert result["reason"] == "private_mode" def test_list_state_scope_and_limits(setup, monkeypatch): router, project, _, base, _ = setup shown = evaluate(setup)[1] status, states, headers = call(router, "GET", base + "/states") assert status == 200 and states["items"][0]["suggestion_id"] == shown["suggestion"]["id"] assert states["items"][0]["revision"] == 1 and headers["Cache-Control"] == "no-store" assert call(router, "GET", base + "/states", headers=OTHER)[0] == 404 wrong = base.replace(project["id"], "prj_missing0000") assert call(router, "GET", wrong + "/states")[0] == 404 _, conversation2, _ = call(router, "POST", "/hux/v1/conversations", {"title": "C2", "project_id": project["id"]}) base2 = f"/hux/v1/projects/{project['id']}/conversations/{conversation2['id']}/suggestions" monkeypatch.setattr(suggestions, "MAX_STATES", 0) result = call(router, "POST", base2 + "/evaluate", {"context": "idle"}, {"Idempotency-Key": "state-limit-key"})[1] assert result == {"suggestion": None, "reason": "state_limit", "stored": False} def test_bad_context_body_and_unknown_decision_fail_closed(setup): router, _, _, base, _ = setup for body in ([], {"context": "nope"}, {"context": "idle", "priority": 100}): raw = json.dumps(body).encode() assert router.dispatch("POST", base + "/evaluate", {**HEADERS, "Idempotency-Key": "bad-context-key"}, raw).status == 400 assert call(router, "POST", base + "/evaluate", {"context": "idle"})[0] == 400 missing = base + "/sug_missing0000/decisions" assert call(router, "POST", missing, {"decision": "acted", "clicked": True}, {"If-Match": "1", "Idempotency-Key": "missing-decision"})[0] == 404 def test_defensive_validation_stale_act_and_private_inspection(setup): router, _, conversation, base, _ = setup with pytest.raises(Invalid, match="suggestion failed"): suggestions._suggestion("idle", "invalid-surface") with pytest.raises(Invalid, match="state failed"): suggestions._public({"schema": "hux.suggestion_state.v1", "owner": "raw-user", "suggestion_id": "sug_idle_workflow", "shows": 1, "never_again": False}) shown = evaluate(setup)[1] path = base + f"/{shown['suggestion']['id']}/decisions" assert call(router, "POST", path, {"decision": "acted", "clicked": True}, {"If-Match": "9", "Idempotency-Key": "stale-decision1"})[0] == 409 status, acted, _ = call(router, "POST", path, {"decision": "acted", "clicked": True}, {"If-Match": "1", "Idempotency-Key": "acted-decision1"}) assert status == 200 and "acted_at" in acted["state"] and "dismissed_at" not in acted["state"] current = tenant(router).get("conversations", conversation["id"]) tenant(router).put("conversations", {**current, "mode": "private"}, current["revision"]) assert call(router, "POST", path, {"decision": "dismissed", "clicked": True}, {"If-Match": "2", "Idempotency-Key": "private-decision"})[0] == 404 status, states, _ = call(router, "GET", base + "/states") assert status == 200 and states == {"items": [], "next": None} def test_source_and_test_files_stay_bounded(): assert len((FOUNDATION / "hux" / "suggestions.py").read_text().splitlines()) <= 500 assert len(Path(__file__).read_text().splitlines()) <= 500