eduard/cobra-boards

Cobra Boards, team task boards you can run from Claude or ChatGPT. FastAPI + DataK3 + appid + Dodil Models + MCP.

A remix template · 1 remixes · Remix eduard/cobra-boards

tests/test_core.py

"""The rules that decide whether the board can be trusted: who sees what, who changes what, what
the history remembers, and what happens when two people edit at once."""
from datetime import timedelta

import pytest
from conftest import MEMBER, OWNER, STRANGER, TODAY, VIEWER

from boards.application.use_cases import NotFound
from boards.domain.model import Conflict, Forbidden, Invalid


# ---------------------------------------------------------------- visibility
def test_a_board_you_are_not_on_does_not_exist_for_you(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Secret plan")
    with pytest.raises(NotFound):
        s.show_board(STRANGER, bid)
    with pytest.raises(NotFound):
        s.card_detail(STRANGER, card["id"])
    with pytest.raises(NotFound):
        s.show_board(STRANGER, "Launch")  # not even by name
    assert s.my_boards(STRANGER) == []
    assert s.overdue(STRANGER) == [] and s.search_cards(STRANGER, "secret") == []


def test_each_person_sees_their_role(svc):
    s, bid = svc
    roles = {p.email: s.my_boards(p)[0]["role"] for p in (OWNER, MEMBER, VIEWER)}
    assert roles == {OWNER.email: "owner", MEMBER.email: "member", VIEWER.email: "viewer"}
    view = s.show_board(VIEWER, bid)
    assert view["can_edit"] is False and view["is_owner"] is False


# ---------------------------------------------------------------- roles
def test_viewer_can_look_but_not_change_anything(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Write copy")
    assert s.card_detail(VIEWER, card["id"])["title"] == "Write copy"
    for attempt in (lambda: s.add_card(VIEWER, bid, "x"),
                    lambda: s.move_card(VIEWER, card["id"], "Done"),
                    lambda: s.update_card(VIEWER, card["id"], title="hacked"),
                    lambda: s.add_list(VIEWER, bid, "Ideas"),
                    lambda: s.add_checklist_items(VIEWER, card["id"], ["x"]),
                    lambda: s.invite(VIEWER, bid, "eve@cobra.test", "member")):
        with pytest.raises(Forbidden):
            attempt()
    assert s.card_detail(OWNER, card["id"])["title"] == "Write copy"


def test_only_the_owner_manages_members(svc):
    s, bid = svc
    with pytest.raises(Forbidden):
        s.invite(MEMBER, bid, "eve@cobra.test", "member")
    with pytest.raises(Forbidden):
        s.remove_member(MEMBER, bid, VIEWER.email)
    with pytest.raises(Forbidden):
        s.rename_board(MEMBER, bid, "Mine now")
    with pytest.raises(Invalid):
        s.invite(OWNER, bid, "eve@cobra.test", "owner")  # one owner per board


def test_removing_a_member_unassigns_their_cards_with_history(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Call the printer", assignee=MEMBER.email)
    out = s.remove_member(OWNER, bid, MEMBER.email)
    assert out["cards_unassigned"] == 1
    assert s.card_detail(OWNER, card["id"])["assignee"] is None
    with pytest.raises(NotFound):
        s.show_board(MEMBER, bid)
    assert any("unassigned" in h["summary"] for h in s.card_history(OWNER, card["id"]))


def test_viewer_promoted_to_member_can_then_edit(svc):
    s, bid = svc
    s.invite(OWNER, bid, VIEWER.email, "member")
    assert s.add_card(VIEWER, bid, "Now I can")["number"] == 1


def test_me_means_the_person_asking(svc):
    s, bid = svc
    assert s.add_card(MEMBER, bid, "Mine", assignee="me")["assignee"] == MEMBER.email
    card = s.add_card(OWNER, bid, "Take it")
    assert s.update_card(MEMBER, card["id"], assignee="Me")["assignee"] == MEMBER.email


def test_you_can_only_assign_people_on_the_board(svc):
    s, bid = svc
    with pytest.raises(Invalid):
        s.add_card(OWNER, bid, "x", assignee=STRANGER.email)
    assert s.add_card(OWNER, bid, "y", assignee=MEMBER.email)["assignee"] == MEMBER.email


# ---------------------------------------------------------------- cards & lists
def test_cards_get_short_numbers_per_board(svc):
    s, bid = svc
    a, b = s.add_card(OWNER, bid, "First"), s.add_card(MEMBER, bid, "Second")
    other = s.create_board(OWNER, "Ops")
    c = s.add_card(OWNER, other["id"], "Elsewhere")
    assert (a["number"], b["number"], c["number"]) == (1, 2, 1)


def test_a_card_is_found_by_number_and_board_name(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "First")
    twelve = [s.add_card(OWNER, bid, f"c{i}") for i in range(2, 13)][-1]
    moved = s.move_card(MEMBER, "12", "Done", board_ref="launch")
    assert moved["id"] == twelve["id"] and moved["list"] == "Done" and moved["done"]
    assert s.card_detail(OWNER, "#12")["title"] == "c12"


def test_ambiguous_card_numbers_ask_which_board(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "Launch one")
    ops = s.create_board(OWNER, "Ops")
    s.add_card(OWNER, ops["id"], "Ops one")
    with pytest.raises(Invalid, match="several of your boards"):
        s.move_card(OWNER, "1", "Doing")
    assert s.move_card(OWNER, "1", "doing", board_ref="Ops")["title"] == "Ops one"


def test_ambiguous_board_names_ask_instead_of_guessing(svc):
    s, _ = svc
    s.create_board(OWNER, "Ops north")
    s.create_board(OWNER, "Ops south")
    with pytest.raises(Invalid, match="2 boards match"):
        s.show_board(OWNER, "ops")
    assert s.show_board(OWNER, "Ops north")["title"] == "Ops north"


def test_lists_are_the_boards_own(svc):
    s, bid = svc
    s.add_list(OWNER, bid, "Review")
    with pytest.raises(Invalid):
        s.add_list(OWNER, bid, "review")  # same name, any case
    card = s.add_card(MEMBER, bid, "Proofread", list_name="REVIEW")
    assert card["list"] == "Review"
    with pytest.raises(Invalid, match="no list called"):
        s.move_card(MEMBER, card["id"], "Someday")
    s.update_list(OWNER, bid, "Review", name="QA")
    assert s.card_detail(OWNER, card["id"])["list"] == "QA"


def test_drag_and_drop_puts_a_card_above_another(svc):
    s, bid = svc
    a, b, c = (s.add_card(OWNER, bid, t, list_name="Doing") for t in "abc")
    s.move_card(OWNER, c["id"], "Doing", before_card=a["id"])
    doing = next(l for l in s.show_board(OWNER, bid)["lists"] if l["name"] == "Doing")
    assert [x["title"] for x in doing["cards"]] == ["c", "a", "b"]


def test_every_field_and_its_validation(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan", description="Rough idea", due_date="tomorrow",
                      labels=["Urgent", "urgent", "design"])
    assert card["labels"] == ["urgent", "design"]
    assert card["due_date"] == (TODAY + timedelta(days=1)).isoformat()
    with pytest.raises(Invalid):
        s.add_card(OWNER, bid, "   ")
    with pytest.raises(Invalid):
        s.add_card(OWNER, bid, "x" * 201)
    with pytest.raises(Invalid):
        s.update_card(OWNER, card["id"], due_date="someday")
    out = s.update_card(OWNER, card["id"], due_date=None, labels=[])
    assert out["due_date"] is None and out["labels"] == []


# ---------------------------------------------------------------- history & conflicts
def test_history_remembers_who_what_and_before_after(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Send invoice")
    s.update_card(MEMBER, card["id"], assignee=MEMBER.email, due_date="2026-11-12")
    s.move_card(MEMBER, card["id"], "Done")
    h = s.card_history(OWNER, card["id"])
    assert [e["actor"] for e in h] == [MEMBER.email, MEMBER.email, OWNER.email]
    assert h[0]["changes"]["list"] == ["To do", "Done"]
    assert h[1]["changes"]["assignee"] == [None, MEMBER.email]
    assert h[1]["changes"]["due_date"] == [None, "2026-11-12"]
    assert all(e["at"] for e in h)


def test_a_stale_edit_is_refused_and_nothing_is_written(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Budget")
    v = card["version"]
    s.update_card(MEMBER, card["id"], expected_version=v, title="Budget v2")
    with pytest.raises(Conflict):
        s.update_card(OWNER, card["id"], expected_version=v, title="Budget (mine)")
    assert s.card_detail(OWNER, card["id"])["title"] == "Budget v2"


def test_an_edit_that_changes_nothing_writes_no_history(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Same")
    s.update_card(OWNER, card["id"], title="Same")
    assert len(s.card_history(OWNER, card["id"])) == 1


# ---------------------------------------------------------------- my work & overdue
def test_my_week_groups_my_open_cards(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "Late", assignee=MEMBER.email, due_date="2026-11-01")
    s.add_card(OWNER, bid, "Soon", assignee=MEMBER.email, due_date="2026-11-13")
    s.add_card(OWNER, bid, "Far", assignee=MEMBER.email, due_date="2026-12-30")
    s.add_card(OWNER, bid, "Whenever", assignee=MEMBER.email)
    done = s.add_card(OWNER, bid, "Finished", assignee=MEMBER.email, due_date="2026-11-02")
    s.move_card(OWNER, done["id"], "Done")
    s.add_card(OWNER, bid, "Not mine", assignee=OWNER.email, due_date="2026-11-11")
    week = s.my_cards(MEMBER)
    titles = {k: [c["title"] for c in v] for k, v in week.items() if k != "today"}
    assert titles == {"overdue": ["Late"], "due_soon": ["Soon"], "no_due_date": ["Whenever"],
                      "later": ["Far"]}


def test_overdue_skips_done_cards_and_marks_the_rest(svc):
    s, bid = svc
    late = s.add_card(OWNER, bid, "Late", due_date="2026-11-09")
    s.add_card(OWNER, bid, "Today is fine", due_date="2026-11-10")
    finished = s.add_card(OWNER, bid, "Late but done", due_date="2026-11-01")
    s.move_card(OWNER, finished["id"], "Done")
    assert [c["title"] for c in s.overdue(VIEWER)] == ["Late"]
    assert s.card_detail(OWNER, late["id"])["overdue"] is True
    assert s.my_boards(OWNER)[0]["overdue"] == 1


# ---------------------------------------------------------------- dependencies
def test_what_is_blocking_walks_the_chain_and_says_where_to_start(svc):
    s, bid = svc
    ship = s.add_card(OWNER, bid, "Ship v1")
    qa = s.add_card(OWNER, bid, "QA pass")
    bug = s.add_card(OWNER, bid, "Fix login bug")
    review = s.add_card(OWNER, bid, "Security review")
    s.add_blocker(MEMBER, ship["id"], qa["id"])
    s.add_blocker(MEMBER, qa["id"], bug["id"])
    s.add_blocker(MEMBER, ship["id"], review["id"])
    out = s.what_is_blocking(VIEWER, ship["id"])
    assert {c["number"] for c in out["chain"]} == {2, 3, 4}
    assert {c["title"] for c in out["start_with"]} == {"Fix login bug", "Security review"}
    s.move_card(OWNER, bug["id"], "Done")
    s.move_card(OWNER, review["id"], "Done")
    assert [c["title"] for c in s.what_is_blocking(OWNER, ship["id"])["chain"]] == ["QA pass"]
    assert s.card_detail(OWNER, ship["id"])["blocked_by"] == [2]


def test_cards_cannot_wait_on_each_other_in_a_loop(svc):
    s, bid = svc
    a, b, c = (s.add_card(OWNER, bid, t) for t in "abc")
    s.add_blocker(OWNER, a["id"], b["id"])
    s.add_blocker(OWNER, b["id"], c["id"])
    with pytest.raises(Invalid, match="forever"):
        s.add_blocker(OWNER, c["id"], a["id"])
    with pytest.raises(Invalid):
        s.add_blocker(OWNER, a["id"], a["id"])


def test_blockers_stay_on_one_board(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "here")
    ops = s.create_board(OWNER, "Ops")
    b = s.add_card(OWNER, ops["id"], "there")
    with pytest.raises((Invalid, NotFound)):
        s.add_blocker(OWNER, a["id"], b["id"])


# ---------------------------------------------------------------- AI extras
def test_checklist_suggestion_changes_nothing_until_you_add_it(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Launch page")
    sug = s.suggest_checklist(VIEWER, card["id"])  # viewers may ask; it saves nothing
    assert sug["suggested_steps"] == ["Draft it", "Review it", "Ship it"]
    assert s.card_detail(OWNER, card["id"])["checklist"] == []
    out = s.add_checklist_items(MEMBER, card["id"], sug["suggested_steps"][:2])
    first = out["checklist"][0]
    s.set_checklist_item(MEMBER, card["id"], first["id"], done=True)
    items = s.card_detail(OWNER, card["id"])["checklist"]
    assert [(i["text"], i["done"]) for i in items] == [("Draft it", True), ("Review it", False)]


def test_similar_cards_finds_meaning_only_on_your_boards(svc):
    s, bid = svc
    target = s.add_card(OWNER, bid, "Send invoice to Acme")
    s.add_card(OWNER, bid, "Chase unpaid bill")
    s.add_card(OWNER, bid, "Design the logo")
    secret = s.create_board(STRANGER, "Private")
    s.add_card(STRANGER, secret["id"], "Invoice the secret client")
    out = s.similar_cards(MEMBER, target["id"])
    titles = [c["title"] for c in out["cards"]]
    assert titles[0] == "Chase unpaid bill"
    assert "Invoice the secret client" not in titles and "Send invoice to Acme" not in titles


def test_daily_summary_counts_are_real(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "Late", due_date="2026-11-01")
    out = s.daily_summary(VIEWER, bid)
    assert out["facts"]["overdue"] == [1]
    assert out["summary"].startswith("Briefing for Launch")


# ---------------------------------------------------------------- first visit
def test_first_visit_gets_one_private_sample_board(svc):
    s, _ = svc
    newbie = STRANGER
    boards = s.first_visit(newbie)
    assert [b["title"] for b in boards] == ["Welcome to Cobra 🐍"]
    assert s.first_visit(newbie) == boards  # no second copy
    bid = boards[0]["id"]
    view = s.show_board(newbie, bid)
    assert [l["name"] for l in view["lists"]] == ["To do", "Doing", "Review", "Done"]
    assert s.overdue(newbie) and s.what_is_blocking(newbie, "1", bid)["start_with"]
    with pytest.raises(NotFound):
        s.show_board(OWNER, bid)  # private to the newcomer
    assert s.first_visit(OWNER)[0]["title"] == "Launch"  # people with boards get nothing extra