"""
Tests for autopil.api (FastAPI endpoints)

Coverage:
  - GET  /health                         — liveness check
  - POST /v1/context/evaluate            — allow and deny paths, audit persistence
  - GET  /v1/audit/sessions/{id}         — trail for known and unknown session
  - GET  /v1/audit/events                — all events, with decision and role filters
  - GET  /v1/audit/export                — CSV and JSON export with filters
  - GET  /v1/audit/stats                 — summary counts
  - POST /v1/audit/lineage               — record agent action against an audit event
  - GET  /v1/audit/lineage/{event_id}    — retrieve event + linked actions
  - GET  /v1/sessions                    — list active sessions
  - GET  /v1/sessions/{id}              — session status
  - DELETE /v1/sessions/{id}           — revoke a session
  - GET  /v1/policies                    — lists loaded policies
  - POST /v1/policies/reload             — hot-reload response
"""

import csv
import io
import json
import uuid

import pytest

from autopil.api.app import create_app
from autopil.models import Decision


def _eval_payload(**overrides):
    base = {
        "query": "test query",
        "agent_role": "analyst",
        "user_id": "u1",
        "source_id": "reports",
        "sensitivity_level": "low",
        "session_id": str(uuid.uuid4()),
    }
    base.update(overrides)
    return base


# ── health ────────────────────────────────────────────────────────────────────

class TestHealth:
    def test_health_returns_ok(self, client):
        r = client.get("/health")
        assert r.status_code == 200
        body = r.json()
        assert body["status"] == "ok"
        assert body["version"]

    def test_health_includes_paths(self, client, policy_file, audit_db):
        r = client.get("/health")
        body = r.json()
        assert body["policy_file"] == policy_file
        assert body["audit_db"]    == audit_db


# ── evaluate ─────────────────────────────────────────────────────────────────

class TestEvaluate:
    def test_allowed_request_returns_allow(self, client):
        r = client.post("/v1/context/evaluate", json=_eval_payload())
        assert r.status_code == 200
        body = r.json()
        assert body["decision"]     == Decision.ALLOW
        assert body["policy_name"]  == "analyst_policy"
        assert "event_id" in body
        assert "session_id" in body

    def test_denied_request_returns_deny(self, client):
        r = client.post(
            "/v1/context/evaluate",
            json=_eval_payload(source_id="executive_comms"),
        )
        assert r.status_code == 200
        body = r.json()
        assert body["decision"] == Decision.DENY

    def test_unknown_role_returns_deny(self, client):
        r = client.post(
            "/v1/context/evaluate",
            json=_eval_payload(agent_role="unknown_role"),
        )
        assert r.status_code == 200
        assert r.json()["decision"] == Decision.DENY

    def test_evaluate_persists_event_to_audit_log(self, client):
        sid = str(uuid.uuid4())
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))

        r = client.get(f"/v1/audit/sessions/{sid}")
        assert r.status_code == 200
        assert r.json()["total_events"] == 1

    def test_sensitivity_above_ceiling_is_denied(self, client):
        r = client.post(
            "/v1/context/evaluate",
            json=_eval_payload(agent_role="viewer", source_id="reports",
                               sensitivity_level="restricted"),
        )
        assert r.json()["decision"] == Decision.DENY


# ── audit sessions ────────────────────────────────────────────────────────────

class TestAuditSessions:
    def test_known_session_returns_trail(self, client):
        sid = str(uuid.uuid4())
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))

        r = client.get(f"/v1/audit/sessions/{sid}")
        assert r.status_code == 200
        body = r.json()
        assert body["total_events"] == 2
        assert body["allowed"]      == 2
        assert body["denied"]       == 0

    def test_mixed_trail_counts_correctly(self, client):
        sid = str(uuid.uuid4())
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid, source_id="reports"))
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid, source_id="executive_comms"))

        body = client.get(f"/v1/audit/sessions/{sid}").json()
        assert body["allowed"] == 1
        assert body["denied"]  == 1

    def test_unknown_session_returns_404(self, client):
        r = client.get(f"/v1/audit/sessions/{uuid.uuid4()}")
        assert r.status_code == 404

    def test_trail_events_contain_expected_fields(self, client):
        sid = str(uuid.uuid4())
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))

        event = client.get(f"/v1/audit/sessions/{sid}").json()["events"][0]
        for field in ("event_id", "session_id", "agent_role", "user_id",
                      "source_id", "query", "decision", "policy_name", "reason", "timestamp"):
            assert field in event


