theaio/theaio-boards
Laneway, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix theaio/theaio-boards
tests/test_boards.py
"""The board's rules, through the use cases. No Dodil account needed: the repository and the AI
are in-memory fakes (tests/fakes.py)."""
from datetime import timedelta
import pytest
from boards.application.use_cases import Boards, NotFound
from boards.domain.model import Forbidden, Invalid
from conftest import MEMBER, OWNER, STRANGER, VIEWER
from fakes import Clock, FakeAI
def test_a_new_board_gets_to_do_doing_done(svc):
s, bid = svc
assert [l["name"] for l in s.show_board(OWNER, bid)["lists"]] == ["To do", "Doing", "Done"]
def test_member_adds_card_by_board_title_and_list_name_and_moves_it_by_number(svc):
s, bid = svc
card = s.add_card(MEMBER, "launch", "Send invoice", "Doing")
assert (card["board"], card["list"], card["number"]) == ("Launch", "Doing", 1)
moved = s.move_card(MEMBER, "1", "Done")
assert moved["list"] == "Done" and moved["id"] == card["id"]
assert s.move_card(MEMBER, "card #1", "To do")["list"] == "To do"
history = s.card_history(OWNER, 1)["history"]
assert ("list", "Doing", "Done") in [(h["field"], h["old"], h["new"]) for h in history]
assert all(h["who"] == MEMBER.email for h in history)
# --- roles ----------------------------------------------------------------------------------
def test_viewer_cannot_edit_anything(svc):
s, bid = svc
s.add_card(OWNER, bid, "Write copy")
for attempt in (lambda: s.add_card(VIEWER, bid, "nope"),
lambda: s.move_card(VIEWER, 1, "Done"),
lambda: s.assign_card(VIEWER, 1, "me"),
lambda: s.set_due_date(VIEWER, 1, "tomorrow"),
lambda: s.label_card(VIEWER, 1, add=["x"]),
lambda: s.describe_card(VIEWER, 1, "x"),
lambda: s.comment_card(VIEWER, 1, "x"),
lambda: s.add_list(VIEWER, bid, "Later"),
lambda: s.rename_list(VIEWER, bid, "Doing", "Now"),
lambda: s.block_card(VIEWER, 1, 1),
lambda: s.invite_member(VIEWER, bid, "eve@x.io")):
with pytest.raises(Forbidden):
attempt()
assert s.show_board(VIEWER, bid)["role"] == "viewer" # but can look
assert s.show_card(VIEWER, 1)["title"] == "Write copy"
assert len(s.card_history(OWNER, 1)["history"]) == 1 # nothing was recorded
def test_stranger_sees_nothing(svc):
s, bid = svc
card = s.add_card(OWNER, bid, "Secret plan")
s.set_due_date(OWNER, 1, "2026-10-01")
assert s.my_boards(STRANGER) == []
assert s.list_overdue(STRANGER)["cards"] == []
for attempt in (lambda: s.show_board(STRANGER, bid),
lambda: s.show_board(STRANGER, "Launch"),
lambda: s.show_card(STRANGER, card["id"]),
lambda: s.show_card(STRANGER, "1"),
lambda: s.show_card(STRANGER, "Secret plan"),
lambda: s.move_card(STRANGER, card["id"], "Done"),
lambda: s.add_card(STRANGER, bid, "x"),
lambda: s.what_is_blocking(STRANGER, "Launch"),
lambda: s.similar_cards(STRANGER, card["id"])):
with pytest.raises(NotFound):
attempt()
def test_only_owner_manages_members(svc):
s, bid = svc
for attempt in (lambda: s.invite_member(MEMBER, bid, "eve@x.io", "member"),
lambda: s.set_member_role(MEMBER, bid, VIEWER.email, "member"),
lambda: s.remove_member(MEMBER, bid, VIEWER.email)):
with pytest.raises(Forbidden):
attempt()
s.set_member_role(OWNER, bid, VIEWER.email, "member")
s.add_card(VIEWER, bid, "now I can edit")
s.remove_member(OWNER, bid, VIEWER.email)
with pytest.raises(NotFound):
s.show_board(VIEWER, bid)
with pytest.raises(Invalid):
s.remove_member(OWNER, bid, OWNER.email)
with pytest.raises(Invalid):
s.invite_member(OWNER, bid, MEMBER.email, "viewer") # already on it
with pytest.raises(Invalid):
s.invite_member(OWNER, bid, "eve@x.io", "admin") # no such role
def test_members_see_who_is_on_the_board(svc):
s, bid = svc
people = s.show_board(MEMBER, bid)["members"]
assert people == [{"email": OWNER.email, "role": "owner"},
{"email": MEMBER.email, "role": "member"},
{"email": VIEWER.email, "role": "viewer"}]
def test_assignee_must_be_a_member_and_not_a_viewer(svc):
s, bid = svc
s.add_card(OWNER, bid, "Ship it")
with pytest.raises(Invalid, match="not on this board"):
s.assign_card(OWNER, 1, STRANGER.email)
with pytest.raises(Invalid, match="viewer"):
s.assign_card(OWNER, 1, VIEWER.email)
assert s.assign_card(OWNER, 1, MEMBER.email)["assignee"] == MEMBER.email
assert s.assign_card(MEMBER, 1, "me")["assignee"] == MEMBER.email
s.set_member_role(OWNER, bid, MEMBER.email, "viewer") # demoted: unassigned
assert s.show_card(OWNER, 1)["assignee"] is None
# --- card numbers ---------------------------------------------------------------------------
def test_card_numbers_are_unique_and_increasing_per_board(svc):
s, bid = svc
other = s.create_board(OWNER, "Ops")["id"]
nums = [s.add_card(OWNER, bid, f"c{i}")["number"] for i in range(5)]
assert nums == [1, 2, 3, 4, 5]
assert s.add_card(OWNER, other, "first on ops")["number"] == 1
assert s.repo.get_card(f"{bid}:3").title == "c2"
def test_a_taken_number_is_retried_not_overwritten(svc):
s, bid = svc
s.add_card(OWNER, bid, "one")
s.add_card(OWNER, bid, "two")
s.repo.stale_max_once = True # another writer got there first
assert s.add_card(OWNER, bid, "three")["number"] == 3
assert s.repo.get_card(f"{bid}:2").title == "two"
def test_a_number_on_two_boards_asks_which_board(svc):
s, bid = svc
ops = s.create_board(OWNER, "Ops")["id"]
s.add_card(OWNER, bid, "launch card")
s.add_card(OWNER, ops, "ops card")
with pytest.raises(Invalid, match="several cards"):
s.move_card(OWNER, "1", "Done")
assert s.move_card(OWNER, "1", "Done", board_ref="Ops")["title"] == "ops card"
assert s.move_card(MEMBER, "1", "Done")["title"] == "launch card" # bo is only on Launch
def test_positions_inside_a_list(svc):
s, bid = svc
for t in ("a", "b", "c"):
s.add_card(OWNER, bid, t)
s.move_card(OWNER, "c", "To do", position=1)
todo = s.show_board(OWNER, bid)["lists"][0]["cards"]
assert [c["title"] for c in todo] == ["c", "a", "b"]
assert [c["position"] for c in todo] == [1, 2, 3]
assert ("position", "3", "1") in [(h["field"], h["old"], h["new"])
for h in s.card_history(OWNER, "c")["history"]]
# --- lists ----------------------------------------------------------------------------------
def test_lists_are_real_rows(svc):
s, bid = svc
s.add_list(MEMBER, bid, "Review")
s.rename_list(MEMBER, bid, "review", "QA")
names = [l["name"] for l in s.show_board(OWNER, bid)["lists"]]
assert names == ["To do", "Doing", "QA", "Done"]
out = s.move_list(MEMBER, bid, "QA", 1)
assert [l["name"] for l in out["lists"]] == ["QA", "To do", "Doing", "Done"]
s.add_card(OWNER, bid, "test it", "QA")
with pytest.raises(Invalid, match="lists are"):
s.move_card(OWNER, 1, "Someday")
# --- history --------------------------------------------------------------------------------
def test_history_records_who_when_field_old_and_new(svc):
s, bid = svc
s.add_card(OWNER, bid, "Plan")
s.set_due_date(MEMBER, 1, "2026-11-20")
s.set_due_date(MEMBER, 1, "2026-11-25")
s.label_card(MEMBER, 1, add=["urgent", "client"])
s.label_card(OWNER, 1, remove=["URGENT"])
s.update_card(OWNER, 1, title="Plan the launch")
s.comment_card(MEMBER, 1, "on it")
s.set_due_date(MEMBER, 1, "2026-11-25") # no change, nothing recorded
rows = [(h["who"], h["field"], h["old"], h["new"])
for h in s.card_history(VIEWER, 1)["history"]]
assert rows == [(OWNER.email, "created", "", "Plan (in To do)"),
(MEMBER.email, "due_date", "", "2026-11-20"),
(MEMBER.email, "due_date", "2026-11-20", "2026-11-25"),
(MEMBER.email, "labels", "", "urgent, client"),
(OWNER.email, "labels", "urgent, client", "client"),
(OWNER.email, "title", "Plan", "Plan the launch"),
(MEMBER.email, "comment", "", "on it")]
assert s.card_history(OWNER, 1)["history"][0]["at"].startswith("2026-11-02T09:00")
# --- my plate, overdue ----------------------------------------------------------------------
def test_my_cards_within_a_window(svc):
s, bid = svc # today is 2026-11-02
for title, due in (("late", "2026-10-30"), ("this week", "+5"), ("next month", "+30"),
("no date", ""), ("finished", "+1")):
s.add_card(OWNER, bid, title)
s.update_card(OWNER, title, assignee=MEMBER.email, due_date=due)
s.add_card(OWNER, bid, "someone else's")
s.update_card(OWNER, "someone else's", assignee=OWNER.email, due_date="+1")
s.move_card(MEMBER, "finished", "Done")
week = [c["title"] for c in s.my_cards(MEMBER, within_days=7)["cards"]]
assert week == ["late", "this week"]
everything = [c["title"] for c in s.my_cards(MEMBER)["cards"]]
assert everything == ["late", "this week", "next month", "no date"]
with pytest.raises(Invalid):
s.my_cards(MEMBER, within_days=-1)
def test_overdue_is_flagged_and_only_on_visible_boards(svc):
s, bid = svc
hidden = s.create_board(STRANGER, "Dee's board")["id"]
s.add_card(STRANGER, hidden, "dee's late card")
s.set_due_date(STRANGER, 1, "2026-10-01")
s.add_card(OWNER, bid, "late")
s.set_due_date(OWNER, "late", "2026-11-01")
s.add_card(OWNER, bid, "late but done")
s.set_due_date(OWNER, "late but done", "2026-11-01")
s.move_card(OWNER, "late but done", "Done")
s.add_card(OWNER, bid, "due today")
s.set_due_date(OWNER, "due today", "today")
assert [c["title"] for c in s.list_overdue(VIEWER)["cards"]] == ["late"]
assert [c["title"] for c in s.list_overdue(STRANGER)["cards"]] == ["dee's late card"]
board = s.show_board(OWNER, bid)
assert board["overdue"] == 1
assert [c["overdue"] for c in board["lists"][0]["cards"]] == [True, False]
# --- dependencies ---------------------------------------------------------------------------
def test_blocked_by_links_refuse_loops(svc):
s, bid = svc
for t in ("design", "build", "ship"):
s.add_card(OWNER, bid, t)
s.block_card(MEMBER, "build", "design")
s.block_card(MEMBER, "ship", "build")
with pytest.raises(Invalid, match="loop"):
s.block_card(MEMBER, "design", "ship") # design <- ship <- build <- design
with pytest.raises(Invalid, match="itself"):
s.block_card(MEMBER, "design", "design")
other = s.create_board(OWNER, "Ops")["id"]
s.add_card(OWNER, other, "elsewhere")
with pytest.raises(NotFound):
s.block_card(OWNER, "ship", "elsewhere", board_ref=bid) # same board only
card = s.what_is_blocking(VIEWER, "ship")
assert [c["title"] for c in card["waiting_on"]] == ["build"]
assert [c["title"] for c in card["whole_chain"]] == ["build", "design"]
board = s.what_is_blocking(VIEWER, "Launch") # a board title: the whole board
assert {b["card"]["title"]: [w["title"] for w in b["waiting_on"]]
for b in board["blocked"]} == {"build": ["design"], "ship": ["build"]}
s.move_card(MEMBER, "design", "Done") # a done blocker blocks nothing
assert s.show_card(OWNER, "build")["blocked"] is False
assert [c["title"] for c in s.what_is_blocking(OWNER, "ship")["whole_chain"]] == ["build"]
s.unblock_card(MEMBER, "ship", "build")
assert s.what_is_blocking(OWNER, "ship")["blocked"] is False
assert ("blocked by", "card 2", "") in [(h["field"], h["old"], h["new"])
for h in s.card_history(OWNER, "ship")["history"]]
# --- AI extras ------------------------------------------------------------------------------
def test_similar_cards_by_meaning_only_on_visible_boards(svc):
s, bid = svc
s.add_card(OWNER, bid, "send invoice to client")
s.add_card(OWNER, bid, "client invoice reminder")
s.add_card(OWNER, bid, "paint the office")
hidden = s.create_board(STRANGER, "Dee")["id"]
s.add_card(STRANGER, hidden, "send invoice to client")
out = s.similar_cards(MEMBER, "send invoice to client", board_ref=bid)
assert out["similar"][0]["title"] == "client invoice reminder"
assert {c["board"] for c in out["similar"]} == {"Launch"}
embedded = s.assistant.embedded
s.similar_cards(MEMBER, 1, board_ref=bid)
assert s.assistant.embedded == embedded # nothing changed, nothing re-embedded
s.describe_card(OWNER, 3, "walls")
s.similar_cards(MEMBER, 1, board_ref=bid)
assert s.assistant.embedded == embedded + 1 # only the edited card
def test_daily_summary(svc):
s, bid = svc
s.add_card(OWNER, bid, "late")
s.set_due_date(OWNER, "late", "2026-10-01")
out = s.board_summary(MEMBER, "Launch")
assert out["summary"].startswith("SUMMARY: Board 'Launch'")
assert any(f.startswith("Overdue: card 1 'late'") for f in out["facts"])
assert any("due_date on card 1" in f for f in out["facts"])
s.clock.at += timedelta(days=2) # yesterday's changes are old news
assert not any(f.startswith("Change:") for f in s.board_summary(OWNER, bid)["facts"])
down = Boards(s.repo, FakeAI(fail=True), clock=s.clock)
out = down.board_summary(OWNER, bid)
assert "note" in out and out["summary"].startswith("Board 'Launch'")
def test_checklist_is_a_suggestion_and_needs_an_assistant(svc):
s, bid = svc
s.add_card(OWNER, bid, "Plan party")
assert s.suggest_checklist(VIEWER, 1)["suggested_checklist"] == ["step one", "step two"]
with pytest.raises(Invalid):
Boards(s.repo, None, clock=Clock()).suggest_checklist(OWNER, 1)
def test_similar_cards_survives_a_model_outage(svc):
"""Dodil Models timing out must not turn 'find similar' into a 500 (seen live 11-01: ReadTimeout)."""
s, board = svc
from conftest import OWNER
class DownAI:
def embed(self, texts):
raise TimeoutError("model read timed out")
card = s.add_card(OWNER, board, "Send invoice", "To do")
s.assistant = DownAI()
out = s.similar_cards(OWNER, card["id"])
assert out["similar"] == [] and "unavailable" in out["note"]