fleet-memory/hindsight-integrations/claude-code/tests/test_client.py
Nicolò Boschi 35b2cbb6ed
fix(claude-code): fix plugin installation, config UX, and release workflow (#661)
* fix(claude-code): fix plugin installation and release workflow

- Fix plugin.json author field (string → object) to pass claude plugin validate
- Add hindsight-integrations/.claude-plugin/marketplace.json so users can install
  via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations
- Update README and install.sh with correct two-command install flow
- Fix release-integration.yml: add explicit package.json check for typescript type
  and add plugin type for integrations with neither pyproject.toml nor package.json
  (prevents claude-code from incorrectly falling into the typescript build path)
- Add CHANGELOG.md for the claude-code integration

* remove install.sh — users install via claude plugin commands directly

* test(claude-code): add 116 unit tests for plugin hooks and lib modules

* feat(claude-code): user settings.json at CLAUDE_PLUGIN_DATA for stable config

Plugin now checks CLAUDE_PLUGIN_DATA/settings.json after the versioned
plugin default, giving users a path that persists across updates:
  ~/.claude/plugins/data/hindsight-memory-hindsight/settings.json

Loading order: defaults → plugin settings.json → user settings.json → env vars

* fix(claude-code): use ~/.hindsight/claude-code.json for user config

Matches the ~/.openclaw/openclaw.json convention. Removes the confusing
CLAUDE_PLUGIN_DATA path whose name depends on marketplace+plugin identifiers.

* docs(claude-code): add ToS hint for claude-code LLM provider option

* fix(claude-code): set author to Hindsight Team in plugin.json

* ci: add test-claude-code-integration job to run plugin unit tests
2026-03-23 15:15:16 +01:00

216 lines
7.3 KiB
Python

"""Tests for lib/client.py — Hindsight REST API client."""
import json
import urllib.error
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from lib.client import HindsightClient, _validate_api_url
class TestValidateApiUrl:
def test_valid_http(self):
assert _validate_api_url("http://localhost:9077") == "http://localhost:9077"
def test_valid_https(self):
assert _validate_api_url("https://api.example.com/") == "https://api.example.com"
def test_trailing_slash_stripped(self):
assert _validate_api_url("http://host:8080/") == "http://host:8080"
def test_invalid_scheme_raises(self):
with pytest.raises(ValueError, match="http or https"):
_validate_api_url("ftp://host")
def test_no_hostname_raises(self):
with pytest.raises(ValueError):
_validate_api_url("http://")
class FakeResp:
def __init__(self, data, status=200):
self.status = status
self._body = json.dumps(data).encode()
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *_):
pass
class TestHindsightClientInit:
def test_rejects_non_http_url(self):
with pytest.raises(ValueError):
HindsightClient("ftp://bad")
def test_stores_token(self):
c = HindsightClient("http://localhost:9077", api_token="tok123")
assert c.api_token == "tok123"
def test_no_token(self):
c = HindsightClient("http://localhost:9077")
assert c.api_token is None
class TestHindsightClientRecall:
def test_posts_to_correct_path(self):
c = HindsightClient("http://localhost:9077")
response_data = {"results": [{"text": "Paris", "type": "world"}]}
with patch("urllib.request.urlopen", return_value=FakeResp(response_data)):
resp = c.recall("my-bank", "capital of France")
assert resp["results"][0]["text"] == "Paris"
def test_bank_id_url_encoded(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["url"] = req.full_url
return FakeResp({"results": []})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.recall("bank with spaces", "query")
assert "bank%20with%20spaces" in captured["url"]
def test_includes_auth_header_when_token_set(self):
c = HindsightClient("http://localhost:9077", api_token="mytoken")
captured = {}
def fake_open(req, timeout=None):
captured["headers"] = dict(req.headers)
return FakeResp({"results": []})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.recall("bank", "query")
assert "Authorization" in captured["headers"]
assert "mytoken" in captured["headers"]["Authorization"]
def test_no_auth_header_without_token(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["headers"] = dict(req.headers)
return FakeResp({"results": []})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.recall("bank", "query")
assert "Authorization" not in captured["headers"]
def test_http_error_raises_runtime_error(self):
c = HindsightClient("http://localhost:9077")
err = urllib.error.HTTPError(
url="http://localhost:9077/v1/default/banks/b/memories/recall",
code=500,
msg="Internal Server Error",
hdrs={},
fp=BytesIO(b"server exploded"),
)
with patch("urllib.request.urlopen", side_effect=err):
with pytest.raises(RuntimeError, match="HTTP 500"):
c.recall("b", "query")
def test_sends_budget_and_types(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return FakeResp({"results": []})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.recall("bank", "query", budget="high", types=["world", "experience"])
assert captured["body"]["budget"] == "high"
assert captured["body"]["types"] == ["world", "experience"]
class TestHindsightClientRetain:
def test_posts_with_async_true(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return FakeResp({"status": "accepted"})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.retain("bank", "transcript content", document_id="doc-1", context="claude-code")
assert captured["body"]["async"] is True
assert captured["body"]["items"][0]["content"] == "transcript content"
assert captured["body"]["items"][0]["context"] == "claude-code"
def test_bank_id_encoded_in_retain_path(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["url"] = req.full_url
return FakeResp({})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.retain("my::bank", "content")
assert "my%3A%3Abank" in captured["url"]
class TestHindsightClientHealthCheck:
def test_returns_true_on_200(self):
c = HindsightClient("http://localhost:9077")
with patch("urllib.request.urlopen", return_value=FakeResp({}, status=200)):
with patch("time.sleep"): # don't actually sleep
assert c.health_check() is True
def test_returns_false_after_retries(self):
c = HindsightClient("http://localhost:9077")
with patch("urllib.request.urlopen", side_effect=OSError("refused")):
with patch("time.sleep"):
assert c.health_check() is False
def test_retries_on_failure(self):
c = HindsightClient("http://localhost:9077")
call_count = 0
def flaky(*_a, **_kw):
nonlocal call_count
call_count += 1
if call_count < 3:
raise OSError("not yet")
return FakeResp({}, status=200)
with patch("urllib.request.urlopen", side_effect=flaky):
with patch("time.sleep"):
result = c.health_check()
assert result is True
assert call_count == 3
class TestHindsightClientSetBankMission:
def test_patches_config_endpoint(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.method
captured["body"] = json.loads(req.data.decode())
return FakeResp({})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.set_bank_mission("my-bank", "I am Claude", retain_mission="Extract facts")
assert captured["method"] == "PATCH"
assert "my-bank" in captured["url"]
assert captured["body"]["updates"]["reflect_mission"] == "I am Claude"
assert captured["body"]["updates"]["retain_mission"] == "Extract facts"