# ── audit events ──────────────────────────────────────────────────────────────

class TestAuditEvents:
    def test_returns_all_events(self, client):
        for _ in range(3):
            client.post("/v1/context/evaluate", json=_eval_payload())
        r = client.get("/v1/audit/events")
        assert r.status_code == 200
        assert len(r.json()) == 3

    def test_filter_by_decision_allow(self, client):
        client.post("/v1/context/evaluate", json=_eval_payload(source_id="reports"))
        client.post("/v1/context/evaluate", json=_eval_payload(source_id="executive_comms"))

        r = client.get("/v1/audit/events?decision=ALLOW")
        assert all(e["decision"] == "ALLOW" for e in r.json())

    def test_filter_by_decision_deny(self, client):
        client.post("/v1/context/evaluate", json=_eval_payload(source_id="reports"))
        client.post("/v1/context/evaluate", json=_eval_payload(source_id="executive_comms"))

        r = client.get("/v1/audit/events?decision=DENY")
        assert all(e["decision"] == "DENY" for e in r.json())

    def test_filter_by_agent_role(self, client):
        client.post("/v1/context/evaluate", json=_eval_payload(agent_role="analyst"))
        client.post("/v1/context/evaluate", json=_eval_payload(agent_role="viewer"))

        r = client.get("/v1/audit/events?agent_role=viewer")
        events = r.json()
        assert len(events) == 1
        assert events[0]["agent_role"] == "viewer"

    def test_empty_db_returns_empty_list(self, client):
        r = client.get("/v1/audit/events")
        assert r.json() == []


# ── task-level scoping (evaluate endpoint) ────────────────────────────────────

class TestTaskScoping:
    def _payload(self, task_type=None, **kwargs):
        p = _eval_payload(agent_role="analyst", source_id="reports", **kwargs)
        if task_type is not None:
            p["task_type"] = task_type
        return p

    def test_no_task_type_still_allows(self, client):
        r = client.post("/v1/context/evaluate", json=self._payload())
        assert r.json()["decision"] == "ALLOW"

    def test_task_type_allowed_when_no_policy_constraint(self, client):
        r = client.post("/v1/context/evaluate", json=self._payload(task_type="any_task"))
        assert r.json()["decision"] == "ALLOW"

    def test_task_type_in_response_not_required(self, client):
        r = client.post("/v1/context/evaluate", json=self._payload(task_type="initial_review"))
        assert r.status_code == 200
        assert "decision" in r.json()

    def test_policy_with_allowed_tasks_permits_matching(self, client):
        client.post("/v1/policies", json={
            "name": "task_scoped_policy",
            "agent_role": "task_agent",
            "allowed_sources": ["reports"],
            "denied_sources": [],
            "max_sensitivity": "high",
            "allowed_tasks": ["initial_underwriting"],
            "denied_tasks": [],
        })
        r = client.post("/v1/context/evaluate", json=_eval_payload(
            agent_role="task_agent", source_id="reports", task_type="initial_underwriting"
        ))
        assert r.json()["decision"] == "ALLOW"

    def test_policy_with_allowed_tasks_denies_unlisted(self, client):
        client.post("/v1/policies", json={
            "name": "task_scoped_policy2",
            "agent_role": "task_agent2",
            "allowed_sources": ["reports"],
            "denied_sources": [],
            "max_sensitivity": "high",
            "allowed_tasks": ["initial_underwriting"],
            "denied_tasks": [],
        })
        r = client.post("/v1/context/evaluate", json=_eval_payload(
            agent_role="task_agent2", source_id="reports", task_type="customer_callback"
        ))
        assert r.json()["decision"] == "DENY"
        assert "customer_callback" in r.json()["reason"]

    def test_policy_with_denied_tasks_blocks_task(self, client):
        client.post("/v1/policies", json={
            "name": "task_deny_policy",
            "agent_role": "task_agent3",
            "allowed_sources": ["reports"],
            "denied_sources": [],
            "max_sensitivity": "high",
            "allowed_tasks": [],
            "denied_tasks": ["restricted_task"],
        })
        r = client.post("/v1/context/evaluate", json=_eval_payload(
            agent_role="task_agent3", source_id="reports", task_type="restricted_task"
        ))
        assert r.json()["decision"] == "DENY"

    def test_policy_with_denied_tasks_allows_other_tasks(self, client):
        client.post("/v1/policies", json={
            "name": "task_deny_policy2",
            "agent_role": "task_agent4",
            "allowed_sources": ["reports"],
            "denied_sources": [],
            "max_sensitivity": "high",
            "allowed_tasks": [],
            "denied_tasks": ["restricted_task"],
        })
        r = client.post("/v1/context/evaluate", json=_eval_payload(
            agent_role="task_agent4", source_id="reports", task_type="allowed_task"
        ))
        assert r.json()["decision"] == "ALLOW"

    def test_policy_create_stores_task_fields(self, client):
        client.post("/v1/policies", json={
            "name": "stored_task_policy",
            "agent_role": "stored_agent",
            "allowed_sources": ["reports"],
            "denied_sources": [],
            "max_sensitivity": "high",
            "allowed_tasks": ["task_a", "task_b"],
            "denied_tasks": ["task_c"],
        })
        r = client.get("/v1/policies")
        policies = {p["name"]: p for p in r.json()}
        p = policies["stored_task_policy"]
        assert p["allowed_tasks"] == ["task_a", "task_b"]
        assert p["denied_tasks"]  == ["task_c"]


