eduard/cobra-boards

Cobra Boards, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Dodil Models + MCP.

A remix template · 1 remixes · Remix eduard/cobra-boards

tests/test_doors_more.py

"""Every route and every MCP tool, through the real HTTP stack with the gateway's identity header:
what a signed-in person gets back, what a viewer and a stranger get back, and what the agent reads."""
import json

import pytest
from conftest import MEMBER, OWNER, STRANGER, VIEWER, headers
from fastapi.testclient import TestClient
from test_doors import mcp

from boards.doors import text
from boards.main import create_app


@pytest.fixture
def client(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        yield c, s, bid


def card(c, bid, who=OWNER, **body):
    r = c.post(f"/api/boards/{bid}/cards", json={"title": "Plan", **body}, headers=headers(who))
    assert r.status_code == 201, r.text
    return r.json()


# ---------------------------------------------------------------- identity
@pytest.mark.parametrize("raw", ["not json", json.dumps({"sub": ""}), json.dumps({})])
def test_an_identity_header_without_a_person_is_no_one(client, raw):
    c, _, _ = client
    assert c.get("/api/me", headers={"X-Dodil-User": raw}).status_code == 401


def test_who_am_i_and_the_email_is_lowercased(client):
    c, _, _ = client
    h = {"X-Dodil-User": json.dumps({"sub": "x", "email": " Bo@Cobra.Test "})}
    assert c.get("/api/me", headers=h).json() == {"email": MEMBER.email}


def test_the_sign_in_metadata_names_the_app(client):
    c, _, _ = client
    meta = c.get("/.well-known/oauth-protected-resource").json()
    assert meta["resource_name"] == "Cobra Boards" and meta["bearer_methods_supported"] == ["header"]


# ---------------------------------------------------------------- boards & members over the web
def test_web_create_board_and_list_my_boards(client):
    c, _, _ = client
    r = c.post("/api/boards", json={"title": "Ops"}, headers=headers(MEMBER))
    assert r.status_code == 201 and r.json()["lists"] == ["To do", "Doing", "Done"]
    mine = c.get("/api/boards", headers=headers(MEMBER)).json()
    assert {(b["title"], b["role"]) for b in mine} == {("Launch", "member"), ("Ops", "owner")}


def test_web_a_newcomer_gets_the_sample_board(client):
    c, _, _ = client
    mine = c.get("/api/boards", headers=headers(STRANGER)).json()
    assert [b["title"] for b in mine] == ["Welcome to Cobra 🐍"] and mine[0]["overdue"] == 2


def test_web_only_the_owner_renames(client):
    c, _, bid = client
    assert c.patch(f"/api/boards/{bid}", json={"title": "Ours"},
                   headers=headers(MEMBER)).status_code == 403
    r = c.patch(f"/api/boards/{bid}", json={"title": "Launch 2027"}, headers=headers(OWNER))
    assert r.status_code == 200 and r.json()["title"] == "Launch 2027"


def test_web_invite_and_remove_a_member(client):
    c, _, bid = client
    card(c, bid, assignee=MEMBER.email)
    r = c.put(f"/api/boards/{bid}/members", json={"email": "eve@cobra.test", "role": "viewer"},
              headers=headers(OWNER))
    assert r.status_code == 201 and r.json()["role"] == "viewer"
    r = c.delete(f"/api/boards/{bid}/members/{MEMBER.email}", headers=headers(OWNER))
    assert r.status_code == 200 and r.json()["cards_unassigned"] == 1
    assert c.get(f"/api/boards/{bid}", headers=headers(MEMBER)).status_code == 404


def test_web_lists_add_and_rename(client):
    c, _, bid = client
    r = c.post(f"/api/boards/{bid}/lists", json={"name": "Review"}, headers=headers(MEMBER))
    assert r.status_code == 201
    lid = r.json()["id"]
    r = c.patch(f"/api/boards/{bid}/lists/{lid}", json={"name": "QA", "is_done": True},
                headers=headers(MEMBER))
    assert r.json()["name"] == "QA" and r.json()["is_done"] is True
    assert c.post(f"/api/boards/{bid}/lists", json={"name": "qa"},
                  headers=headers(MEMBER)).status_code == 422


def test_web_activity_and_summary(client):
    c, _, bid = client
    card(c, bid, due_date="2026-11-01")
    act = c.get(f"/api/boards/{bid}/activity", headers=headers(VIEWER)).json()
    assert act[0]["summary"].startswith("added #1")
    summary = c.get(f"/api/boards/{bid}/summary", headers=headers(VIEWER)).json()
    assert summary["facts"]["overdue"] == [1]


# ---------------------------------------------------------------- cards over the web
def test_web_bad_bodies_are_422(client):
    c, _, bid = client
    h = headers(OWNER)
    assert c.post(f"/api/boards/{bid}/cards", json={}, headers=h).status_code == 422
    assert c.post(f"/api/boards/{bid}/cards", json={"title": " "}, headers=h).status_code == 422
    assert c.post(f"/api/boards/{bid}/cards", json={"title": "x", "due_date": "someday"},
                  headers=h).status_code == 422
    assert c.post(f"/api/boards/{bid}/cards", json={"title": "x", "assignee": STRANGER.email},
                  headers=h).status_code == 422


def test_web_card_detail_has_everything_the_page_shows(client):
    c, _, bid = client
    made = card(c, bid, assignee=MEMBER.email, labels=["urgent"], description="Rough")
    d = c.get(f"/api/cards/{made['id']}", headers=headers(VIEWER)).json()
    assert d["can_edit"] is False and d["people"] == [OWNER.email, MEMBER.email, VIEWER.email]
    assert d["labels"] == ["urgent"] and d["history"][0]["action"] == "created"
    assert d["checklist"] == [] and d["blocks"] == [] and d["blocked_by_all"] == []


def test_web_stranger_gets_404_on_every_card_route(client):
    c, _, bid = client
    cid = card(c, bid)["id"]
    h = headers(STRANGER)
    assert c.get(f"/api/cards/{cid}/history", headers=h).status_code == 404
    assert c.get(f"/api/cards/{cid}/blocking", headers=h).status_code == 404
    assert c.patch(f"/api/cards/{cid}", json={"title": "x"}, headers=h).status_code == 404
    assert c.post(f"/api/cards/{cid}/move", json={"list": "Done"}, headers=h).status_code == 404
    assert c.post(f"/api/cards/{cid}/checklist/suggest", headers=h).status_code == 404


def test_web_blockers_add_explain_and_remove(client):
    c, _, bid = client
    ship, qa = card(c, bid, title="Ship"), card(c, bid, title="QA")
    r = c.post(f"/api/cards/{ship['id']}/blockers", json={"blocked_by": qa["id"]},
               headers=headers(MEMBER))
    assert r.status_code == 201 and r.json() == {"card": 1, "blocked_by": 2}
    why = c.get(f"/api/cards/{ship['id']}/blocking", headers=headers(VIEWER)).json()
    assert why["blocked"] and why["start_with"][0]["title"] == "QA"
    assert c.post(f"/api/cards/{qa['id']}/blockers", json={"blocked_by": ship["id"]},
                  headers=headers(MEMBER)).status_code == 422  # a loop
    r = c.delete(f"/api/cards/{ship['id']}/blockers/{qa['id']}", headers=headers(MEMBER))
    assert r.status_code == 200
    assert c.get(f"/api/cards/{ship['id']}", headers=headers(OWNER)).json()["blocked_by"] == []


def test_web_checklist_suggest_add_tick_and_delete(client):
    c, _, bid = client
    cid = card(c, bid)["id"]
    h = headers(MEMBER)
    sug = c.post(f"/api/cards/{cid}/checklist/suggest", headers=h).json()
    assert sug["suggested_steps"] == ["Draft it", "Review it", "Ship it"]
    items = c.post(f"/api/cards/{cid}/checklist", json={"items": sug["suggested_steps"][:2]},
                   headers=h).json()["checklist"]
    first = items[0]["id"]
    ticked = c.patch(f"/api/cards/{cid}/checklist/{first}", json={"done": True}, headers=h).json()
    assert ticked["checklist"][0]["done"] is True
    left = c.delete(f"/api/cards/{cid}/checklist/{first}", headers=h).json()["checklist"]
    assert [i["text"] for i in left] == ["Review it"]
    assert c.patch(f"/api/cards/{cid}/checklist/{first}", json={"done": True},
                   headers=h).status_code == 404
    assert c.post(f"/api/cards/{cid}/checklist", json={"items": ["x"]},
                  headers=headers(VIEWER)).status_code == 403


def test_web_my_week_overdue_and_search(client):
    c, _, bid = client
    card(c, bid, title="Send invoice", assignee=MEMBER.email, due_date="2026-11-01")
    week = c.get("/api/me/week", headers=headers(MEMBER)).json()
    assert [x["title"] for x in week["overdue"]] == ["Send invoice"]
    assert [x["title"] for x in c.get("/api/overdue", headers=headers(VIEWER)).json()] == [
        "Send invoice"]
    found = c.get("/api/search", params={"q": "invoice"}, headers=headers(VIEWER)).json()
    assert [x["number"] for x in found] == [1]
    assert c.get("/api/search", params={"q": " "}, headers=headers(VIEWER)).status_code == 422
    assert c.get("/api/search", headers=headers(VIEWER)).status_code == 422
    assert c.get("/api/search", params={"q": "invoice"}, headers=headers(STRANGER)).json() == []


def test_web_similar_by_card_and_by_text(client):
    c, _, bid = client
    inv = card(c, bid, title="Send invoice to Acme")
    card(c, bid, title="Chase unpaid bill")
    card(c, bid, title="Design the logo")
    by_card = c.post(f"/api/cards/{inv['id']}/similar", headers=headers(MEMBER)).json()
    assert by_card["cards"][0]["title"] == "Chase unpaid bill"
    by_text = c.post("/api/similar", json={"text": "logo design"}, headers=headers(MEMBER)).json()
    assert by_text["cards"][0]["title"] == "Design the logo"


def test_web_move_before_another_card_and_stale_move(client):
    c, _, bid = client
    a, b = card(c, bid, title="a"), card(c, bid, title="b")
    r = c.post(f"/api/cards/{b['id']}/move", json={"list": "To do", "before_card": a["id"]},
               headers=headers(MEMBER))
    assert r.status_code == 200 and r.json()["position"] < a["position"]
    stale = c.post(f"/api/cards/{b['id']}/move",
                   json={"list": "Done", "expected_version": b["version"]}, headers=headers(OWNER))
    assert stale.status_code == 409


# ---------------------------------------------------------------- MCP: the tool list
def tools(c):
    h = {**headers(MEMBER), "accept": "application/json, text/event-stream"}
    c.post("/mcp", headers=h, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
        "protocolVersion": "2025-06-18", "capabilities": {},
        "clientInfo": {"name": "t", "version": "0"}}})
    return c.post("/mcp", headers=h, json={"jsonrpc": "2.0", "id": 2,
                                           "method": "tools/list"}).json()["result"]["tools"]


