rgboards/rg-boards

Lanes, team task boards you run from the web or from Claude/ChatGPT (MCP). FastAPI + DataK3 + appid + Dodil Models.

A remix template · 0 remixes · Remix rgboards/rg-boards

tests/test_boards.py

"""The board's rules, through the use cases and both doors. No Dodil account needed:
the repository and the AI are in-memory fakes."""
import json
from datetime import date

import pytest
from fastapi.testclient import TestClient

from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Conflict, Forbidden, Invalid, Person
from boards.infrastructure.memory import MemoryBoards
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"))
TODAY = date(2026, 11, 2)


class FakeAI:
    def checklist(self, title, description=""):
        return ["step one", "step two"]

    def summary(self, board_title, facts):
        return f"summary of {board_title}"

    def embed(self, texts):
        # a toy embedding: which of a few words the text mentions
        vocab = ["invoice", "payment", "launch", "design", "bug"]
        return [[1.0 if w in t.lower() else 0.0 for w in vocab] + [0.01] for t in texts]


@pytest.fixture
def svc():
    s = Boards(MemoryBoards(), FakeAI(), today=lambda: TODAY)
    b = s.create_board(OWNER, "Ops")
    s.add_member(OWNER, b["id"], MEMBER.email, "member")
    s.add_member(OWNER, b["id"], VIEWER.email, "viewer")
    return s, b["id"]