# ── audit export ──────────────────────────────────────────────────────────────

class TestAuditExport:
    def _seed(self, client, n=1, **kwargs):
        for _ in range(n):
            client.post("/v1/context/evaluate", json=_eval_payload(**kwargs))

    # ── CSV ────────────────────────────────────────────────────────────────────

    def test_csv_content_type(self, client):
        self._seed(client)
        r = client.get("/v1/audit/export?format=csv")
        assert r.status_code == 200
        assert "text/csv" in r.headers["content-type"]

    def test_csv_attachment_filename(self, client):
        self._seed(client)
        r = client.get("/v1/audit/export?format=csv")
        cd = r.headers["content-disposition"]
        assert cd.startswith("attachment")
        assert "autopil_audit_" in cd
        assert ".csv" in cd

    def test_csv_has_header_row(self, client):
        r = client.get("/v1/audit/export?format=csv")
        reader = csv.DictReader(io.StringIO(r.text))
        assert set(reader.fieldnames) == {
            "event_id", "timestamp", "session_id", "agent_role", "user_id",
            "source_id", "query", "decision", "policy_name", "reason", "context_hash",
        }

    def test_csv_empty_db_returns_header_only(self, client):
        r = client.get("/v1/audit/export?format=csv")
        rows = list(csv.DictReader(io.StringIO(r.text)))
        assert rows == []

    def test_csv_row_count_matches_events(self, client):
        self._seed(client, n=4)
        rows = list(csv.DictReader(io.StringIO(client.get("/v1/audit/export").text)))
        assert len(rows) == 4

    def test_csv_decision_values_are_strings(self, client):
        self._seed(client, source_id="reports")
        self._seed(client, source_id="executive_comms")
        rows = list(csv.DictReader(io.StringIO(client.get("/v1/audit/export").text)))
        decisions = {r["decision"] for r in rows}
        assert decisions == {"ALLOW", "DENY"}

    def test_csv_filter_by_decision(self, client):
        self._seed(client, source_id="reports")
        self._seed(client, source_id="executive_comms")
        rows = list(csv.DictReader(io.StringIO(client.get("/v1/audit/export?decision=ALLOW").text)))
        assert all(r["decision"] == "ALLOW" for r in rows)
        assert len(rows) == 1

    def test_csv_filter_by_agent_role(self, client):
        self._seed(client, agent_role="analyst")
        self._seed(client, agent_role="viewer")
        rows = list(csv.DictReader(io.StringIO(client.get("/v1/audit/export?agent_role=viewer").text)))
        assert len(rows) == 1
        assert rows[0]["agent_role"] == "viewer"

    def test_csv_filter_by_source_id(self, client):
        self._seed(client, source_id="reports")
        self._seed(client, source_id="market_data")
        rows = list(csv.DictReader(io.StringIO(client.get("/v1/audit/export?source_id=reports").text)))
        assert len(rows) == 1
        assert rows[0]["source_id"] == "reports"

    def test_csv_filter_by_decision_and_role(self, client):
        self._seed(client, agent_role="analyst", source_id="reports")
        self._seed(client, agent_role="analyst", source_id="executive_comms")
        self._seed(client, agent_role="viewer",  source_id="reports")
        rows = list(csv.DictReader(
            io.StringIO(client.get("/v1/audit/export?decision=ALLOW&agent_role=analyst").text)
        ))
        assert len(rows) == 1
        assert rows[0]["agent_role"] == "analyst"
        assert rows[0]["decision"]   == "ALLOW"

    # ── JSON ───────────────────────────────────────────────────────────────────

    def test_json_content_type(self, client):
        self._seed(client)
        r = client.get("/v1/audit/export?format=json")
        assert r.status_code == 200
        assert "application/json" in r.headers["content-type"]

    def test_json_attachment_filename(self, client):
        self._seed(client)
        r = client.get("/v1/audit/export?format=json")
        cd = r.headers["content-disposition"]
        assert cd.startswith("attachment")
        assert "autopil_audit_" in cd
        assert ".json" in cd

    def test_json_returns_list(self, client):
        self._seed(client, n=3)
        data = json.loads(client.get("/v1/audit/export?format=json").text)
        assert isinstance(data, list)
        assert len(data) == 3

    def test_json_empty_db_returns_empty_list(self, client):
        data = json.loads(client.get("/v1/audit/export?format=json").text)
        assert data == []

    def test_json_event_has_required_fields(self, client):
        self._seed(client)
        data = json.loads(client.get("/v1/audit/export?format=json").text)
        event = data[0]
        for field in ("event_id", "timestamp", "session_id", "agent_role",
                      "user_id", "source_id", "query", "decision",
                      "policy_name", "reason", "context_hash"):
            assert field in event

    def test_json_decision_values_are_strings(self, client):
        self._seed(client, source_id="reports")
        data = json.loads(client.get("/v1/audit/export?format=json").text)
        assert data[0]["decision"] in ("ALLOW", "DENY")

    def test_json_filter_by_decision(self, client):
        self._seed(client, source_id="reports")
        self._seed(client, source_id="executive_comms")
        data = json.loads(client.get("/v1/audit/export?format=json&decision=DENY").text)
        assert all(e["decision"] == "DENY" for e in data)
        assert len(data) == 1

    def test_json_filter_by_source_id(self, client):
        self._seed(client, source_id="reports")
        self._seed(client, source_id="market_data")
        data = json.loads(client.get("/v1/audit/export?format=json&source_id=market_data").text)
        assert len(data) == 1
        assert data[0]["source_id"] == "market_data"

    # ── validation ─────────────────────────────────────────────────────────────

    def test_invalid_format_returns_422(self, client):
        r = client.get("/v1/audit/export?format=xlsx")
        assert r.status_code == 422

    def test_requires_auth(self, client):
        from starlette.testclient import TestClient
        app = client.app
        unauthed = TestClient(app)
        r = unauthed.get("/v1/audit/export")
        assert r.status_code == 401


