akif/arclet-boards

Arclet Boards — private Kanban workspaces with AppID, DataK3, shared web and MCP actions.

A remix template · 1 remixes · Remix akif/arclet-boards

tests/test_application.py

from copy import deepcopy
from datetime import date
from uuid import uuid4

import pytest

from app.domain import model as m
from tests.support import OWNER, MEMBER, VIEWER, OUTSIDER, call, fixture_board


@pytest.fixture
def fixture():
    return fixture_board()


def add(service, board, person=OWNER, title="Send launch notes", **kwargs):
    return call(service, "add_card", person, board_id=board["id"], list_id=board["lists"][0]["id"],
                title=title, **kwargs)


def test_owner_creates_workspace_board_and_ordered_lists(fixture):
    service, workspace, board = fixture
    assert workspace["owner_sub"] == OWNER.sub
    assert board["role"] == "owner"
    assert [(row["title"], row["position"], row["is_done"]) for row in board["lists"]] == [
        ("To do", 0, False), ("In progress", 1, False), ("Done", 2, True)]


def test_member_can_create_and_edit_card(fixture):
    service, _, board = fixture
    card = add(service, board, MEMBER)
    edited = call(service, "update_card", MEMBER, card_id=card["id"], title="Updated",
        description="A clear description", assignee=MEMBER.email, due_date="2026-12-01",
        labels=[{"name": "Design", "color": "indigo"}], version=card["version"])
    assert (edited["title"], edited["assignee"], edited["due_date"]) == ("Updated", MEMBER.email, "2026-12-01")
    assert call(service, "get_card", MEMBER, card_id=card["id"])["labels"] == [{"name": "Design", "color": "indigo"}]


@pytest.mark.parametrize("action,extra", [
    ("create_list", {"title": "Review"}),
    ("update_board", {"title": "Changed", "version": 3}),
    ("invite_member", {"email": "new@arclet.test", "role": "member"}),
    ("change_member_role", {"email": MEMBER.email, "role": "viewer"}),
    ("remove_member", {"email": MEMBER.email}),
])
def test_viewer_is_read_only_for_board_mutations(fixture, action, extra):
    service, _, board = fixture
    with pytest.raises(m.Forbidden):
        call(service, action, VIEWER, board_id=board["id"], **extra)


def test_viewer_reads_but_cannot_create_card(fixture):
    service, _, board = fixture
    assert call(service, "get_board", VIEWER, board_id=board["id"])["role"] == "viewer"
    with pytest.raises(m.Forbidden):
        add(service, board, VIEWER)


def test_member_cannot_manage_members(fixture):
    service, _, board = fixture
    with pytest.raises(m.Forbidden):
        call(service, "invite_member", MEMBER, board_id=board["id"], email="new@arclet.test")


def test_outsider_and_missing_board_are_indistinguishable(fixture):
    service, _, board = fixture
    messages = []
    for bid in (board["id"], "missing"):
        with pytest.raises(m.NotFound) as error:
            call(service, "get_board", OUTSIDER, board_id=bid)
        messages.append(str(error.value))
    assert messages[0] == messages[1]
    assert call(service, "list_boards", OUTSIDER) == []
    assert call(service, "list_workspaces", OUTSIDER) == []


def test_workspace_membership_does_not_disclose_private_boards(fixture):
    service, workspace, board = fixture
    call(service, "invite_workspace_member", workspace_id=workspace["id"], email=OUTSIDER.email)
    assert call(service, "list_workspaces", OUTSIDER)
    assert call(service, "list_boards", OUTSIDER) == []
    with pytest.raises(m.NotFound):
        call(service, "get_board", OUTSIDER, board_id=board["id"])


def test_workspace_viewer_cannot_create_board(fixture):
    service, workspace, _ = fixture
    call(service, "invite_workspace_member", workspace_id=workspace["id"], email=OUTSIDER.email, role="viewer")
    with pytest.raises(m.Forbidden):
        call(service, "create_board", OUTSIDER, workspace_id=workspace["id"], title="No")


