freelancer/flowboard

Boards starter for the Dodil Boards Challenge. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix freelancer/flowboard

tests/test_boards.py


"""Test board rules through the use cases and web/MCP doors.

The repository and AI are in-memory fakes; no Dodil account is required.
"""

import json

import pytest
from fastapi.testclient import TestClient

from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid, Person
from boards.main import create_app


OWNER, MEMBER, VIEWER, STRANGER = (
    Person(email)
    for email in ("ana@x.io", "bo@x.io", "cy@x.io", "dee@x.io")
)


class MemoryBoards:
    """In-memory repository for isolated tests."""

    def __init__(self):
        self.boards = {}
        self.cards = {}
        self.events = []

    def get(self, board_id):
        return self.boards.get(board_id)

    def boards_for(self, email):
        return [
            board
            for board in self.boards.values()
            if board.owner_email == email or email in board.members
        ]

    def save_board(self, board):
        self.boards[board.id] = board

    def get_card(self, card_id):
        return self.cards.get(card_id)

    def cards_on(self, board_id):
        return [
            card
            for card in self.cards.values()
            if card.board_id == board_id
        ]

    def save_card(self, card):
        self.cards[card.id] = card

    def log(self, board_id, card_id, actor, action):
        self.events.append((board_id, card_id, actor, action))

    def activity_for(self, board_id, limit=100):
        """Return recent events for one board, newest first."""
        events = [
            {
                "id": str(index),
                "board_id": event[0],
                "card_id": event[1],
                "actor": event[2],
                "action": event[3],
                "event_at": None,
            }
            for index, event in enumerate(self.events, start=1)
            if event[0] == board_id
        ]
        return list(reversed(events))[:limit]


class FakeAI:
    """Predictable AI fake for checklist tests."""

    def checklist(self, title):
        return ["step one", "step two"]


@pytest.fixture
def svc():
    """Create a board with one member and one viewer."""
    service = Boards(MemoryBoards(), FakeAI())
    board = service.create_board(OWNER, "Launch")
    service.add_member(OWNER, board["id"], MEMBER.email, "member")
    service.add_member(OWNER, board["id"], VIEWER.email, "viewer")
    return service, board["id"]


def test_member_adds_and_moves_cards_and_it_is_logged(svc):
    service, board_id = svc

    card = service.add_card(MEMBER, board_id, "Send invoice")
    moved = service.move_card(MEMBER, card["id"], "Done")

    assert moved["list_name"] == "Done"
    assert any(
        "moved from To do to Done" in event[3]
        for event in service.repo.events
        if event[1] == card["id"]
    )


def test_viewer_cannot_edit(svc):
    service, board_id = svc

    with pytest.raises(Forbidden):
        service.add_card(VIEWER, board_id, "nope")


def test_only_owner_manages_members(svc):
    service, board_id = svc

    with pytest.raises(Forbidden):
        service.add_member(MEMBER, board_id, "eve@x.io", "member")


def test_stranger_cannot_see_board(svc):
    service, board_id = svc

    with pytest.raises(NotFound):
        service.show_board(STRANGER, board_id)

    assert service.my_boards(STRANGER) == []


def test_unknown_list_is_refused(svc):
    service, board_id = svc
    card = service.add_card(OWNER, board_id, "x")

    with pytest.raises(Invalid):
        service.move_card(OWNER, card["id"], "Someday")


def _h(person):
    """Build test identity headers for the identity door."""
    return {
        "X-Dodil-User": json.dumps(
            {"sub": person.email, "email": person.email}
        )
    }


def test_web_and_mcp_doors_share_the_rules(svc):
    service, board_id = svc

    with TestClient(create_app(service)) as client:
        assert client.get("/api/boards").status_code == 401

        response = client.get("/api/boards", headers=_h(VIEWER))
        assert response.status_code == 200
        assert response.json()[0]["role"] == "viewer"

        response = client.post(
            f"/api/boards/{board_id}/cards",
            json={"title": "t"},
            headers=_h(VIEWER),
        )
        assert response.status_code == 403

        # Unauthenticated MCP access must be rejected.
        response = client.post(
            "/mcp",
            json={
                "jsonrpc": "2.0",
                "id": 1,
                "method": "tools/list",
            },
        )
        assert response.status_code == 401
        assert "resource_metadata" in response.headers["www-authenticate"]

        mcp_headers = {
            **_h(MEMBER),
            "accept": "application/json, text/event-stream",
        }

        initialize = {
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {
                "protocolVersion": "2025-06-18",
                "capabilities": {},
                "clientInfo": {"name": "t", "version": "0"},
            },
        }

        response = client.post(
            "/mcp",
            json=initialize,
            headers=mcp_headers,
        )
        assert response.status_code == 200

        # Create a card through MCP.
        add_call = {
            "jsonrpc": "2.0",
            "id": 2,
            "method": "tools/call",
            "params": {
                "name": "add_card",
                "arguments": {
                    "board_id": board_id,
                    "title": "via mcp",
                },
            },
        }

        response = client.post(
            "/mcp",
            json=add_call,
            headers=mcp_headers,
        )
        output = response.json()
        assert not output["result"].get("isError"), output

        created_card = next(
            card
            for card in service.repo.cards.values()
            if card.title == "via mcp"
        )

        # Update the card through MCP.
        update_call = {
            "jsonrpc": "2.0",
            "id": 3,
            "method": "tools/call",
            "params": {
                "name": "update_card",
                "arguments": {
                    "card_id": created_card.id,
                    "description": "Updated through MCP",
                    "labels": ["mcp", "test"],
                },
            },
        }

        response = client.post(
            "/mcp",
            json=update_call,
            headers=mcp_headers,
        )
        output = response.json()
        assert not output["result"].get("isError"), output
        assert created_card.description == "Updated through MCP"
        assert created_card.labels == ["mcp", "test"]

        # Retrieve activity history through MCP.
        history_call = {
            "jsonrpc": "2.0",
            "id": 4,
            "method": "tools/call",
            "params": {
                "name": "activity_history",
                "arguments": {"board_id": board_id, "limit": 20},
            },
        }

        response = client.post(
            "/mcp",
            json=history_call,
            headers=mcp_headers,
        )
        output = response.json()
        assert not output["result"].get("isError"), output

        # Verify the tool call did not return an MCP error.
        assert "result" in output