# ── audit stats ───────────────────────────────────────────────────────────────

class TestAuditStats:
    def test_empty_db_returns_zero_counts(self, client):
        r = client.get("/v1/audit/stats")
        assert r.status_code == 200
        body = r.json()
        assert body["total_events"]    == 0
        assert body["allowed"]         == 0
        assert body["denied"]          == 0
        assert body["active_sessions"] == 0

    def test_counts_match_recorded_events(self, client):
        sid = str(uuid.uuid4())
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid, source_id="reports"))
        client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid, source_id="executive_comms"))

        body = client.get("/v1/audit/stats").json()
        assert body["total_events"] == 2
        assert body["allowed"]      == 1
        assert body["denied"]       == 1

    def test_active_sessions_counted(self, client):
        for _ in range(3):
            client.post("/v1/context/evaluate", json=_eval_payload(session_id=str(uuid.uuid4())))
        body = client.get("/v1/audit/stats").json()
        assert body["active_sessions"] == 3


# ── policies ──────────────────────────────────────────────────────────────────

class TestPolicies:
    def test_list_policies_returns_all_roles(self, client):
        r = client.get("/v1/policies")
        assert r.status_code == 200
        roles = {p["agent_role"] for p in r.json()}
        assert "analyst" in roles
        assert "viewer"  in roles
        assert "admin"   in roles

    def test_policy_fields_present(self, client):
        r = client.get("/v1/policies")
        p = r.json()[0]
        for field in ("name", "agent_role", "allowed_sources", "denied_sources", "max_sensitivity"):
            assert field in p

    def test_reload_returns_ok(self, client):
        r = client.post("/v1/policies/reload")
        assert r.status_code == 200
        body = r.json()
        assert body["status"]           == "reloaded"
        assert body["policies_loaded"]  == 3  # analyst, viewer, admin