def test_mcp_has_exactly_the_action_tools(client):
    c, _, _ = client
    assert {t["name"] for t in tools(c)} == {
        "my_boards", "show_board", "my_week", "overdue", "find_cards", "show_card",
        "card_history", "what_is_blocking", "create_board", "add_list", "invite",
        "remove_member", "add_card", "move_card", "assign_card", "edit_card", "add_blocker",
        "remove_blocker", "suggest_checklist", "add_checklist_items", "check_item",
        "similar_cards", "daily_summary", "add_comment", "my_notifications",
        "mark_notifications_read", "archive_card", "restore_card", "archived_cards"}


def test_mcp_every_tool_explains_itself_and_says_if_it_writes(client):
    c, _, _ = client
    reads = {"my_boards", "show_board", "my_week", "overdue", "find_cards", "show_card",
             "card_history", "what_is_blocking", "suggest_checklist", "similar_cards",
             "daily_summary", "my_notifications", "archived_cards"}
    for t in tools(c):
        assert len(t["description"]) > 30, t["name"]
        assert t["annotations"]["readOnlyHint"] is (t["name"] in reads), t["name"]
        assert t["annotations"]["openWorldHint"] is False


# ---------------------------------------------------------------- MCP: each tool, as the agent reads it
def test_mcp_create_board_and_a_done_list(client):
    c, _, _ = client
    err, out = mcp(c, MEMBER, "create_board", title="Ops")
    assert not err and "Created **Ops**" in out and "You're the owner" in out
    err, out = mcp(c, MEMBER, "add_list", board="Ops", name="Shipped", is_done=True)
    assert not err and "(a done list)" in out