def test_only_workspace_owner_can_invite_workspace_members(fixture):
    service, workspace, _ = fixture
    call(service, "invite_workspace_member", workspace_id=workspace["id"], email=MEMBER.email)
    with pytest.raises(m.Forbidden):
        call(service, "invite_workspace_member", MEMBER, workspace_id=workspace["id"], email=OUTSIDER.email)


def test_board_member_can_see_shared_workspace_but_not_manage_it(fixture):
    service, workspace, _ = fixture
    result = call(service, "get_workspace", MEMBER, workspace_id=workspace["id"])
    assert result["role"] == "guest"
    assert "members" not in result


def test_owner_updates_board_and_stale_version_conflicts(fixture):
    service, _, board = fixture
    updated = call(service, "update_board", board_id=board["id"], title="Release", version=board["version"])
    assert updated["title"] == "Release"
    with pytest.raises(m.Conflict):
        call(service, "update_board", board_id=board["id"], title="Old edit", version=board["version"])


def test_card_creation_is_logged_with_actor_and_complete_initial_values(fixture):
    service, _, board = fixture
    card = add(service, board, MEMBER, description="Context", assignee=OWNER.email)
    activity = call(service, "get_card_activity", VIEWER, card_id=card["id"])
    assert len(activity) == 1
    assert activity[0]["actor_sub"] == MEMBER.sub
    assert activity[0]["changes"]["card"]["after"]["description"] == "Context"


def test_cross_list_move_persists_contiguous_order_and_history(fixture):
    service, _, board = fixture
    cards = [add(service, board, title=f"Task {index}") for index in range(3)]
    current = call(service, "get_board", board_id=board["id"])
    moved = call(service, "move_card", MEMBER, card_id=cards[1]["id"], list_id=board["lists"][1]["id"],
                 index=0, version=1, board_version=current["version"])
    fresh = call(service, "get_board", board_id=board["id"])
    origin = [card for card in fresh["cards"] if card["list_id"] == board["lists"][0]["id"]]
    assert [(card["title"], card["position"]) for card in origin] == [("Task 0", 0), ("Task 2", 1)]
    assert moved["list_id"] == board["lists"][1]["id"]
    assert call(service, "get_card_activity", card_id=cards[2]["id"])[-1]["action"] == "reordered"


def test_same_list_card_reordering(fixture):
    service, _, board = fixture
    cards = [add(service, board, title=f"Task {index}") for index in range(3)]
    current = call(service, "get_board", board_id=board["id"])
    call(service, "move_card", card_id=cards[2]["id"], list_id=cards[2]["list_id"], index=0,
         version=1, board_version=current["version"])
    result = call(service, "get_board", board_id=board["id"])
    assert [card["title"] for card in result["cards"]] == ["Task 2", "Task 0", "Task 1"]


def test_cross_board_move_is_404_and_does_not_mutate(fixture):
    service, workspace, board = fixture
    card = add(service, board)
    other = call(service, "create_board", workspace_id=workspace["id"], title="Other")
    other = call(service, "get_board", board_id=other["id"])
    current = call(service, "get_board", board_id=board["id"])
    with pytest.raises(m.NotFound):
        call(service, "move_card", card_id=card["id"], list_id=other["lists"][0]["id"], index=0,
             version=1, board_version=current["version"])
    assert call(service, "get_card", card_id=card["id"])["list_id"] == card["list_id"]


def test_move_refuses_invalid_index_and_stale_board(fixture):
    service, _, board = fixture
    card = add(service, board)
    with pytest.raises(m.Conflict):
        call(service, "move_card", card_id=card["id"], list_id=card["list_id"], index=0,
             version=1, board_version=board["version"])
    current = call(service, "get_board", board_id=board["id"])
    with pytest.raises(m.Invalid):
        call(service, "move_card", card_id=card["id"], list_id=card["list_id"], index=7,
             version=1, board_version=current["version"])