# ── lineage ───────────────────────────────────────────────────────────────────

class TestLineage:
    def _do_eval(self, client):
        """Helper: perform a successful evaluation and return the event_id."""
        r = client.post("/v1/context/evaluate", json=_eval_payload())
        assert r.status_code == 200
        return r.json()["event_id"], r.json()["session_id"]

    def test_record_action_returns_action(self, client):
        event_id, session_id = self._do_eval(client)
        r = client.post("/v1/audit/lineage", json={
            "event_id":      event_id,
            "session_id":    session_id,
            "action_type":   "test_action",
            "action_detail": {"result": "ok"},
            "outcome":       "success",
        })
        assert r.status_code == 200
        body = r.json()
        assert body["event_id"]    == event_id
        assert body["action_type"] == "test_action"
        assert body["outcome"]     == "success"
        assert "action_id" in body
        assert "timestamp" in body

    def test_record_action_stores_agent_role(self, client):
        event_id, session_id = self._do_eval(client)
        r = client.post("/v1/audit/lineage", json={
            "event_id":    event_id,
            "session_id":  session_id,
            "action_type": "role_check",
        })
        assert r.status_code == 200
        assert r.json()["agent_role"] == "analyst"

    def test_record_action_minimal_payload(self, client):
        event_id, session_id = self._do_eval(client)
        r = client.post("/v1/audit/lineage", json={
            "event_id":    event_id,
            "session_id":  session_id,
            "action_type": "minimal",
        })
        assert r.status_code == 200
        body = r.json()
        assert body["action_detail"] == {}
        assert body["outcome"] is None

    def test_record_action_unknown_event_returns_404(self, client):
        r = client.post("/v1/audit/lineage", json={
            "event_id":    str(uuid.uuid4()),
            "session_id":  str(uuid.uuid4()),
            "action_type": "ghost",
        })
        assert r.status_code == 404

    def test_get_lineage_returns_event_and_actions(self, client):
        event_id, session_id = self._do_eval(client)
        # Record two actions
        for i in range(2):
            client.post("/v1/audit/lineage", json={
                "event_id":    event_id,
                "session_id":  session_id,
                "action_type": f"step_{i}",
            })
        r = client.get(f"/v1/audit/lineage/{event_id}")
        assert r.status_code == 200
        body = r.json()
        assert body["event_id"]       == event_id
        assert body["event"]["event_id"] == event_id
        assert len(body["actions"])   == 2

    def test_get_lineage_no_actions(self, client):
        event_id, _ = self._do_eval(client)
        r = client.get(f"/v1/audit/lineage/{event_id}")
        assert r.status_code == 200
        assert r.json()["actions"] == []

    def test_get_lineage_unknown_event_returns_404(self, client):
        r = client.get(f"/v1/audit/lineage/{uuid.uuid4()}")
        assert r.status_code == 404

    def test_lineage_requires_auth(self, client):
        from starlette.testclient import TestClient
        unauthed = TestClient(client.app)
        r = unauthed.post("/v1/audit/lineage", json={
            "event_id":    str(uuid.uuid4()),
            "session_id":  str(uuid.uuid4()),
            "action_type": "x",
        })
        assert r.status_code == 401


# ── session lifecycle ─────────────────────────────────────────────────────────