def test_mcp_my_boards_lists_roles_and_counts(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Late", due_date="2026-11-01")
    err, out = mcp(c, VIEWER, "my_boards")
    assert not err and "**Launch** (viewer) · 1 open · 1 overdue" in out


def test_mcp_invite_and_remove_are_owner_only(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Mine", assignee=MEMBER.email)
    err, out = mcp(c, MEMBER, "invite", board="Launch", email="eve@cobra.test")
    assert err and "Not allowed" in out
    err, out = mcp(c, OWNER, "invite", board="Launch", email="eve@cobra.test", role="viewer")
    assert not err and "eve@cobra.test is now a viewer on Launch" in out
    err, out = mcp(c, OWNER, "remove_member", board="Launch", email=MEMBER.email)
    assert not err and "1 card(s) unassigned" in out


def test_mcp_assign_to_me_and_unassign(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Send invoice")
    err, out = mcp(c, MEMBER, "assign_card", card="1", assignee="me", board="Launch")
    assert not err and out.startswith("Assigned #1") and f"@{MEMBER.email}" in out
    err, out = mcp(c, MEMBER, "assign_card", card="1", board="Launch")
    assert not err and out.startswith("Unassigned #1")


def test_mcp_edit_with_a_stale_version_is_a_conflict(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Budget")
    err, out = mcp(c, MEMBER, "edit_card", card="1", board="Launch", expected_version=1,
                   title="Budget v2", due="tomorrow")
    assert not err and "Budget v2" in out and "due 2026-11-11" in out and "(version 2)" in out
    err, out = mcp(c, OWNER, "edit_card", card="1", board="Launch", expected_version=1,
                   title="Mine")
    assert err and "Conflict" in out and "reload" in out
    assert s.card_detail(OWNER, "1", "Launch")["title"] == "Budget v2"


def test_mcp_a_bad_date_is_explained(client):
    c, _, _ = client
    err, out = mcp(c, MEMBER, "add_card", board="Launch", title="x", due="someday")
    assert err and "is not a date" in out and "tomorrow" in out


def test_mcp_find_cards_and_nothing_overdue(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Send invoice", labels=["finance"])
    err, out = mcp(c, VIEWER, "find_cards", query="finance")
    assert not err and "#1 Send invoice" in out and "[Launch › To do]" in out
    err, out = mcp(c, VIEWER, "overdue")
    assert not err and out == "Nothing is overdue. 🎉"
    err, out = mcp(c, VIEWER, "find_cards", query="nothing like this")
    assert out == "No card mentions “nothing like this”."


def test_mcp_show_card_reads_like_the_card(client):
    c, s, bid = client
    a = s.add_card(OWNER, bid, "Ship", description="Go live", assignee=MEMBER.email,
                   due_date="2026-11-01", labels=["launch"])
    b = s.add_card(OWNER, bid, "QA")
    s.add_blocker(OWNER, a["id"], b["id"])
    s.add_checklist_items(OWNER, a["id"], ["Deploy"])
    err, out = mcp(c, VIEWER, "show_card", card="1", board="Launch")
    assert not err
    for bit in ("**#1 Ship** · Launch › To do", "Go live", f"assignee: {MEMBER.email}",
                "due: 2026-11-01 (OVERDUE)", "labels: launch", "Waits on: #2 QA",
                "Checklist 0/1", "Recent history"):
        assert bit in out, bit


def test_mcp_blockers_add_already_and_remove(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Ship")
    s.add_card(OWNER, bid, "QA")
    _, out = mcp(c, MEMBER, "add_blocker", card="1", blocked_by="2", board="Launch")
    assert out == "#1 is now blocked by #2."
    _, out = mcp(c, MEMBER, "add_blocker", card="1", blocked_by="2", board="Launch")
    assert out == "#1 already waits on #2."
    err, out = mcp(c, MEMBER, "add_blocker", card="2", blocked_by="1", board="Launch")
    assert err and "forever" in out
    _, out = mcp(c, MEMBER, "remove_blocker", card="1", blocked_by="2", board="Launch")
    assert out == "#1 no longer waits on #2."
    _, out = mcp(c, VIEWER, "what_is_blocking", card="1", board="Launch")
    assert out == "Nothing is blocking #1 Ship: it can go ahead."


def test_mcp_check_item_ticks_a_step(client):
    c, s, bid = client
    a = s.add_card(OWNER, bid, "Launch page")
    item = s.add_checklist_items(OWNER, a["id"], ["Draft it"])["checklist"][0]
    err, out = mcp(c, MEMBER, "check_item", card="1", item_id=item["id"], board="Launch")
    assert not err and "checklist 1/1" in out and "[x] Draft it" in out
    err, out = mcp(c, VIEWER, "check_item", card="1", item_id=item["id"], board="Launch",
                   done=False)
    assert err and "Not allowed" in out


def test_mcp_similar_and_daily_summary(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Send invoice to Acme")
    s.add_card(OWNER, bid, "Chase unpaid bill", due_date="2026-11-01")
    err, out = mcp(c, MEMBER, "similar_cards", text_query="invoice payment")
    assert not err and "alike" in out and "Send invoice to Acme" in out
    err, out = mcp(c, VIEWER, "daily_summary", board="Launch")
    assert not err and out.startswith("**Launch** · 2026-11-10") and "Briefing for Launch" in out


def test_mcp_my_week_when_nothing_is_mine(client):
    c, _, _ = client
    err, out = mcp(c, VIEWER, "my_week")
    assert not err and out == "Nothing open is assigned to you (today is 2026-11-10)."


def test_mcp_a_stranger_sees_none_of_the_cards(client):
    c, s, bid = client
    s.add_card(OWNER, bid, "Secret plan")
    for tool, args in (("show_card", {"card": "1"}), ("card_history", {"card": "1"}),
                       ("what_is_blocking", {"card": "1"}), ("daily_summary", {"board": "Launch"}),
                       ("move_card", {"card": "1", "list": "Done"})):
        err, out = mcp(c, STRANGER, tool, **args)
        assert err and "Not found" in out, tool
    err, out = mcp(c, STRANGER, "find_cards", query="secret")
    assert not err and "No card" in out


# ---------------------------------------------------------------- the chat text itself
def test_chat_text_for_an_empty_board_and_a_blocked_chain():
    b = {"title": "Ops", "role": "viewer", "can_edit": False,
         "lists": [{"name": "To do", "is_done": False, "cards": []}],
         "members": [{"email": OWNER.email, "role": "owner"}]}
    out = text.board(b)
    assert "(read only)" in out and "- (empty)" in out and f"{OWNER.email} (owner)" in out
    r = {"card": 1, "title": "Ship", "blocked": True, "start_with": [{"number": 3, "title": "Bug"}],
         "chain": [{"number": 2, "title": "QA", "depth": 1, "assignee": None, "list": "Doing",
                    "waits_on": [3]},
                   {"number": 3, "title": "Bug", "depth": 2, "assignee": "bo@cobra.test",
                    "list": "To do", "waits_on": []}]}
    out = text.blocking(r)
    assert "waiting on 2 unfinished cards" in out and "(unassigned) ← waits on #3" in out
    assert "  - #3 Bug [To do] @bo@cobra.test" in out and out.endswith("Start with: #3 Bug")