def test_lists_can_be_created_renamed_and_reordered(fixture):
    service, _, board = fixture
    created = call(service, "create_list", MEMBER, board_id=board["id"], title="Review")
    changed = call(service, "update_list", MEMBER, list_id=created["id"], title="Reviewed", is_done=True, version=1)
    assert changed["is_done"] is True
    fresh = call(service, "get_board", board_id=board["id"])
    ids = [row["id"] for row in reversed(fresh["lists"])]
    call(service, "reorder_lists", MEMBER, board_id=board["id"], list_ids=ids, version=fresh["version"])
    assert [row["id"] for row in call(service, "get_board", board_id=board["id"])["lists"]] == ids


def test_list_order_requires_exact_board_list_set(fixture):
    service, _, board = fixture
    with pytest.raises(m.Invalid):
        call(service, "reorder_lists", board_id=board["id"], list_ids=[board["lists"][0]["id"]]*3,
             version=board["version"])


def test_assignment_must_be_to_board_member(fixture):
    service, _, board = fixture
    card = add(service, board)
    with pytest.raises(m.Invalid):
        call(service, "assign_card", card_id=card["id"], assignee=OUTSIDER.email, version=1)
    assigned = call(service, "assign_card", card_id=card["id"], assignee=VIEWER.email, version=1)
    assert assigned["assignee"] == VIEWER.email


def test_due_date_validation_is_atomic(fixture):
    service, _, board = fixture
    card = add(service, board)
    with pytest.raises(m.Invalid):
        call(service, "set_due_date", card_id=card["id"], due_date="2026-02-31", version=1)
    assert call(service, "get_card", card_id=card["id"])["version"] == 1


def test_overdue_uses_utc_day_and_excludes_completed_lists():
    assert m.overdue({"due_date": "2026-10-30"}, False, date(2026,10,31))
    assert not m.overdue({"due_date": "2026-10-31"}, False, date(2026,10,31))
    assert not m.overdue({"due_date": "2026-10-30"}, True, date(2026,10,31))
    assert not m.overdue({"due_date": None}, False, date(2026,10,31))


def test_task_and_overdue_queries_do_not_leak_private_boards(fixture):
    service, _, board = fixture
    card = add(service, board, assignee=MEMBER.email, due_date="2020-01-01")
    assert call(service, "list_my_cards", MEMBER)[0]["id"] == card["id"]
    assert call(service, "list_overdue", MEMBER)[0]["id"] == card["id"]
    assert call(service, "list_overdue", OUTSIDER) == []


def test_stale_card_edit_preserves_newer_values(fixture):
    service, _, board = fixture
    card = add(service, board)
    call(service, "set_due_date", card_id=card["id"], due_date="2026-12-01", version=1)
    with pytest.raises(m.Conflict):
        call(service, "assign_card", card_id=card["id"], assignee=MEMBER.email, version=1)
    fresh = call(service, "get_card", card_id=card["id"])
    assert fresh["due_date"] == "2026-12-01" and fresh["assignee"] is None


def test_all_field_changes_have_before_and_after_history(fixture):
    service, _, board = fixture
    card = add(service, board)
    call(service, "update_card", card_id=card["id"], title="A new title", description="New context",
         assignee=MEMBER.email, due_date="2026-12-01", labels=[{"name":"Urgent","color":"rose"}], version=1)
    history = call(service, "get_card_activity", card_id=card["id"])
    assert [row["action"] for row in history] == ["created", "updated", "labels_updated"]
    assert set(history[1]["changes"]) == {"title", "description", "assignee", "due_date"}
    assert history[1]["changes"]["title"] == {"before":"Send launch notes","after":"A new title"}


def test_duplicate_labels_rejected_without_partial_write(fixture):
    service, _, board = fixture
    card = add(service, board)
    with pytest.raises(m.Invalid):
        call(service, "update_card", card_id=card["id"], title="Should roll back", description="",
             assignee=None, due_date=None, labels=[{"name":"A","color":"rose"},{"name":"a","color":"blue"}], version=1)
    assert call(service, "get_card", card_id=card["id"])["title"] == card["title"]


