stubborn-otter/otter-boards
Otter Boards, a team task board you can run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 1 remixes · Remix stubborn-otter/otter-boards
tests/test_boards.py
"""The board's rules, through the use cases. No Dodil account needed: see conftest.py."""
import pytest
from conftest import MEMBER, OWNER, STRANGER, TODAY, VIEWER
from fakes import MemoryBoards
from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid, Person
def actions(s, card_id):
return [e["action"] for e in s.card_history(OWNER, card_id)]
def test_a_new_board_has_three_lists_and_only_done_is_finished(svc):
s, bid = svc
lists = s.show_board(OWNER, bid)["lists"]
assert [(x["name"], x["is_done"]) for x in lists] == [
("To do", False), ("Doing", False), ("Done", True)]
def test_member_adds_and_moves_cards_and_it_is_logged(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Send invoice")
moved = s.move_card(MEMBER, card["id"], "Done")
assert moved["list"] == "Done"
assert "moved from To do to Done" in actions(s, card["id"])
def test_viewer_cannot_edit(svc):
s, bid = svc
with pytest.raises(Forbidden):
s.add_card(VIEWER, bid, "nope")
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)
def test_stranger_cannot_see_board(svc):
s, bid = svc
with pytest.raises(NotFound):
s.show_board(STRANGER, bid)
assert s.my_boards(STRANGER) == []
def test_unknown_list_is_refused_and_names_the_real_ones(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "x")
with pytest.raises(Invalid, match="To do, Doing, Done"):
s.move_card(OWNER, card["id"], "Someday")
def test_people_are_invited_by_email_and_roles_are_checked(svc):
s, bid = svc
assert s.add_member(OWNER, bid, " Eve@X.io ", "viewer")["email"] == "eve@x.io"
assert s.show_board(Person("eve@x.io"), bid)["role"] == "viewer"
for email, role in (("not-an-email", "member"), ("x@y.io", "boss"), ("x@y.io", "owner"),
(OWNER.email, "member")):
with pytest.raises(Invalid):
s.add_member(OWNER, bid, email, role)
def test_owner_can_change_a_role_and_it_takes_effect(svc):
s, bid = svc
s.add_member(OWNER, bid, MEMBER.email, "viewer")
with pytest.raises(Forbidden):
s.add_card(MEMBER, bid, "nope")
s.add_member(OWNER, bid, VIEWER.email, "member")
assert s.add_card(VIEWER, bid, "now allowed")["title"] == "now allowed"
def test_removed_member_loses_the_board_and_their_cards(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Theirs", assignee=MEMBER.email)
s.remove_member(OWNER, bid, MEMBER.email)
with pytest.raises(NotFound):
s.show_board(MEMBER, bid)
assert s.my_boards(MEMBER) == []
assert s.my_cards(MEMBER)["no_due_date"] == []
assert s.show_card(OWNER, card["id"])["assignee"] is None
assert any("cleared assignee" in a for a in actions(s, card["id"]))
with pytest.raises(Invalid):
s.remove_member(OWNER, bid, MEMBER.email)
def test_removed_member_can_be_invited_again(svc):
s, bid = svc
s.remove_member(OWNER, bid, MEMBER.email)
s.add_member(OWNER, bid, MEMBER.email, "member")
assert s.show_board(MEMBER, bid)["role"] == "member"
def test_saving_the_board_does_not_bring_a_removed_member_back(svc):
s, bid = svc
s.remove_member(OWNER, bid, MEMBER.email)
s.rename_board(OWNER, bid, "Launch 2")
s.add_member(OWNER, bid, "eve@x.io", "member")
assert MEMBER.email not in [m["email"] for m in s.list_members(OWNER, bid)]
with pytest.raises(NotFound):
s.show_board(MEMBER, bid)
def test_demoting_to_viewer_hands_their_cards_back(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Theirs", assignee=MEMBER.email)
s.add_member(OWNER, bid, MEMBER.email, "viewer")
assert s.show_card(OWNER, card["id"])["assignee"] is None
def test_the_owner_stays(svc):
s, bid = svc
with pytest.raises(Invalid):
s.remove_member(OWNER, bid, OWNER.email)
def test_only_owner_renames_a_board(svc):
s, bid = svc
assert s.rename_board(OWNER, bid, "Launch 2")["title"] == "Launch 2"
with pytest.raises(Forbidden):
s.rename_board(MEMBER, bid, "Mine now")
def test_blank_titles_are_refused(svc):
s, bid = svc
for fn, args in ((s.create_board, (OWNER, " ")), (s.add_card, (OWNER, bid, " ")),
(s.add_list, (OWNER, bid, " ")), (s.rename_board, (OWNER, bid, ""))):
with pytest.raises(Invalid):
fn(*args)
def test_a_card_holds_title_description_assignee_due_date_and_labels(svc):
s, bid = svc
card = s.add_card(OWNER, bid, " Send invoice ", "Doing", "For March", "bo", "2026-06-12",
["Finance", "finance", " urgent "])
assert card["title"] == "Send invoice"
assert card["description"] == "For March"
assert card["assignee"] == MEMBER.email
assert card["due_date"] == "2026-06-12"
assert card["labels"] == ["Finance", "urgent"]
assert card["list"] == "Doing"
again = s.show_card(VIEWER, card["id"])
assert again["labels"] == ["Finance", "urgent"] and again["due_date"] == "2026-06-12"
def test_a_bad_date_is_refused(svc):
s, bid = svc
with pytest.raises(Invalid, match="YYYY-MM-DD"):
s.add_card(OWNER, bid, "x", due_date="next Tuesday")
def test_cards_number_up_per_board_and_never_reuse_a_number(svc):
s, bid = svc
other = s.create_board(OWNER, "Other")["id"]
first, second = s.add_card(OWNER, bid, "a"), s.add_card(OWNER, bid, "b")
assert (first["number"], second["number"]) == (1, 2)
assert s.add_card(OWNER, other, "c")["number"] == 1
s.archive_card(OWNER, second["id"])
assert s.add_card(OWNER, bid, "d")["number"] == 3
def test_two_people_taking_the_same_number_both_get_a_card(repo, ai):
"""The race itself: a rival claims the number between our read and our insert."""
s = Boards(repo, ai)
bid = s.create_board(OWNER, "Race")["id"]
real, raced = repo.last_card_number, []
def stale(board_id):
if not raced:
raced.append(True)
value = real(board_id)
s.add_card(OWNER, bid, "rival")
return value
return real(board_id)
repo.last_card_number = stale
card = s.add_card(OWNER, bid, "mine")
repo.last_card_number = real
assert card["number"] == 2
assert {c["title"]: c["number"] for c in s.show_board(OWNER, bid)["lists"][0]["cards"]} == {
"rival": 1, "mine": 2}
def test_every_card_change_records_who_when_and_what(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Plan", "To do")["id"]
s.update_card(MEMBER, card, {"title": "Plan launch", "description": "Long text",
"due_date": "2026-06-20", "labels": ["q3"]})
s.assign_card(OWNER, card, "bo")
s.move_card(OWNER, card, "Doing")
s.update_card(OWNER, card, {"due_date": None})
s.save_checklist(MEMBER, card, ["one"])
s.tick_checklist_item(MEMBER, card, "1")
log = s.card_history(VIEWER, card)
assert [(e["who"], e["action"]) for e in log] == [
(MEMBER.email, "added the card to To do"),
(MEMBER.email, "changed title from Plan to Plan launch"),
(MEMBER.email, "changed the description"),
(MEMBER.email, "set due date to 2026-06-20"),
(MEMBER.email, "set labels to q3"),
(OWNER.email, f"set assignee to {MEMBER.email}"),
(OWNER.email, "moved from To do to Doing"),
(OWNER.email, "cleared due date (was 2026-06-20)"),
(MEMBER.email, "changed the checklist: 0 of 1 done"),
(MEMBER.email, "changed the checklist: ticked 'one'"),
]
assert all(e["at"].startswith(TODAY) for e in log)
title = log[1]
assert (title["field"], title["old"], title["new"]) == ("title", "Plan", "Plan launch")
def test_a_change_that_changes_nothing_records_nothing(svc):
s, bid = svc
card = s.add_card(MEMBER, bid, "Same", "To do", "Words")["id"]
before = len(s.card_history(OWNER, card))
s.update_card(MEMBER, card, {"title": "Same", "description": "Words"})
s.move_card(MEMBER, card, "To do")
assert len(s.card_history(OWNER, card)) == before
def test_board_activity_is_newest_first_and_includes_membership(svc):
s, bid = svc
s.add_card(OWNER, bid, "x")
log = s.board_activity(VIEWER, bid, 3)
assert log[0]["action"] == "added the card to To do"
assert log[1]["action"] == f"added {VIEWER.email} as viewer"
def test_lists_are_real_add_rename_reorder_archive(svc):
s, bid = svc
review = s.add_list(MEMBER, bid, "Review")
s.update_list(MEMBER, bid, "Review", name="In review")
s.move_list(MEMBER, bid, "In review", before="Done")
names = [x["name"] for x in s.show_board(OWNER, bid)["lists"]]
assert names == ["To do", "Doing", "In review", "Done"]
s.move_list(MEMBER, bid, "To do")
s.move_list(MEMBER, bid, "To do", before="Doing")
assert [x["name"] for x in s.show_board(OWNER, bid)["lists"]] == names
assert s.archive_list(MEMBER, bid, review["id"])["archived"]
assert "In review" not in [x["name"] for x in s.show_board(OWNER, bid)["lists"]]
def test_a_card_keeps_its_place_when_its_list_is_renamed(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Stay", "Doing")
s.update_list(OWNER, bid, "Doing", name="In progress")
assert s.show_card(OWNER, card["id"])["list"] == "In progress"
assert s.move_card(OWNER, card["id"], "In progress")["list"] == "In progress"
def test_list_names_stay_unique_and_a_list_with_cards_stays(svc):
s, bid = svc
s.add_card(OWNER, bid, "x", "Doing")
with pytest.raises(Invalid, match="already has"):
s.add_list(OWNER, bid, "doing")
with pytest.raises(Invalid, match="already has"):
s.update_list(OWNER, bid, "To do", name="DOING")
with pytest.raises(Invalid, match="still has"):
s.archive_list(OWNER, bid, "Doing")
def test_a_board_keeps_at_least_one_list(svc):
s, bid = svc
s.archive_list(OWNER, bid, "To do")
s.archive_list(OWNER, bid, "Doing")
with pytest.raises(Invalid, match="at least one"):
s.archive_list(OWNER, bid, "Done")
def test_list_finished_flag_survives_a_rename_and_decides_overdue(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Old", "Done", due_date="2026-01-01")["id"]
assert s.overdue_cards(OWNER)["count"] == 0
s.update_list(OWNER, bid, "Done", name="Shipped")
assert s.overdue_cards(OWNER)["count"] == 0
s.update_list(OWNER, bid, "Shipped", is_done=False)
assert s.overdue_cards(OWNER)["cards"][0]["id"] == card
def test_a_list_on_another_board_is_not_a_target(svc):
s, bid = svc
other = s.create_board(OWNER, "Other")["id"]
foreign = s.add_list(OWNER, other, "Elsewhere")
card = s.add_card(OWNER, bid, "x")
with pytest.raises(Invalid):
s.move_card(OWNER, card["id"], foreign["id"])
def test_drag_order_within_and_between_lists(svc):
s, bid = svc
a, b, c = (s.add_card(OWNER, bid, t)["id"] for t in "abc")
titles = lambda i=0: [x["title"] for x in s.show_board(OWNER, bid)["lists"][i]["cards"]] # noqa
s.move_card(OWNER, c, "To do", before=a)
assert titles() == ["c", "a", "b"]
s.move_card(OWNER, a, "To do", before=b) # already just above b: no change
assert titles() == ["c", "a", "b"]
s.move_card(OWNER, c, "To do") # to the bottom
assert titles() == ["a", "b", "c"]
s.move_card(OWNER, b, "To do", before=c) # already there
x = s.add_card(OWNER, bid, "x", "Doing")["id"]
s.move_card(OWNER, b, "Doing", before=x)
assert titles(1) == ["b", "x"] and titles() == ["a", "c"]
with pytest.raises(Invalid):
s.move_card(OWNER, a, "Done", before=x)
def test_archived_cards_are_gone_from_the_board_but_not_from_history(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Old")["id"]
s.archive_card(MEMBER, card)
assert s.show_board(OWNER, bid)["lists"][0]["cards"] == []
with pytest.raises(NotFound):
s.show_card(OWNER, card)
with pytest.raises(NotFound):
s.move_card(OWNER, card, "Done")
logged = s.board_activity(OWNER, bid)[0]
assert logged["action"] == "archived the card" and logged["who"] == MEMBER.email
def test_assignee_must_be_able_to_act(svc):
s, bid = svc
with pytest.raises(Invalid):
s.add_card(OWNER, bid, "x", assignee=VIEWER.email)
with pytest.raises(Invalid):
s.add_card(OWNER, bid, "x", assignee="stranger@x.io")
card = s.add_card(MEMBER, bid, "x", assignee="me")
assert card["assignee"] == MEMBER.email
assert s.assign_card(OWNER, card["id"], "")["assignee"] is None
def test_my_plate_splits_by_when_it_is_due(svc):
s, bid = svc
s.add_card(MEMBER, bid, "Late", assignee="me", due_date="2026-06-09")
s.add_card(MEMBER, bid, "Today", assignee="me", due_date=TODAY)
s.add_card(MEMBER, bid, "Sunday", assignee="me", due_date="2026-06-14")
s.add_card(MEMBER, bid, "Next month", assignee="me", due_date="2026-07-01")
s.add_card(MEMBER, bid, "Whenever", assignee="me")
s.add_card(MEMBER, bid, "Finished", "Done", assignee="me", due_date="2026-06-09")
s.add_card(OWNER, bid, "Not mine", assignee="me", due_date="2026-06-11")
plate = s.my_cards(MEMBER)
titles = {k: [c["title"] for c in v] for k, v in plate.items() if isinstance(v, list)}
assert titles == {"overdue": ["Late"], "due_soon": ["Today", "Sunday"],
"no_due_date": ["Whenever"], "due_later": ["Next month"]}
assert plate["today"] == TODAY and plate["through"] == "2026-06-17"
assert [c["title"] for c in s.my_cards(MEMBER, days=31)["due_soon"]] == [
"Today", "Sunday", "Next month"]
def test_my_plate_never_shows_a_board_i_am_not_on(svc):
s, bid = svc
s.add_card(OWNER, bid, "Mine", assignee="me", due_date=TODAY)
assert s.my_cards(STRANGER)["due_soon"] == []
assert s.overdue_cards(STRANGER)["cards"] == []
def test_overdue_flag_comes_from_the_date_alone(svc):
s, bid = svc
late = s.add_card(OWNER, bid, "Late", due_date="2026-06-09")
today = s.add_card(OWNER, bid, "Today", due_date=TODAY)
assert late["overdue"] is True and today["overdue"] is False
assert s.show_board(VIEWER, bid)["overdue_count"] == 1
moved = s.move_card(OWNER, late["id"], "Done")
assert moved["overdue"] is False
def test_board_by_name_list_by_name_card_by_number(svc):
s, bid = svc
s.add_card(MEMBER, "launch", "Send invoice", "doing")
assert s.show_board(VIEWER, "LAUNCH")["lists"][1]["cards"][0]["title"] == "Send invoice"
for ref in (1, "1", "#1", "card 1", "Send invoice", "invoice"):
assert s.show_card(VIEWER, ref)["number"] == 1
assert s.move_card(MEMBER, "1", "DONE")["list"] == "Done"
def test_todo_spellings_all_find_the_list(svc):
s, bid = svc
for name in ("todo", "To Do", "to-do"):
assert s.add_card(MEMBER, bid, "x", name)["list"] == "To do"
def test_ambiguous_names_ask_instead_of_guessing(svc):
s, bid = svc
with pytest.raises(Invalid, match="more than one list"):
s.add_card(OWNER, bid, "x", "do")
s.create_board(OWNER, "Launch party")
with pytest.raises(Invalid, match="more than one board"):
s.show_board(OWNER, "laun")
assert s.show_board(OWNER, "Launch")["id"] == bid # an exact name beats partial matches
def test_same_card_number_on_two_boards_asks_which(svc):
s, bid = svc
other = s.create_board(OWNER, "Ops")["id"]
s.add_card(OWNER, bid, "a")
s.add_card(OWNER, other, "b")
with pytest.raises(Invalid, match="more than one board: 'Launch', 'Ops'"):
s.move_card(OWNER, "1", "Done")
assert s.move_card(OWNER, "1", "Done", board="Ops")["title"] == "b"
def test_a_stranger_cannot_reach_a_card_by_number_or_title(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Secret")
for ref in (card["id"], "1", "#1", "Secret"):
with pytest.raises(NotFound):
s.show_card(STRANGER, ref)
with pytest.raises(NotFound):
s.move_card(STRANGER, ref, "Done")
def test_card_numbers_do_not_tell_a_stranger_that_another_board_has_that_number(svc):
s, bid = svc
s.add_card(OWNER, bid, "Secret")
mine = s.create_board(STRANGER, "Mine")["id"]
s.add_card(STRANGER, mine, "Visible")
assert s.show_card(STRANGER, "1")["title"] == "Visible"
def test_a_card_id_outside_the_named_board_is_not_found(svc):
s, bid = svc
other = s.create_board(OWNER, "Ops")["id"]
card = s.add_card(OWNER, bid, "Here")
with pytest.raises(NotFound):
s.show_card(OWNER, card["id"], other)
def test_repo_writes_nothing_for_a_refused_action():
repo = MemoryBoards()
s = Boards(repo)
bid = s.create_board(OWNER, "b")["id"]
s.add_member(OWNER, bid, VIEWER.email, "viewer")
before = repo.saves
with pytest.raises(Forbidden):
s.add_list(VIEWER, bid, "x")
assert repo.saves == before