ghpsolutions/quadro
Quadro: team boards with roles, history and an MCP endpoint for Claude and ChatGPT
A remix template · 0 remixes · Remix ghpsolutions/quadro
tests/test_use_cases.py
from datetime import timedelta
import pytest
from boards.application.ports import NumberTaken
from boards.application.use_cases import Boards
from boards.domain.model import Conflict, Forbidden, Invalid, NotFound
from conftest import MEMBER, OWNER, STRANGER, TODAY, VIEWER, FakeAssistant
def day(n):
return (TODAY + timedelta(days=n)).isoformat()
def test_new_board_has_default_lists_and_owner(svc):
b = svc.create_board(OWNER, "Ops")
assert [l["name"] for l in b["lists"]] == ["To do", "Doing", "Done"]
assert [l["done"] for l in b["lists"]] == [False, False, True]
assert b["role"] == "owner" and b["key"] == "OPS"
def test_roles_hold(svc, ops):
with pytest.raises(Forbidden):
svc.add_card(VIEWER, ops, "nope")
with pytest.raises(Forbidden):
svc.invite_member(MEMBER, ops, "x@ghp.test", "member")
with pytest.raises(NotFound):
svc.show_board(STRANGER, ops)
assert svc.my_boards(STRANGER) == []
assert svc.show_board(VIEWER, ops)["role"] == "viewer"
card = svc.add_card(MEMBER, ops, "Send invoice")
with pytest.raises(Forbidden):
svc.move_card(VIEWER, card["id"], "Done")
with pytest.raises(NotFound): # a stranger cannot even learn the card exists
svc.card_detail(STRANGER, card["id"])
def test_owner_manages_members(svc, ops):
svc.invite_member(OWNER, ops, VIEWER.email, "member")
assert svc.add_card(VIEWER, ops, "now I can")["title"] == "now I can"
svc.remove_member(OWNER, ops, VIEWER.email)
with pytest.raises(NotFound):
svc.show_board(VIEWER, ops)
with pytest.raises(Invalid):
svc.remove_member(OWNER, ops, OWNER.email)
with pytest.raises(Invalid):
svc.invite_member(OWNER, ops, "not-an-email", "member")
def test_cards_by_name_and_number(svc, ops):
"""'add a card Send invoice to Doing on the Ops board', then 'move card 2 to Done'."""
first = svc.add_card(MEMBER, "Ops board", "Pay rent")
card = svc.add_card(MEMBER, "ops", "Send invoice", "doing")
assert (first["ref"], card["ref"], card["list"]) == ("OPS-1", "OPS-2", "Doing")
moved = svc.move_card(MEMBER, "2", "Done")
assert moved["list"] == "Done" and moved["done"]
assert svc.move_card(MEMBER, "OPS-2", "To do")["list"] == "To do"
with pytest.raises(Invalid, match="not a list"):
svc.move_card(MEMBER, "2", "Someday")
def test_ambiguous_numbers_ask_for_the_board(svc, ops):
other = svc.create_board(MEMBER, "Launch")["id"]
svc.add_card(MEMBER, ops, "a")
svc.add_card(MEMBER, other, "b")
with pytest.raises(Invalid, match="several boards"):
svc.move_card(MEMBER, "1", "Done")
assert svc.move_card(MEMBER, "1", "Done", board_ref="Launch")["title"] == "b"
assert svc.move_card(MEMBER, "LAUN-1", "Doing")["title"] == "b"
def test_every_change_is_logged_with_before_and_after(svc, ops):
card = svc.add_card(MEMBER, ops, "Send invoice")
svc.update_card(OWNER, card["id"], due_date=day(2), labels=["Finance"], assignee=MEMBER.email)
svc.move_card(MEMBER, card["id"], "Doing")
history = svc.card_detail(OWNER, card["id"])["history"]
assert [h["action"] for h in history] == ["moved the card", "edited the card", "added the card"]
assert history[0]["actor"] == MEMBER.email and history[0]["happened_at"]
assert history[0]["changes"] == [{"field": "list", "from": "To do", "to": "Doing"}]
edited = {c["field"]: c for c in history[1]["changes"]}
assert edited["due_date"] == {"field": "due_date", "from": None, "to": day(2)}
assert edited["assignee"]["to"] == MEMBER.email
def test_stale_edits_are_refused(svc, ops):
card = svc.add_card(MEMBER, ops, "Send invoice")
svc.update_card(OWNER, card["id"], expected_version=card["version"], title="Send invoice now")
with pytest.raises(Conflict):
svc.update_card(MEMBER, card["id"], expected_version=card["version"], title="mine")
assert svc.card_detail(MEMBER, card["id"])["title"] == "Send invoice now"
def test_number_races_take_the_next_number(svc, ops, repo):
real = repo.insert_card
calls = {"n": 0}
def flaky(card):
calls["n"] += 1
if calls["n"] == 1:
raise NumberTaken(card.id) # someone else claimed this number first
real(card)
repo.insert_card = flaky
try:
assert svc.add_card(MEMBER, ops, "x")["number"] == 2
finally:
repo.insert_card = real
def test_archived_numbers_are_not_reused(svc, ops):
a = svc.add_card(MEMBER, ops, "a")
svc.delete_card(MEMBER, a["id"])
assert svc.add_card(MEMBER, ops, "b")["number"] == 2
with pytest.raises(NotFound):
svc.card_detail(MEMBER, a["id"])
def test_assignee_must_be_on_the_board(svc, ops):
with pytest.raises(Invalid, match="invite them"):
svc.add_card(MEMBER, ops, "x", assignee=STRANGER.email)
def test_my_plate_and_overdue(svc, ops):
svc.add_card(OWNER, ops, "late", assignee=MEMBER.email, due_date=day(-1))
svc.add_card(OWNER, ops, "this week", assignee=MEMBER.email, due_date=day(3))
svc.add_card(OWNER, ops, "later", assignee=MEMBER.email, due_date=day(30))
svc.add_card(OWNER, ops, "not mine", assignee=OWNER.email, due_date=day(-3))
done = svc.add_card(OWNER, ops, "finished", "Done", assignee=MEMBER.email, due_date=day(-9))
plate = svc.my_plate(MEMBER)
assert [c["title"] for c in plate["overdue"]] == ["late"]
assert [c["title"] for c in plate["due_soon"]] == ["this week"]
assert [c["title"] for c in plate["no_date_or_later"]] == ["later"]
overdue = svc.overdue(VIEWER)
assert [c["title"] for c in overdue] == ["not mine", "late"]
assert all(c["overdue"] for c in overdue) and done["id"] not in [c["id"] for c in overdue]
assert svc.overdue(STRANGER) == []
def test_lists_can_be_added_renamed_reordered_and_deleted(svc, ops):
svc.add_list(MEMBER, ops, "Review")
svc.update_list(MEMBER, ops, "Review", index=2)
assert [l["name"] for l in svc.show_board(OWNER, ops)["lists"]] == \
["To do", "Doing", "Review", "Done"]
svc.update_list(MEMBER, ops, "Review", name="QA")
svc.add_card(MEMBER, ops, "x", "QA")
with pytest.raises(Invalid, match="still has cards"):
svc.delete_list(MEMBER, ops, "QA")
with pytest.raises(Forbidden):
svc.add_list(VIEWER, ops, "Nope")
def test_reordering_inside_a_list(svc, ops):
a, b, c = (svc.add_card(MEMBER, ops, t) for t in "abc")
svc.move_card(MEMBER, c["id"], "To do", index=0)
svc.move_card(MEMBER, a["id"], "To do", index=1)
titles = [x["title"] for x in svc.show_board(MEMBER, ops)["lists"][0]["cards"]]
assert titles == ["c", "a", "b"]
def test_comments(svc, ops):
card = svc.add_card(MEMBER, ops, "x")
svc.comment(OWNER, card["id"], "PO number is 4471")
with pytest.raises(Forbidden):
svc.comment(VIEWER, card["id"], "hi")
detail = svc.card_detail(VIEWER, card["id"])
assert detail["comments"] == 1 and detail["comment_list"][0]["author"] == OWNER.email
def test_blockers_and_what_blocks_the_launch(svc, ops):
cert = svc.add_card(MEMBER, ops, "Renew certificate", assignee=MEMBER.email)
backup = svc.add_card(MEMBER, ops, "Migrate backups")
launch = svc.add_card(MEMBER, ops, "Launch")
svc.block(MEMBER, launch["id"], backup["ref"])
svc.block(MEMBER, backup["id"], cert["ref"])
with pytest.raises(Invalid, match="loop"):
svc.block(MEMBER, cert["id"], launch["id"])
answer = svc.what_blocks(VIEWER, "Launch".lower() and launch["ref"])
assert [(b["ref"], b["depth"]) for b in answer["open_blockers"]] == [("OPS-2", 1), ("OPS-1", 2)]
assert svc.show_board(MEMBER, ops)["lists"][0]["cards"][2]["blocked"]
svc.move_card(MEMBER, cert["id"], "Done")
svc.move_card(MEMBER, backup["id"], "Done")
assert svc.what_blocks(MEMBER, launch["id"])["clear"]
svc.unblock(MEMBER, launch["id"], backup["id"])
assert svc.card_detail(MEMBER, launch["id"])["blocked_by"] == []
def test_checklist_suggestion_saves_nothing_until_applied(svc, ops, repo):
card = svc.add_card(MEMBER, ops, "Send invoice")
steps = svc.suggest_checklist(VIEWER, card["id"])["steps"]
assert steps == ["Draft it", "Review it", "Send it"]
assert svc.card_detail(MEMBER, card["id"])["description"] == ""
with pytest.raises(Forbidden):
svc.apply_checklist(VIEWER, card["id"], steps)
after = svc.apply_checklist(MEMBER, card["id"], steps[:2])
assert after["checklist"] == {"done": 0, "total": 2}
def test_similar_cards_use_embeddings_then_fall_back(svc, ops, repo):
a = svc.add_card(MEMBER, ops, "Send invoice to Acme")
svc.add_card(MEMBER, ops, "Renew certificate")
svc.add_card(MEMBER, ops, "Chase unpaid invoice from Globex")
down = Boards(repo, FakeAssistant(fail=True), today=lambda: TODAY)
r = down.similar_cards(MEMBER, a["id"])
assert r["method"] == "keywords" and r["similar"][0]["title"].startswith("Chase")
r = svc.similar_cards(MEMBER, a["id"])
assert r["method"] == "embeddings" and r["similar"][0]["title"] == "Chase unpaid invoice from Globex"
assert len(repo.embeddings_on(ops)) == 3
# Vectors are cached by content, so asking again needs no model call.
assert down.similar_cards(MEMBER, a["id"])["method"] == "embeddings"
def test_board_summary_with_and_without_the_model(svc, ops, repo):
svc.add_card(MEMBER, ops, "late one", due_date=day(-2))
s = svc.board_summary(VIEWER, ops)
assert s["source"] == "dodil-models" and s["summary"].startswith("Standup: Board: Ops")
assert s["facts"]["overdue"][0].startswith("OPS-1 late one")
down = Boards(repo, FakeAssistant(fail=True), today=lambda: TODAY).board_summary(VIEWER, ops)
assert down["source"] == "facts" and "Overdue: OPS-1 late one" in down["summary"]
def test_search(svc, ops):
svc.add_card(MEMBER, ops, "Send invoice", labels=["Finance"])
svc.add_card(MEMBER, ops, "Renew certificate")
assert [c["title"] for c in svc.search(VIEWER, "finance")] == ["Send invoice"]
assert svc.search(STRANGER, "invoice") == []