def test_removing_member_clears_assignments_with_history(fixture):
    service, _, board = fixture
    card = add(service, board, assignee=MEMBER.email)
    call(service, "remove_member", board_id=board["id"], email=MEMBER.email)
    assert call(service, "get_card", card_id=card["id"])["assignee"] is None
    assert call(service, "get_card_activity", card_id=card["id"])[-1]["action"] == "member_removed"
    with pytest.raises(m.NotFound):
        call(service, "get_board", MEMBER, board_id=board["id"])


def test_owner_can_change_member_to_viewer(fixture):
    service, _, board = fixture
    call(service, "change_member_role", board_id=board["id"], email=MEMBER.email, role="viewer")
    with pytest.raises(m.Forbidden):
        add(service, board, MEMBER)


def test_accepted_invitation_binds_immutable_subject(fixture):
    service, _, board = fixture
    call(service, "get_board", MEMBER, board_id=board["id"])
    impostor = m.Person("different-sub", MEMBER.email)
    with pytest.raises(m.NotFound):
        call(service, "get_board", impostor, board_id=board["id"])


def test_request_replay_is_exactly_once(fixture):
    service, _, board = fixture
    key = uuid4().hex
    args = {"board_id":board["id"],"list_id":board["lists"][0]["id"],"title":"Once"}
    first = service.call("add_card", OWNER, args, key)
    second = service.call("add_card", OWNER, args, key)
    assert first == second
    assert len(call(service, "get_board", board_id=board["id"])["cards"]) == 1
    assert len(call(service, "get_card_activity", card_id=first["id"])) == 1


def test_request_key_cannot_be_reused_for_different_payload(fixture):
    service, _, board = fixture
    key = uuid4().hex
    args = {"board_id":board["id"],"list_id":board["lists"][0]["id"],"title":"Once"}
    service.call("add_card", OWNER, args, key)
    with pytest.raises(m.Conflict):
        service.call("add_card", OWNER, {**args,"title":"Other"}, key)


def test_replay_rechecks_revoked_permissions(fixture):
    service, _, board = fixture
    key = uuid4().hex
    args = {"board_id":board["id"],"list_id":board["lists"][0]["id"],"title":"Once"}
    service.call("add_card", MEMBER, args, key)
    call(service, "remove_member", board_id=board["id"], email=MEMBER.email)
    with pytest.raises(m.NotFound):
        service.call("add_card", MEMBER, args, key)


def test_commit_failure_rolls_back_card_history_and_idempotency(fixture):
    service, _, board = fixture
    before = deepcopy(service.store.rows)
    service.store.fail_after = m.Unavailable("Storage interrupted")
    with pytest.raises(m.Unavailable):
        add(service, board)
    assert service.store.rows == before


def test_checklist_changes_are_authorized_versioned_and_logged(fixture):
    service, _, board = fixture
    card = add(service, board)
    item = call(service, "add_checklist_item", MEMBER, card_id=card["id"], title="Review copy", version=1)
    with pytest.raises(m.Forbidden):
        call(service, "complete_checklist_item", VIEWER, item_id=item["id"], completed=True, version=1)
    call(service, "complete_checklist_item", MEMBER, item_id=item["id"], completed=True, version=1)
    fresh = call(service, "get_card", card_id=card["id"])
    assert fresh["checklist"][0]["completed"] is True
    assert [row["action"] for row in fresh["activity"]][-2:] == ["checklist_item_added","checklist_item_changed"]


def test_userless_mutation_and_identity_arguments_are_rejected(fixture):
    service, _, _ = fixture
    with pytest.raises(m.Unauthenticated):
        service.call("create_workspace", None, {"title":"No"}, uuid4().hex)
    with pytest.raises(m.Invalid):
        call(service, "create_workspace", title="No", user_sub=OWNER.sub)


@pytest.mark.parametrize("title", ["", " " * 10, "X" * 161, "Bad\x00title"])
def test_invalid_card_titles_are_rejected(fixture, title):
    service, _, board = fixture
    with pytest.raises(m.Invalid):
        add(service, board, title=title)