m2-market/scout/tests/test_scout.py

186 lines
6.5 KiB
Python

from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import httpx
import pytest
PLUGIN = Path(__file__).resolve().parents[1] / "plugin" / "m2-solution-scout" / "__init__.py"
@pytest.fixture()
def scout(monkeypatch, tmp_path):
spec = importlib.util.spec_from_file_location("m2_solution_scout_test", PLUGIN)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
assert spec.loader is not None
spec.loader.exec_module(module)
monkeypatch.setattr(module, "STATE_PATH", tmp_path / "state.json")
monkeypatch.setattr(module, "STATE_DIR", tmp_path)
monkeypatch.setattr(module, "OUTBOX_DIR", tmp_path / "outbox")
return module
def policy(scout):
return {
"enabled": True,
"desktop_id": "chris-m2o",
"operator_id": "op_chris",
"tenant_id": "machine-machine",
"min_score": 0.78,
"min_coverage": 0.2,
"max_proposals_per_day": 5,
"max_proposals_per_session": 1,
"proposal_cooldown_minutes": 45,
"summary": {
"max_source_chars": 12000,
"max_summary_chars": 120,
"redact_client_identifiers": True,
},
"catalog": {
"memory_api_url": "https://memory.test",
"agent_id": "market:catalog",
"limit": 5,
"routing_strategy": "standard",
},
"store": {"deeplink_scheme": "m2store"},
}
def test_gate_disabled_opted_out_rate_capped_and_cooldown_noop(scout):
enabled = policy(scout)
assert scout.gate({"enabled": False}, {}, session_id="s1") == (False, "disabled")
assert scout.load_policy(Path("/no/such/file")) == {
"enabled": False,
"reason": "disabled_or_missing_tenant",
}
today = scout._utcnow().date().isoformat()
assert scout.gate(
enabled,
{"daily": {today: 5}, "sessions": {}, "last_proposal_at": None},
session_id="s1",
) == (False, "rate_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {"s1": 1}, "last_proposal_at": None},
session_id="s1",
) == (False, "session_capped")
assert scout.gate(
enabled,
{"daily": {}, "sessions": {}, "last_proposal_at": scout._iso()},
session_id="s1",
) == (False, "proposal_cooldown")
def test_policy_refuses_raw_sources(scout, tmp_path):
path = tmp_path / "pull-policy.toml"
path.write_text(
"""
[scout]
enabled = true
tenant_id = "machine-machine"
[scout.sources]
raw_keystrokes = true
raw_client_data = false
""",
encoding="utf-8",
)
with pytest.raises(scout.PolicyError):
scout.load_policy(path)
def test_redaction_and_summary_are_bounded(scout):
text = (
"Use sk-abcdefghijklmnopqrstuvwxyz and API_KEY=supersecretvalue for jane@example.com. "
"Build a competitor research report with sources and PDF output."
)
summary = scout.summarize_turn(text, "Prepare the report.", policy(scout))
assert "sk-" not in summary["summary"]
assert "supersecretvalue" not in summary["summary"]
assert "jane@example.com" not in summary["summary"]
assert "[REDACTED_SECRET]" in summary["summary"]
assert len(summary["summary"]) <= 120
assert summary["schema_version"] == "m2.scout.intent_summary.v1"
def test_proposal_outbox_record_shape_matches_schema(scout, tmp_path):
p = policy(scout)
summary = scout.summarize_turn("competitor research report pdf", "", p)
match = {
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"score": 0.84,
"coverage": 0.72,
"listing": {
"name": "Competitor Scan",
"summary": "Research report package.",
"price": {"amount": 40, "currency": "m2cr", "model": "fixed"},
"stats": {"installs": 12, "rating": 4.6},
},
}
proposal = scout.build_proposal(match, summary, p)
event = scout.write_proposal_shown(proposal, tmp_path / "outbox")
assert event["event_type"] == "proposal_shown"
assert event["proposal"]["schema_version"] == "m2.scout.proposal.v1"
assert event["proposal"]["listing"]["listing_id"] == "lst_competitor-scan"
assert event["proposal"]["deeplink"].startswith("m2store://listing/lst_competitor-scan")
lines = list((tmp_path / "outbox").glob("*.jsonl"))[0].read_text(encoding="utf-8").splitlines()
assert json.loads(lines[0])["proposal_id"] == event["proposal_id"]
def test_mocked_catalog_search_filters_visibility_and_posts_live_shape(scout, monkeypatch):
requests = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(json.loads(request.content.decode("utf-8")))
return httpx.Response(
200,
json={
"results": [
{
"score": 0.91,
"metadata": {
"listing_id": "lst_competitor-scan",
"listing": {
"name": "Competitor Scan",
"summary": "Competitor research report with PDF output",
"keywords": ["competitor", "research", "report"],
"tenant_visibility": ["*"],
"status": "published",
},
},
},
{
"score": 0.99,
"metadata": {
"listing_id": "lst_private",
"listing": {
"name": "Private",
"tenant_visibility": ["other-tenant"],
"status": "published",
},
},
},
],
"routing": {},
},
)
original_init = httpx.Client.__init__
def patched_init(self, *args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
original_init(self, *args, **kwargs)
monkeypatch.setattr(httpx.Client, "__init__", patched_init)
summary = scout.summarize_turn("competitor research report pdf", "", policy(scout))
results = scout.search_catalog(summary, policy(scout))
assert requests[0]["agent_id"] == "market:catalog"
assert requests[0]["query"] == summary["summary"]
assert "tenant_id" not in requests[0]
assert [item["listing_id"] for item in results] == ["lst_competitor-scan"]