diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b0c089a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt -r requirements-dev.txt + - run: ruff check . + - run: pytest diff --git a/README.md b/README.md index df671f2..8dd225c 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,14 @@ curl -X POST http://:8742/entries \ -d '{"source": "finance-agent", "description": "Verdict - MSFT", "keywords": ["MSFT", "verdict"], "data": {}}' ``` -Returns the created entry (`201`) or a `400` with an error message for a missing field or invalid JSON body. +Returns the created entry (`201`) or a `400` with an error message for a missing or wrongly-typed field or invalid JSON body. + +## Development + +```bash +pip install -r requirements.txt -r requirements-dev.txt +ruff check . +pytest +``` + +Tests run against an in-memory MongoDB (`mongomock-motor`) — no containers needed. CI runs the same two commands on every push and PR. diff --git a/app/database.py b/app/database.py index 8310426..ffd5f30 100644 --- a/app/database.py +++ b/app/database.py @@ -1,4 +1,5 @@ from motor.motor_asyncio import AsyncIOMotorClient + from app.config import settings _client: AsyncIOMotorClient | None = None @@ -15,6 +16,13 @@ def get_db(): return get_client()[settings.mongodb_db] +async def ensure_indexes(): + db = get_db() + await db.entries.create_index([("created_at", -1)]) + await db.entries.create_index("source") + await db.entries.create_index("keywords") + + async def close_client(): global _client if _client is not None: diff --git a/app/formatting.py b/app/formatting.py index 3d46df6..3351374 100644 --- a/app/formatting.py +++ b/app/formatting.py @@ -13,5 +13,5 @@ def format_doc(doc: dict) -> dict: def parse_id(entry_id: str) -> ObjectId: try: return ObjectId(entry_id) - except (InvalidId, Exception): - raise ValueError(f"Invalid entry ID: {entry_id}") + except (InvalidId, TypeError): + raise ValueError(f"Invalid entry ID: {entry_id}") from None diff --git a/app/server.py b/app/server.py index 033dc01..680ea07 100644 --- a/app/server.py +++ b/app/server.py @@ -1,19 +1,24 @@ +import secrets from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import uvicorn from fastmcp import FastMCP +from pydantic import BaseModel, ValidationError from starlette.requests import Request from starlette.responses import JSONResponse from app.config import settings -from app.database import close_client, get_db +from app.database import close_client, ensure_indexes, get_db from app.formatting import format_doc, parse_id +MAX_LIMIT = 100 + @asynccontextmanager async def lifespan(server: FastMCP): + await ensure_indexes() yield await close_client() @@ -31,7 +36,8 @@ async def __call__(self, scope, receive, send): if scope["type"] == "http": headers = dict(scope.get("headers", [])) auth = headers.get(b"authorization", b"").decode() - if not auth.startswith("Bearer ") or auth[7:] != settings.api_key: + token = auth[7:] if auth.startswith("Bearer ") else "" + if not secrets.compare_digest(token.encode(), settings.api_key.encode()): response = JSONResponse({"error": "Unauthorized"}, status_code=401) await response(scope, receive, send) return @@ -42,14 +48,14 @@ async def _create_entry( source: str, description: str, data: dict[str, Any], - keywords: list[str] = [], + keywords: list[str] | None = None, ) -> dict: db = get_db() - now = datetime.now(timezone.utc) + now = datetime.now(UTC) doc = { "source": source, "description": description, - "keywords": keywords, + "keywords": keywords or [], "data": data, "created_at": now, "updated_at": now, @@ -64,7 +70,7 @@ async def create_entry( source: str, description: str, data: dict[str, Any], - keywords: list[str] = [], + keywords: list[str] | None = None, ) -> dict: """Store a new data entry produced by an AI agent.""" return await _create_entry(source, description, data, keywords) @@ -78,6 +84,8 @@ async def list_entries( skip: int = 0, ) -> dict: """List stored entries. Optionally filter by source agent or keywords.""" + limit = max(1, min(limit, MAX_LIMIT)) + skip = max(0, skip) db = get_db() query: dict = {} if source: @@ -121,7 +129,7 @@ async def update_entry( if not updates: raise ValueError("No fields provided to update") db = get_db() - updates["updated_at"] = datetime.now(timezone.utc) + updates["updated_at"] = datetime.now(UTC) result = await db.entries.find_one_and_update( {"_id": parse_id(entry_id)}, {"$set": updates}, @@ -142,6 +150,13 @@ async def delete_entry(entry_id: str) -> dict: return {"deleted": True, "id": entry_id} +class EntryPayload(BaseModel): + source: str + description: str + data: dict[str, Any] + keywords: list[str] = [] + + async def create_entry_endpoint(request: Request) -> JSONResponse: """REST equivalent of create_entry, for callers that can't speak MCP (e.g. hook scripts).""" try: @@ -150,17 +165,19 @@ async def create_entry_endpoint(request: Request) -> JSONResponse: return JSONResponse({"error": "Invalid JSON body"}, status_code=400) try: - source = body["source"] - description = body["description"] - data = body["data"] - except KeyError as e: - return JSONResponse({"error": f"Missing required field: {e.args[0]}"}, status_code=400) + payload = EntryPayload.model_validate(body) + except ValidationError as e: + errors = "; ".join( + f"{'.'.join(str(loc) for loc in err['loc']) or 'body'}: {err['msg']}" + for err in e.errors() + ) + return JSONResponse({"error": errors}, status_code=400) entry = await _create_entry( - source=source, - description=description, - data=data, - keywords=body.get("keywords", []), + source=payload.source, + description=payload.description, + data=payload.data, + keywords=payload.keywords, ) return JSONResponse(entry, status_code=201) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a255430 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9f233e4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +pytest==8.3.4 +pytest-asyncio==0.25.2 +mongomock-motor==0.0.36 +httpx==0.28.1 +ruff==0.9.4 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..04bdeb4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import os + +os.environ.setdefault("API_KEY", "test-api-key") +os.environ.setdefault("UI_USERNAME", "admin") +os.environ.setdefault("UI_PASSWORD", "test-password") + +import pytest +from mongomock_motor import AsyncMongoMockClient + +from app import database + + +@pytest.fixture(autouse=True) +def mock_db(monkeypatch): + """Back every test with a fresh in-memory MongoDB.""" + client = AsyncMongoMockClient() + monkeypatch.setattr(database, "_client", client) + yield database.get_db() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..079fb82 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,11 @@ +from app import database + + +async def test_ensure_indexes_creates_expected_indexes(mock_db): + await database.ensure_indexes() + + index_info = await mock_db.entries.index_information() + indexed_keys = {tuple(info["key"]) for info in index_info.values()} + assert (("created_at", -1),) in indexed_keys + assert (("source", 1),) in indexed_keys + assert (("keywords", 1),) in indexed_keys diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 0000000..d8d9f73 --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime + +import pytest +from bson import ObjectId + +from app.formatting import format_doc, parse_id + + +def test_format_doc_converts_id_and_timestamps(): + oid = ObjectId() + now = datetime(2026, 8, 1, 12, 0, 0, tzinfo=UTC) + doc = {"_id": oid, "source": "x", "created_at": now, "updated_at": now} + + out = format_doc(doc) + + assert out["id"] == str(oid) + assert "_id" not in out + assert out["created_at"] == "2026-08-01T12:00:00+00:00" + assert out["updated_at"] == "2026-08-01T12:00:00+00:00" + + +def test_format_doc_does_not_mutate_input(): + doc = { + "_id": ObjectId(), + "created_at": datetime.now(UTC), + "updated_at": datetime.now(UTC), + } + format_doc(doc) + assert "_id" in doc + + +def test_parse_id_roundtrip(): + oid = ObjectId() + assert parse_id(str(oid)) == oid + + +@pytest.mark.parametrize("bad", ["not-an-id", "", "68571a2c", 123]) +def test_parse_id_invalid_raises_value_error(bad): + with pytest.raises(ValueError, match="Invalid entry ID"): + parse_id(bad) diff --git a/tests/test_hook.py b/tests/test_hook.py new file mode 100644 index 0000000..b47bafd --- /dev/null +++ b/tests/test_hook.py @@ -0,0 +1,139 @@ +import importlib.util +import io +import json +from pathlib import Path + +import pytest + +HOOK_PATH = Path(__file__).parent.parent / "hooks" / "log_finance_entry.py" +spec = importlib.util.spec_from_file_location("log_finance_entry", HOOK_PATH) +hook = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hook) + + +def test_match_prefix_picks_longest_match(): + assert hook.match_prefix("check_stock_MSFT_20260801.json") == "check_stock" + assert hook.match_prefix("check_crypto_BTC_20260801.json") == "check_crypto" + assert hook.match_prefix("verdict_MSFT_20260801.json") == "verdict" + assert hook.match_prefix("news_results_20260801.json") == "news_results" + + +def test_match_prefix_rejects_unknown(): + assert hook.match_prefix("random_file.json") is None + assert hook.match_prefix("verdict.json") is None # no underscore separator + + +def test_describe_verdict(): + content = {"symbol": "MSFT", "verdict": {"winner": "bear", "confidence": "medium"}} + assert hook.describe("verdict", "MSFT", content) == "Verdict - MSFT (bear, medium confidence)" + + +def test_describe_check_stock_with_price(): + content = {"name": "Microsoft", "symbol": "MSFT", "price": 512.5, "change_percent": -1.25} + assert hook.describe("check_stock", "MSFT", content) == "Microsoft (MSFT) - $512.50 (-1.25%)" + + +def test_describe_check_stock_without_price(): + content = {"name": "Microsoft", "symbol": "MSFT"} + assert hook.describe("check_stock", "MSFT", content) == "Microsoft (MSFT) price check" + + +def test_describe_portfolio(): + content = {"account_summary": {"totalValue": 12345.67, "currency": "GBP"}} + assert hook.describe("portfolio", None, content) == "Portfolio snapshot - GBP 12,345.67" + + +def test_describe_news_results(): + assert hook.describe("news_results", None, [1, 2, 3]) == "News search - 3 articles" + assert hook.describe("news_results", None, {"odd": "shape"}) == "News search" + + +def test_build_keywords(): + assert hook.build_keywords("verdict", "trader", "MSFT", None) == ["MSFT", "trader", "verdict"] + assert hook.build_keywords("history", "trader", "AAPL", "1mo") == [ + "AAPL", + "trader", + "history", + "1mo", + ] + # prefix matching the skill dir is not duplicated + assert hook.build_keywords("portfolio", "portfolio", None, None) == ["portfolio"] + + +@pytest.fixture +def artifact(tmp_path): + """A finance-agent repo layout with one verdict artifact on disk.""" + cwd = tmp_path / "finance-agent" + tmp_dir = cwd / "skills" / "trader" / "tmp" + tmp_dir.mkdir(parents=True) + path = tmp_dir / "verdict_MSFT_20260801_122634.json" + path.write_text( + json.dumps({"symbol": "MSFT", "verdict": {"winner": "bear", "confidence": "medium"}}) + ) + return cwd, path + + +def run_main(monkeypatch, event): + posted = [] + monkeypatch.setattr(hook, "load_ingest_config", lambda: ("http://test/entries", "tok")) + monkeypatch.setattr(hook, "post_entry", lambda url, token, payload: posted.append(payload)) + monkeypatch.setattr(hook.sys, "stdin", io.StringIO(json.dumps(event))) + hook.main() + return posted + + +def test_main_logs_verdict_artifact(monkeypatch, artifact): + cwd, path = artifact + event = { + "cwd": str(cwd), + "tool_name": "Bash", + "tool_input": {"command": "python skills/trader/verdict.py MSFT"}, + "tool_response": {"stdout": f"{path}\n"}, + } + + posted = run_main(monkeypatch, event) + + assert len(posted) == 1 + entry = posted[0] + assert entry["source"] == "finance-agent" + assert entry["description"] == "Verdict - MSFT (bear, medium confidence)" + assert entry["keywords"] == ["MSFT", "trader", "verdict"] + assert entry["data"]["artifact"]["symbol"] == "MSFT" + assert entry["data"]["artifact_path"] == str(path) + + +def test_main_ignores_other_projects(monkeypatch, artifact): + _, path = artifact + event = { + "cwd": "/home/user/some-other-project", + "tool_name": "Bash", + "tool_response": {"stdout": f"{path}\n"}, + } + assert run_main(monkeypatch, event) == [] + + +def test_main_ignores_non_bash_tools(monkeypatch, artifact): + cwd, path = artifact + event = {"cwd": str(cwd), "tool_name": "Read", "tool_response": {"stdout": f"{path}\n"}} + assert run_main(monkeypatch, event) == [] + + +def test_main_ignores_non_artifact_stdout(monkeypatch, artifact): + cwd, _ = artifact + event = { + "cwd": str(cwd), + "tool_name": "Bash", + "tool_response": {"stdout": "total 4\n-rw-r--r-- 1 user user 0 file.txt\n"}, + } + assert run_main(monkeypatch, event) == [] + + +def test_main_ignores_missing_artifact_file(monkeypatch, artifact): + cwd, path = artifact + path.unlink() + event = { + "cwd": str(cwd), + "tool_name": "Bash", + "tool_response": {"stdout": f"{path}\n"}, + } + assert run_main(monkeypatch, event) == [] diff --git a/tests/test_rest_endpoint.py b/tests/test_rest_endpoint.py new file mode 100644 index 0000000..9948021 --- /dev/null +++ b/tests/test_rest_endpoint.py @@ -0,0 +1,76 @@ +import httpx +import pytest +from starlette.applications import Starlette +from starlette.routing import Route + +from app.server import BearerAuthMiddleware, create_entry_endpoint + +VALID = { + "source": "finance-agent", + "description": "Verdict - MSFT", + "keywords": ["MSFT", "verdict"], + "data": {"winner": "bear"}, +} +AUTH = {"Authorization": "Bearer test-api-key"} + + +@pytest.fixture +def client(): + app = Starlette(routes=[Route("/entries", create_entry_endpoint, methods=["POST"])]) + app.add_middleware(BearerAuthMiddleware) + transport = httpx.ASGITransport(app=app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +async def test_rejects_missing_token(client): + resp = await client.post("/entries", json=VALID) + assert resp.status_code == 401 + + +async def test_rejects_wrong_token(client): + resp = await client.post("/entries", json=VALID, headers={"Authorization": "Bearer nope"}) + assert resp.status_code == 401 + + +async def test_rejects_non_bearer_scheme(client): + resp = await client.post("/entries", json=VALID, headers={"Authorization": "test-api-key"}) + assert resp.status_code == 401 + + +async def test_creates_entry(client): + resp = await client.post("/entries", json=VALID, headers=AUTH) + + assert resp.status_code == 201 + entry = resp.json() + assert entry["source"] == "finance-agent" + assert entry["keywords"] == ["MSFT", "verdict"] + assert entry["data"] == {"winner": "bear"} + assert entry["id"] + + +async def test_keywords_optional(client): + payload = {k: v for k, v in VALID.items() if k != "keywords"} + resp = await client.post("/entries", json=payload, headers=AUTH) + assert resp.status_code == 201 + assert resp.json()["keywords"] == [] + + +async def test_rejects_invalid_json(client): + resp = await client.post( + "/entries", content=b"not json", headers={**AUTH, "Content-Type": "application/json"} + ) + assert resp.status_code == 400 + assert resp.json()["error"] == "Invalid JSON body" + + +async def test_rejects_missing_field(client): + payload = {k: v for k, v in VALID.items() if k != "description"} + resp = await client.post("/entries", json=payload, headers=AUTH) + assert resp.status_code == 400 + assert "description" in resp.json()["error"] + + +async def test_rejects_wrong_field_type(client): + resp = await client.post("/entries", json={**VALID, "data": "not a dict"}, headers=AUTH) + assert resp.status_code == 400 + assert "data" in resp.json()["error"] diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py new file mode 100644 index 0000000..82ac406 --- /dev/null +++ b/tests/test_server_tools.py @@ -0,0 +1,131 @@ +import itertools +from datetime import UTC, datetime, timedelta + +import pytest +from bson import ObjectId + +from app import server + +# @mcp.tool() wraps functions in FunctionTool objects; the plain coroutine is .fn +create_entry = server.create_entry.fn +list_entries = server.list_entries.fn +get_entry = server.get_entry.fn +update_entry = server.update_entry.fn +delete_entry = server.delete_entry.fn + + +@pytest.fixture(autouse=True) +def ticking_clock(monkeypatch): + """Advance time 1ms per call — Mongo timestamps have millisecond precision, + so real wall-clock time gives consecutive writes identical created_at.""" + base = datetime(2026, 8, 8, 12, 0, 0, tzinfo=UTC) + ticks = itertools.count() + + class SteppingDatetime: + @staticmethod + def now(tz=None): + return base + timedelta(milliseconds=next(ticks)) + + monkeypatch.setattr(server, "datetime", SteppingDatetime) + + +async def test_create_entry_returns_formatted_doc(): + entry = await create_entry("finance-agent", "Verdict - MSFT", {"x": 1}, ["MSFT"]) + + assert entry["source"] == "finance-agent" + assert entry["description"] == "Verdict - MSFT" + assert entry["keywords"] == ["MSFT"] + assert entry["data"] == {"x": 1} + assert entry["created_at"] == entry["updated_at"] + assert ObjectId(entry["id"]) + + +async def test_create_entry_defaults_keywords_to_empty_list(): + entry = await create_entry("a", "b", {}) + assert entry["keywords"] == [] + + +async def test_list_entries_filters_and_sorts_newest_first(): + await create_entry("agent-a", "first", {}, ["MSFT"]) + await create_entry("agent-a", "second", {}, ["MSFT", "verdict"]) + await create_entry("agent-b", "other", {}, ["AAPL"]) + + result = await list_entries() + assert result["total"] == 3 + assert [i["description"] for i in result["items"]] == ["other", "second", "first"] + + by_source = await list_entries(source="agent-a") + assert by_source["total"] == 2 + + by_keywords = await list_entries(keywords=["MSFT", "verdict"]) + assert by_keywords["total"] == 1 + assert by_keywords["items"][0]["description"] == "second" + + +async def test_list_entries_pagination(): + for i in range(5): + await create_entry("a", f"entry-{i}", {}) + + page = await list_entries(limit=2, skip=2) + assert page["total"] == 5 + assert len(page["items"]) == 2 + assert [i["description"] for i in page["items"]] == ["entry-2", "entry-1"] + + +async def test_list_entries_clamps_bad_limit_and_skip(): + await create_entry("a", "only", {}) + + result = await list_entries(limit=-5, skip=-10) + assert len(result["items"]) == 1 + + result = await list_entries(limit=10_000_000) + assert len(result["items"]) == 1 + + +async def test_get_entry_roundtrip(): + created = await create_entry("a", "b", {"k": "v"}) + fetched = await get_entry(created["id"]) + assert fetched == created + + +async def test_get_entry_not_found(): + with pytest.raises(ValueError, match="Entry not found"): + await get_entry(str(ObjectId())) + + +async def test_get_entry_invalid_id(): + with pytest.raises(ValueError, match="Invalid entry ID"): + await get_entry("garbage") + + +async def test_update_entry_changes_only_given_fields(): + created = await create_entry("a", "old", {"k": "v"}, ["kw"]) + + updated = await update_entry(created["id"], description="new") + + assert updated["description"] == "new" + assert updated["source"] == "a" + assert updated["keywords"] == ["kw"] + assert updated["data"] == {"k": "v"} + assert updated["updated_at"] > created["updated_at"] + + +async def test_update_entry_requires_a_field(): + created = await create_entry("a", "b", {}) + with pytest.raises(ValueError, match="No fields provided"): + await update_entry(created["id"]) + + +async def test_update_entry_not_found(): + with pytest.raises(ValueError, match="Entry not found"): + await update_entry(str(ObjectId()), description="x") + + +async def test_delete_entry(): + created = await create_entry("a", "b", {}) + + result = await delete_entry(created["id"]) + assert result == {"deleted": True, "id": created["id"]} + + with pytest.raises(ValueError, match="Entry not found"): + await delete_entry(created["id"]) diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..9ec1d57 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,92 @@ +from datetime import UTC, datetime + +import httpx +import pytest +from bson import ObjectId + +from app.web import app as web_app + +AUTH = ("admin", "test-password") + + +@pytest.fixture +def client(): + transport = httpx.ASGITransport(app=web_app) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + +async def seed(db, **overrides): + now = datetime.now(UTC) + doc = { + "source": "finance-agent", + "description": "Verdict - MSFT", + "keywords": ["MSFT", "verdict"], + "data": {"winner": "bear"}, + "created_at": now, + "updated_at": now, + **overrides, + } + result = await db.entries.insert_one(doc) + return str(result.inserted_id) + + +async def test_requires_auth(client): + resp = await client.get("/") + assert resp.status_code == 401 + assert resp.headers["www-authenticate"] == 'Basic realm="ai-data-store"' + + +async def test_rejects_wrong_password(client): + resp = await client.get("/", auth=("admin", "wrong")) + assert resp.status_code == 401 + + +async def test_index_empty_state(client): + resp = await client.get("/", auth=AUTH) + assert resp.status_code == 200 + assert "No entries found" in resp.text + + +async def test_index_lists_entries(client, mock_db): + await seed(mock_db) + await seed(mock_db, source="other-agent", description="Something else", keywords=[]) + + resp = await client.get("/", auth=AUTH) + assert resp.status_code == 200 + assert "Verdict - MSFT" in resp.text + assert "Something else" in resp.text + assert "2 entries" in resp.text + + +async def test_index_filters_by_source(client, mock_db): + await seed(mock_db) + await seed(mock_db, source="other-agent", description="Something else") + + resp = await client.get("/", params={"source": "other-agent"}, auth=AUTH) + assert "Something else" in resp.text + assert "Verdict - MSFT" not in resp.text + + +async def test_index_escapes_html(client, mock_db): + await seed(mock_db, description="") + + resp = await client.get("/", auth=AUTH) + assert "" not in resp.text + assert "<script>" in resp.text + + +async def test_detail_page(client, mock_db): + entry_id = await seed(mock_db) + + resp = await client.get(f"/entries/{entry_id}", auth=AUTH) + assert resp.status_code == 200 + assert "Verdict - MSFT" in resp.text + assert "winner" in resp.text + assert entry_id in resp.text + + +@pytest.mark.parametrize("entry_id", ["garbage", str(ObjectId())]) +async def test_detail_not_found(client, entry_id): + resp = await client.get(f"/entries/{entry_id}", auth=AUTH) + assert resp.status_code == 404 + assert "Entry not found" in resp.text