# ---------------------------------------------------------------- roles and visibility
def test_member_adds_and_moves_cards_and_it_is_logged(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Send invoice")
    assert card["number"] == 1 and card["list_name"] == "To do"
    moved = s.move_card(MEMBER, card["id"], "Done")
    assert moved["list_name"] == "Done"
    hist = s.card_history(MEMBER, card["id"])
    assert hist[0]["field"] == "list" and hist[0]["old"] == "To do" and hist[0]["new"] == "Done"
    assert hist[0]["actor"] == MEMBER.email and hist[0]["at"]


def test_viewer_can_read_but_never_edit(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan")
    assert s.show_board(VIEWER, bid)["role"] == "viewer"
    for attempt in (lambda: s.add_card(VIEWER, bid, "nope"),
                    lambda: s.move_card(VIEWER, card["id"], "Done"),
                    lambda: s.update_card(VIEWER, card["id"], title="x"),
                    lambda: s.assign_card(VIEWER, card["id"], "me"),
                    lambda: s.add_list(VIEWER, bid, "Later"),
                    lambda: s.make_checklist(VIEWER, card["id"]),
                    lambda: s.archive_card(VIEWER, card["id"])):
        with pytest.raises(Forbidden):
            attempt()


def test_only_owner_manages_members(svc):
    s, bid = svc
    with pytest.raises(Forbidden):
        s.add_member(MEMBER, bid, "eve@x.io", "member")
    with pytest.raises(Forbidden):
        s.remove_member(MEMBER, bid, VIEWER.email)
    with pytest.raises(Invalid):
        s.add_member(OWNER, bid, "eve@x.io", "owner")
    s.remove_member(OWNER, bid, VIEWER.email)
    with pytest.raises(NotFound):
        s.show_board(VIEWER, bid)


def test_stranger_sees_nothing(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Secret", due_date="2026-10-01")
    with pytest.raises(NotFound):
        s.show_board(STRANGER, bid)
    with pytest.raises(NotFound):
        s.card_details(STRANGER, card["id"])
    with pytest.raises(NotFound):
        s.move_card(STRANGER, card["id"], "Done")
    assert s.my_boards(STRANGER) == []
    assert s.overdue(STRANGER) == []
    assert s.search_cards(STRANGER, "secret") == []
    assert s.find_similar(STRANGER, text="secret") == []


def test_removed_member_is_unassigned(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Task", assignee=MEMBER.email)
    s.remove_member(OWNER, bid, MEMBER.email)
    assert s.card_details(OWNER, card["id"])["assignee"] is None


# ---------------------------------------------------------------- cards
def test_card_fields_and_history(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Write post", description="draft", assignee="me",
                      due_date="2026-11-05", labels=["Content", "blog", "content"])
    assert card["assignee"] == MEMBER.email and card["labels"] == ["content", "blog"]
    s.update_card(OWNER, card["id"], title="Write launch post", due_date="2026-11-09")
    fields = {h["field"]: (h["old"], h["new"]) for h in s.card_history(OWNER, card["id"])}
    assert fields["title"] == ("Write post", "Write launch post")
    assert fields["due_date"] == ("2026-11-05", "2026-11-09")


def test_assignee_must_be_on_the_board(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Task")
    with pytest.raises(Invalid):
        s.assign_card(OWNER, card["id"], STRANGER.email)
    with pytest.raises(Invalid):
        s.assign_card(OWNER, card["id"], VIEWER.email)
    assert s.assign_card(OWNER, card["id"], MEMBER.email)["assignee"] == MEMBER.email


def test_cards_by_number_and_board_title(svc):
    s, bid = svc
    s.add_card(OWNER, "Ops", "First")
    second = s.add_card(OWNER, "the ops board", "Second", list_name="doing")
    assert second["number"] == 2 and second["list_name"] == "Doing"
    assert s.move_card(MEMBER, "2", "done")["list_name"] == "Done"
    other = s.create_board(MEMBER, "Home")
    s.add_card(MEMBER, other["id"], "Elsewhere")
    with pytest.raises(Invalid):        # card #1 is on two of MEMBER's boards
        s.move_card(MEMBER, 1, "Done")
    assert s.move_card(MEMBER, 1, "Done", board_ref="Home")["board"] == "Home"


def test_unknown_list_is_refused(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x")
    with pytest.raises(Invalid):
        s.move_card(OWNER, card["id"], "Someday")


def test_drag_and_drop_order(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "A")
    s.add_card(OWNER, bid, "B")
    c = s.add_card(OWNER, bid, "C", list_name="Doing")
    s.move_card(OWNER, c["id"], "To do", before_card=a["id"])
    titles = [x["title"] for x in s.show_board(OWNER, bid)["lists"][0]["cards"]]
    assert titles == ["C", "A", "B"]


def test_stale_edit_is_refused(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x")
    s.update_card(MEMBER, card["id"], title="y", expected_version=card["version"])
    with pytest.raises(Conflict):
        s.update_card(OWNER, card["id"], title="z", expected_version=card["version"])


# ---------------------------------------------------------------- questions people ask
def test_my_plate_this_week_and_overdue(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "Late", assignee=MEMBER.email, due_date="2026-10-30")
    s.add_card(OWNER, bid, "Soon", assignee=MEMBER.email, due_date="2026-11-06")
    s.add_card(OWNER, bid, "Later", assignee=MEMBER.email, due_date="2026-12-20")
    done = s.add_card(OWNER, bid, "Finished", assignee=MEMBER.email, due_date="2026-10-01")
    s.move_card(OWNER, done["id"], "Done")
    week = [c["title"] for c in s.my_cards(MEMBER, days=7)]
    assert week == ["Late", "Soon"]
    assert [c["title"] for c in s.overdue(VIEWER)] == ["Late"]
    assert s.my_boards(OWNER)[0]["overdue"] == 1


def test_dependencies_and_what_blocks(svc):
    s, bid = svc
    launch = s.add_card(OWNER, bid, "Launch")
    site = s.add_card(OWNER, bid, "Website")
    copy = s.add_card(OWNER, bid, "Copy")
    s.add_blocker(OWNER, launch["id"], site["number"])
    s.add_blocker(OWNER, site["id"], copy["number"])
    with pytest.raises(Invalid):
        s.add_blocker(OWNER, copy["id"], launch["number"])  # would be a loop
    r = s.what_blocks(MEMBER, launch["id"])
    assert {c["title"] for c in r["open_blockers"]} == {"Website", "Copy"}
    assert [c["title"] for c in r["start_here"]] == ["Copy"]
    s.move_card(OWNER, copy["id"], "Done")
    assert [c["title"] for c in s.what_blocks(OWNER, launch["id"])["start_here"]] == ["Website"]
    assert s.show_board(OWNER, bid)["lists"][0]["cards"][0]["blocked_by"][0]["title"] == "Website"


# ---------------------------------------------------------------- AI extras
def test_checklist_similar_and_summary(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Send invoice to Acme")
    s.add_card(OWNER, bid, "Chase invoice payment")
    s.add_card(OWNER, bid, "Fix login bug")
    assert s.suggest_checklist(VIEWER, card["id"])["suggested_checklist"] == ["step one",
                                                                              "step two"]
    items = s.make_checklist(MEMBER, card["id"])["checklist"]
    assert [i["text"] for i in items] == ["step one", "step two"]
    s.tick_item(MEMBER, card["id"], items[0]["id"], True)
    assert s.card_details(OWNER, card["id"])["checklist"][0]["done"] is True
    sim = s.find_similar(OWNER, card_ref=card["id"])
    assert sim[0]["title"] == "Chase invoice payment"
    assert s.daily_summary(VIEWER, bid)["summary"] == "summary of Ops"


# ---------------------------------------------------------------- both doors
def _h(p):
    return {"X-Dodil-User": json.dumps({"sub": p.email, "email": p.email})}


def _mcp(c, person, name, args, i=2):
    h = {**_h(person), "accept": "application/json, text/event-stream"}
    init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
        "protocolVersion": "2025-06-18", "capabilities": {},
        "clientInfo": {"name": "t", "version": "0"}}}
    assert c.post("/mcp", json=init, headers=h).status_code == 200
    call = {"jsonrpc": "2.0", "id": i, "method": "tools/call",
            "params": {"name": name, "arguments": args}}
    return c.post("/mcp", json=call, headers=h).json()["result"]


def test_web_and_mcp_doors_share_the_rules(svc):
    s, bid = svc
    with TestClient(create_app(s)) as c:
        assert c.get("/api/boards").status_code == 401
        assert c.get("/api/boards", headers=_h(VIEWER)).json()[0]["role"] == "viewer"
        assert c.post(f"/api/boards/{bid}/cards", json={"title": "t"},
                      headers=_h(VIEWER)).status_code == 403
        assert c.get(f"/api/boards/{bid}", headers=_h(STRANGER)).status_code == 404
        r = c.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
        assert r.status_code == 401 and "resource_metadata" in r.headers["www-authenticate"]

        out = _mcp(c, MEMBER, "add_card", {"board": "Ops", "title": "Send invoice",
                                            "list_name": "Doing"})
        assert not out.get("isError"), out
        num = next(x for x in s.repo.cards.values() if x.title == "Send invoice").number
        out = _mcp(c, MEMBER, "move_card", {"card": num, "list_name": "Done"})
        assert not out.get("isError"), out
        out = _mcp(c, VIEWER, "move_card", {"card": num, "list_name": "To do"})
        assert out.get("isError") and "viewers" in json.dumps(out)
        out = _mcp(c, STRANGER, "show_board", {"board": bid})
        assert out.get("isError") and "no such board" in json.dumps(out)
        out = _mcp(c, MEMBER, "overdue", {})
        assert not out.get("isError"), out

        page = c.get("/", headers=_h(MEMBER))
        assert page.status_code == 200 and "Lanes" in page.text
        landing = c.get("/", follow_redirects=False)   # signed out: the landing page, no data
        assert landing.status_code == 200 and "/.dodil/auth/login" in landing.text
        assert "demo.owner@" not in landing.text            # demo logins only on the demo pool
        assert c.get("/api/boards").status_code == 401
        assert c.get("/healthz").json() == {"ok": True}


def test_slow_summary_answers_writing_then_ready(svc):
    import threading
    s, bid = svc
    gate = threading.Event()

    class SlowAI(FakeAI):
        def summary(self, board_title, facts):
            gate.wait(5)
            return "late summary"
    s.assistant, s.ai_wait = SlowAI(), 0.05
    assert s.daily_summary(MEMBER, bid)["status"] == "writing"
    gate.set()
    s._writing[f"{bid}:{TODAY.isoformat()}"].result(5)
    s.ai_wait = 1
    r = s.daily_summary(VIEWER, bid)
    assert r["status"] == "ready" and r["summary"] == "late summary"


def test_landing_lists_demo_logins_only_on_the_demo_pool(monkeypatch):
    from boards.doors import web
    monkeypatch.setenv("DODIL_USER_POOL", "acme/lanes-users")
    monkeypatch.setenv("DEMO_POOL", "rgboards/rg-boards-users")
    assert "demo.viewer@" not in web.landing_page()
    monkeypatch.setenv("DODIL_USER_POOL", "rgboards/rg-boards-users")
    page = web.landing_page()
    assert all(f"demo.{r}@lanes-demo.dev" in page for r in ("owner", "member", "viewer"))


def test_new_lists_go_before_done(svc):
    s, bid = svc
    s.add_list(OWNER, bid, "Review")
    lists = s.show_board(OWNER, bid)["lists"]
    assert [l["name"] for l in lists][-2:] == ["Review", "Done"]


def test_demo_seed_tells_the_role_story():
    import sys
    from pathlib import Path
    sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
    from seed_demo import seed
    s = Boards(MemoryBoards(), FakeAI(), today=lambda: TODAY)
    o, m, v = (f"demo.{r}@lanes-demo.dev" for r in ("owner", "member", "viewer"))
    ids = seed(s, o, m, v, today=TODAY)
    roles = {b["title"]: b["role"] for b in s.my_boards(Person(v))}
    assert roles == {"Product Launch": "viewer", "Website Refresh": "viewer"}   # not on Ops
    assert {b["title"]: b["role"] for b in s.my_boards(Person(m))}["Website Refresh"] == "owner"
    board = s.show_board(Person(o), ids["Product Launch"])
    assert [l["name"] for l in board["lists"]] == ["Backlog", "To do", "Doing", "Review", "Done"]
    assert sum(len(l["cards"]) for l in board["lists"]) == 16
    assert s.overdue(Person(v))                       # a few cards are overdue
    launch = next(c for l in board["lists"] for c in l["cards"] if c["title"].startswith("Launch"))
    chain = s.what_blocks(Person(v), launch["id"])
    assert [c["title"] for c in chain["start_here"]] and chain["blocked"]
    with pytest.raises(Forbidden):
        s.move_card(Person(v), launch["id"], "Done")