class TestSessionLifecycle:
    def _eval(self, client, session_id=None):
        """Perform an evaluate call; returns (response_json, session_id used)."""
        sid = session_id or str(uuid.uuid4())
        r = client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))
        assert r.status_code == 200
        return r.json(), sid

    # ── session creation ──────────────────────────────────────────────────────

    def test_evaluate_creates_session(self, client):
        _, sid = self._eval(client)
        r = client.get(f"/v1/sessions/{sid}")
        assert r.status_code == 200
        body = r.json()
        assert body["session_id"] == sid
        assert body["status"] == "active"
        assert body["is_revoked"] is False

    def test_session_stores_agent_role(self, client):
        _, sid = self._eval(client)
        r = client.get(f"/v1/sessions/{sid}")
        assert r.json()["agent_role"] == "analyst"

    def test_second_evaluate_touches_session(self, client):
        _, sid = self._eval(client)
        first = client.get(f"/v1/sessions/{sid}").json()["last_active"]
        self._eval(client, session_id=sid)
        second = client.get(f"/v1/sessions/{sid}").json()["last_active"]
        # last_active should be >= first (may be equal if sub-second)
        assert second >= first

    def test_unknown_session_returns_404(self, client):
        r = client.get(f"/v1/sessions/{uuid.uuid4()}")
        assert r.status_code == 404

    # ── list sessions ─────────────────────────────────────────────────────────

    def test_list_sessions_returns_active(self, client):
        _, sid = self._eval(client)
        sessions = client.get("/v1/sessions").json()
        ids = [s["session_id"] for s in sessions]
        assert sid in ids

    def test_list_sessions_all_have_active_status(self, client):
        self._eval(client)
        sessions = client.get("/v1/sessions").json()
        for s in sessions:
            assert s["status"] == "active"

    # ── revocation ────────────────────────────────────────────────────────────

    def test_revoke_session(self, client):
        _, sid = self._eval(client)
        r = client.delete(f"/v1/sessions/{sid}")
        assert r.status_code == 200
        assert r.json()["status"] == "revoked"

    def test_revoked_session_shows_revoked_status(self, client):
        _, sid = self._eval(client)
        client.delete(f"/v1/sessions/{sid}")
        r = client.get(f"/v1/sessions/{sid}")
        assert r.json()["status"] == "revoked"
        assert r.json()["is_revoked"] is True

    def test_revoked_session_denies_evaluate(self, client):
        _, sid = self._eval(client)
        client.delete(f"/v1/sessions/{sid}")
        r = client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))
        assert r.status_code == 200
        body = r.json()
        assert body["decision"] == "DENY"
        assert body["policy_name"] == "session_revoked"

    def test_revoke_unknown_session_returns_404(self, client):
        r = client.delete(f"/v1/sessions/{uuid.uuid4()}")
        assert r.status_code == 404

    def test_revoked_session_included_in_all_list(self, client):
        _, sid = self._eval(client)
        client.delete(f"/v1/sessions/{sid}")
        sessions = client.get("/v1/sessions?include_expired=true").json()
        ids = [s["session_id"] for s in sessions]
        assert sid in ids

    def test_revoked_session_excluded_from_active_list(self, client):
        _, sid = self._eval(client)
        client.delete(f"/v1/sessions/{sid}")
        sessions = client.get("/v1/sessions").json()
        ids = [s["session_id"] for s in sessions]
        assert sid not in ids

    # ── TTL expiration ────────────────────────────────────────────────────────

    def test_session_with_ttl_has_expires_at(self, policy_file, audit_db, api_key):
        from fastapi.testclient import TestClient
        app = create_app(policy_path=policy_file, audit_db=audit_db, session_ttl_minutes=60)
        ttl_client = TestClient(app, headers={"X-API-Key": api_key})
        sid = str(uuid.uuid4())
        ttl_client.post("/v1/context/evaluate", json=_eval_payload(session_id=sid))
        r = ttl_client.get(f"/v1/sessions/{sid}")
        body = r.json()
        assert body["ttl_minutes"] == 60
        assert body["expires_at"] is not None

    def test_session_without_ttl_has_no_expires_at(self, client):
        _, sid = self._eval(client)
        r = client.get(f"/v1/sessions/{sid}")
        body = r.json()
        assert body["ttl_minutes"] is None
        assert body["expires_at"] is None

    # ── auth ──────────────────────────────────────────────────────────────────

    def test_sessions_require_auth(self, client):
        from starlette.testclient import TestClient
        unauthed = TestClient(client.app)
        assert unauthed.get("/v1/sessions").status_code == 401
