stubborn-otter/otter-boards
Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 1 remixes · Remix stubborn-otter/otter-boards
tests/conftest.py
"""Shared fixtures. By default the rules run on the in-memory fake.
Set TEST_DATABASE_URL to a throwaway Postgres loaded with schema.sql and every rules test runs a
second time on the real SqlBoards repository. tests/e2e/run.sh does that for you.
"""
import json
import os
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from fakes import FakeAI, MemoryBoards
from boards.application.use_cases import Boards
from boards.domain.model import Person
from boards.main import create_app
OWNER, MEMBER, VIEWER, STRANGER = (Person(e) for e in
("ana@x.io", "bo@x.io", "cy@x.io", "dee@x.io"))
NOW = datetime(2026, 6, 10, 9, 0, 0) # a Wednesday
TODAY = "2026-06-10"
def _repo(kind):
if kind == "memory":
return MemoryBoards()
import psycopg
from boards.infrastructure.repository import SqlBoards
url = os.environ["TEST_DATABASE_URL"]
with psycopg.connect(url, autocommit=True) as db: # every test starts from an empty bucket
db.execute("TRUNCATE boards, memberships, lists, cards, activity, card_embeddings")
return SqlBoards(lambda: psycopg.connect(url, autocommit=True))
def pytest_generate_tests(metafunc):
if "repo" in metafunc.fixturenames:
kinds = ["memory"] + (["sql"] if os.environ.get("TEST_DATABASE_URL") else [])
metafunc.parametrize("repo", kinds, indirect=True)
@pytest.fixture
def repo(request):
return _repo(request.param)
class Clock:
"""A clock that ticks, so events in one test still sort in order."""
def __init__(self):
self.value = NOW
def __call__(self):
self.value += timedelta(microseconds=10)
return self.value
@pytest.fixture
def ai():
return FakeAI()
@pytest.fixture
def svc(repo, ai):
"""A board with an owner, a member and a viewer."""
s = Boards(repo, ai, now=Clock())
b = s.create_board(OWNER, "Launch")
s.add_member(OWNER, b["id"], MEMBER.email, "member")
s.add_member(OWNER, b["id"], VIEWER.email, "viewer")
return s, b["id"]
def header(person):
return {"X-Dodil-User": json.dumps({"sub": person.email, "email": person.email})}
class McpCaller:
"""Speaks real MCP JSON-RPC to the app's /mcp, as a client would."""
def __init__(self, client, person):
self.client, self.n = client, 0
self.headers = {**header(person), "accept": "application/json, text/event-stream"}
r = self._send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "tests", "version": "0"}})
assert r.status_code == 200, r.text
def _send(self, method, params):
self.n += 1
return self.client.post("/mcp", headers=self.headers, json={
"jsonrpc": "2.0", "id": self.n, "method": method, "params": params})
def raw(self, tool, **arguments):
out = self._send("tools/call", {"name": tool, "arguments": arguments}).json()
assert "result" in out, out
return out["result"]
def call(self, tool, **arguments):
"""The tool's data; fails the test if the tool refused."""
result = self.raw(tool, **arguments)
assert not result.get("isError"), result
data = result.get("structuredContent")
return data["result"] if isinstance(data, dict) and set(data) == {"result"} else data
def error(self, tool, **arguments):
"""The tool's refusal text; fails the test if the tool worked."""
result = self.raw(tool, **arguments)
assert result.get("isError"), result
return result["content"][0]["text"]
def tools(self):
return self._send("tools/list", {}).json()["result"]["tools"]
@pytest.fixture
def web(svc):
s, _ = svc
with TestClient(create_app(s)) as client:
yield client
@pytest.fixture
def mcp_as(web):
return lambda person: McpCaller(web, person)