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") == []