def test_update_card_details_and_log_changes(svc):
    service, board_id = svc
    card = service.add_card(MEMBER, board_id, "Prepare report")

    updated = service.update_card(
        MEMBER,
        card["id"],
        title="Prepare final report",
        description="Review and submit",
        assignee="ANA@X.IO",
        due_date="2026-11-15",
        labels=["urgent", "research"],
    )

    assert updated["title"] == "Prepare final report"
    assert updated["description"] == "Review and submit"
    assert updated["assignee"] == "ana@x.io"
    assert updated["due_date"] == "2026-11-15"
    assert updated["labels"] == ["urgent", "research"]

    update_events = [
        event
        for event in service.repo.events
        if event[1] == card["id"]
        and "updated card fields:" in event[3]
    ]

    assert update_events
    assert update_events[-1][2] == MEMBER.email

    for field_name in (
        "title",
        "description",
        "assignee",
        "due_date",
        "labels",
    ):
        assert field_name in update_events[-1][3]


def test_update_card_can_clear_assignee_and_due_date(svc):
    service, board_id = svc
    card = service.add_card(MEMBER, board_id, "Prepare report")

    service.update_card(
        MEMBER,
        card["id"],
        assignee="ana@x.io",
        due_date="2026-11-15",
    )

    updated = service.update_card(
        MEMBER,
        card["id"],
        assignee="",
        due_date="",
    )

    assert updated["assignee"] is None
    assert updated["due_date"] is None


def test_viewer_cannot_update_card(svc):
    service, board_id = svc
    card = service.add_card(MEMBER, board_id, "Restricted edit")

    with pytest.raises(Forbidden):
        service.update_card(
            VIEWER,
            card["id"],
            title="Unauthorized change",
        )

    assert service.repo.cards[card["id"]].title == "Restricted edit"


def test_update_card_rejects_invalid_due_date(svc):
    service, board_id = svc
    card = service.add_card(OWNER, board_id, "Date validation")

    with pytest.raises(Invalid, match="YYYY-MM-DD"):
        service.update_card(
            OWNER,
            card["id"],
            due_date="15/11/2026",
        )


def test_update_card_rejects_blank_title(svc):
    service, board_id = svc
    card = service.add_card(OWNER, board_id, "Keep valid title")

    with pytest.raises(Invalid, match="title"):
        service.update_card(
            OWNER,
            card["id"],
            title="   ",
        )

    assert service.repo.cards[card["id"]].title == "Keep valid title"


def test_activity_history_returns_recent_events(svc):
    service, board_id = svc

    service.add_card(MEMBER, board_id, "First task")
    service.add_card(OWNER, board_id, "Second task")

    result = service.activity_history(OWNER, board_id)

    assert result["board_id"] == board_id
    assert result["board_title"] == "Launch"
    assert result["activity"]
    assert result["activity"][0]["actor"] == OWNER.email
    assert result["activity"][0]["action"] == "added card 'Second task'"


def test_stranger_cannot_read_activity_history(svc):
    service, board_id = svc
    service.add_card(OWNER, board_id, "Private task")

    with pytest.raises(NotFound):
        service.activity_history(STRANGER, board_id)


def test_activity_history_validates_limit(svc):
    service, board_id = svc

    with pytest.raises(Invalid, match="between 1 and 500"):
        service.activity_history(OWNER, board_id, 0)

    with pytest.raises(Invalid, match="between 1 and 500"):
        service.activity_history(OWNER, board_id, 501)


def test_web_activity_endpoint_requires_authentication_and_board_access(svc):
    service, board_id = svc
    service.add_card(OWNER, board_id, "History test")

    with TestClient(create_app(service)) as client:
        assert client.get(
            f"/api/boards/{board_id}/activity"
        ).status_code == 401

        response = client.get(
            f"/api/boards/{board_id}/activity",
            headers=_h(OWNER),
        )
        assert response.status_code == 200
        assert response.json()["board_id"] == board_id
        assert response.json()["activity"]

        response = client.get(
            f"/api/boards/{board_id}/activity",
            headers=_h(STRANGER),
        )
        assert response.status_code == 404