dmslanes/lanes

Lanes - team task boards with roles, history and an MCP endpoint for Claude and ChatGPT

A remix template · 0 remixes · Remix dmslanes/lanes

tests/test_rules.py

import os
from datetime import datetime, timedelta, timezone

import pytest

from app.application.use_cases import BoardService
from app.domain.model import Conflict, DomainError, Forbidden, NotFound
from app.infrastructure.memory import MemoryRepository

NOW = datetime(2026, 10, 29, 12, 0, tzinfo=timezone.utc)
OWNER, MEMBER, VIEWER, STRANGER = "olga@team.io", "mike@team.io", "vera@team.io", "sam@else.io"


BACKENDS = ["memory"] + (["k3"] if os.environ.get("LANES_TEST_K3") else [])


def _repo(kind):
    if kind == "memory":
        return MemoryRepository()
    from app.infrastructure.k3 import K3Repository  # LANES_TEST_K3=1 K3_BUCKET=<test bucket> DODIL_TOKEN=...
    repo = K3Repository()
    for table in ("activity", "cards", "card_numbers", "lists", "boards", "memberships", "workspaces"):
        repo._x(f"DELETE FROM {table} WHERE TRUE")
    return repo


@pytest.fixture(params=BACKENDS)
def svc(request):
    return BoardService(_repo(request.param), clock=lambda: NOW)


@pytest.fixture
def team(svc):
    ws = svc.create_workspace(OWNER, "Acme")
    svc.invite_member(OWNER, ws.id, MEMBER, "member")
    svc.invite_member(OWNER, ws.id, VIEWER, "viewer")
    board = svc.create_board(OWNER, ws.id, "Ops")
    return ws, board


def test_only_owner_manages_members(svc, team):
    ws, _ = team
    with pytest.raises(Forbidden):
        svc.invite_member(MEMBER, ws.id, "new@team.io", "member")
    with pytest.raises(Forbidden):
        svc.remove_member(VIEWER, ws.id, MEMBER)
    with pytest.raises(DomainError):
        svc.invite_member(OWNER, ws.id, "x@team.io", "owner")
    svc.remove_member(OWNER, ws.id, MEMBER)
    assert [m.email for m in svc.list_members(OWNER, ws.id)] == [OWNER, VIEWER]


def test_viewer_reads_but_cannot_edit(svc, team):
    _, board = team
    card = svc.add_card(MEMBER, board.id, "To do", "Send invoice")
    assert svc.get_board(VIEWER, board.id)["cards"]
    for attempt in (
        lambda: svc.add_card(VIEWER, board.id, "To do", "Nope"),
        lambda: svc.move_card(VIEWER, card.number, "Done"),
        lambda: svc.assign_card(VIEWER, card.number, VIEWER),
        lambda: svc.update_card(VIEWER, card.number, title="x"),
        lambda: svc.delete_card(VIEWER, card.number),
        lambda: svc.add_list(VIEWER, board.id, "Later"),
    ):
        with pytest.raises(Forbidden):
            attempt()


def test_outsider_sees_nothing(svc, team):
    _, board = team
    card = svc.add_card(OWNER, board.id, "To do", "Secret")
    assert svc.list_boards(STRANGER) == []
    assert svc.overdue(STRANGER) == []
    assert svc.search_cards(STRANGER, "secret") == []
    with pytest.raises(NotFound):
        svc.get_board(STRANGER, board.id)
    with pytest.raises(NotFound):
        svc.get_card(STRANGER, card.number)
    with pytest.raises(NotFound):
        svc.move_card(STRANGER, card.number, "Done")


def test_move_card_by_list_name_and_history(svc, team):
    _, board = team
    card = svc.add_card(MEMBER, board.id, "doing", "Send invoice")
    moved = svc.move_card(MEMBER, card.number, "Done")
    assert svc.repo.get_list(moved.list_id).name == "Done"
    history = svc.card_history(OWNER, card.number)
    assert [h.action for h in history] == ["created", "moved"]
    assert history[1].actor == MEMBER and history[1].changes["list"] == ["Doing", "Done"]


def test_move_card_to_position_keeps_order(svc, team):
    _, board = team
    a, b, c = (svc.add_card(OWNER, board.id, "To do", t) for t in "ABC")
    svc.move_card(OWNER, c.number, "To do", position=0)
    todo = svc.get_board(OWNER, board.id)
    first_list = todo["lists"][0].id
    assert [x.title for x in todo["cards"][first_list]] == ["C", "A", "B"]


def test_assignee_must_be_member(svc, team):
    _, board = team
    card = svc.add_card(OWNER, board.id, "To do", "Pay rent")
    with pytest.raises(DomainError):
        svc.assign_card(OWNER, card.number, STRANGER)
    svc.assign_card(OWNER, card.number, MEMBER)
    assert svc.card_history(OWNER, card.number)[-1].changes == {"assignee": [None, MEMBER]}


def test_my_plate_this_week_and_overdue(svc, team):
    _, board = team
    today = NOW.date()
    late = svc.add_card(OWNER, board.id, "Doing", "Late one", assignee=MEMBER, due_date=today - timedelta(days=2))
    soon = svc.add_card(OWNER, board.id, "To do", "Soon", assignee=MEMBER, due_date=today + timedelta(days=3))
    svc.add_card(OWNER, board.id, "To do", "Later", assignee=MEMBER, due_date=today + timedelta(days=30))
    done = svc.add_card(OWNER, board.id, "Done", "Old done", assignee=MEMBER, due_date=today - timedelta(days=9))
    assert [c.title for c in svc.my_cards(MEMBER, due_within_days=7)] == ["Late one", "Soon"]
    assert [c.number for c in svc.overdue(VIEWER)] == [late.number]
    assert done.number not in [c.number for c in svc.overdue(OWNER, board.id)]
    assert soon.number in [c.number for c in svc.my_cards(MEMBER)]


def test_stale_edit_is_refused(svc, team):
    _, board = team
    card = svc.add_card(OWNER, board.id, "To do", "Draft")
    svc.update_card(MEMBER, card.number, title="Draft v2")
    with pytest.raises(Conflict):
        svc.update_card(OWNER, card.number, title="Draft v3", expected_version=card.version)


def test_find_board_by_name(svc, team):
    _, board = team
    assert svc.find_board(MEMBER, "the ops board".removeprefix("the ")).id == board.id
    with pytest.raises(NotFound):
        svc.find_board(STRANGER, "Ops")


def test_card_numbers_are_unique(svc, team):
    _, board = team
    numbers = {svc.add_card(OWNER, board.id, "To do", f"c{i}").number for i in range(20)}
    assert len(numbers) == 20