sirenamc/stackline

Stackline, a team task board you run from the web or from Claude/ChatGPT. FastAPI + DataK3 + appid + Models + MCP.

A remix template · 0 remixes · Remix sirenamc/stackline

tests/test_boards.py

"""The board's rules, through the use cases. No Dodil account needed (see conftest.py)."""
import pytest

from boards.application.use_cases import NotFound
from boards.domain.model import Forbidden, Invalid
from conftest import MEMBER, OWNER, STRANGER, VIEWER


# ---- roles and visibility -------------------------------------------------------------------

def test_viewer_can_read_but_not_change_anything(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Send invoice")
    assert s.show_board(VIEWER, bid)["role"] == "viewer"
    for attempt in (lambda: s.add_card(VIEWER, bid, "nope"),
                    lambda: s.move_card(VIEWER, card["id"], "Done"),
                    lambda: s.assign_card(VIEWER, card["id"], "me"),
                    lambda: s.update_card(VIEWER, card["id"], title="x"),
                    lambda: s.delete_card(VIEWER, card["id"]),
                    lambda: s.add_list(VIEWER, bid, "Later"),
                    lambda: s.block_card(VIEWER, card["id"], card["id"])):
        with pytest.raises(Forbidden):
            attempt()


def test_only_owner_manages_members(svc):
    s, bid = svc
    with pytest.raises(Forbidden):
        s.invite_member(MEMBER, bid, "eve@x.io", "member")
    with pytest.raises(Forbidden):
        s.remove_member(MEMBER, bid, VIEWER.email)
    with pytest.raises(Invalid):
        s.invite_member(OWNER, bid, "eve@x.io", "owner")


def test_strangers_see_nothing_not_even_by_title_or_card_number(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Secret")
    assert s.my_boards(STRANGER) == []
    for attempt in (lambda: s.show_board(STRANGER, bid),
                    lambda: s.show_board(STRANGER, "Ops"),
                    lambda: s.card_details(STRANGER, card["id"]),
                    lambda: s.move_card(STRANGER, card["id"], "Done")):
        with pytest.raises(NotFound):
            attempt()
    assert s.overdue(STRANGER) == [] and s.my_plate(STRANGER)["no_due_date"] == []


def test_removed_member_loses_the_board_and_their_cards_are_unassigned(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Ship", assignee=MEMBER.email)
    s.remove_member(OWNER, bid, MEMBER.email)
    with pytest.raises(NotFound):
        s.show_board(MEMBER, bid)
    assert s.card_details(OWNER, card["id"])["assignee"] is None


def test_viewer_promoted_to_member_can_edit(svc):
    s, bid = svc
    s.invite_member(OWNER, bid, VIEWER.email, "member")
    assert s.add_card(VIEWER, bid, "now I can")["title"] == "now I can"


# ---- cards, lists, moving -------------------------------------------------------------------

def test_cards_get_short_numbers_and_can_be_named_like_people_do(svc):
    s, bid = svc
    a = s.add_card(OWNER, "ops", "First")              # board by title, any case
    b = s.add_card(OWNER, bid, "Second", list_name="doing")
    assert (a["number"], b["number"]) == (1, 2) and b["list"] == "Doing"
    assert s.move_card(MEMBER, "#2", "done")["list"] == "Done"


def test_unknown_list_is_refused_with_the_real_names(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_drag_and_drop_position_orders_cards_inside_a_list(svc):
    s, bid = svc
    for t in ("a", "b", "c"):
        s.add_card(OWNER, bid, t)
    s.move_card(OWNER, "3", "To do", position=0)
    titles = [c["title"] for c in s.show_board(OWNER, bid)["lists"][0]["cards"]]
    assert titles == ["c", "a", "b"]


def test_custom_lists_and_done_flag(svc):
    s, bid = svc
    s.add_list(OWNER, bid, "Review")
    s.rename_list(OWNER, bid, "Review", "In review")
    lists = [x["name"] for x in s.show_board(OWNER, bid)["lists"]]
    assert lists == ["To do", "Doing", "Done", "In review"]
    with pytest.raises(Invalid):
        s.add_list(OWNER, bid, "done")


def test_assignee_must_be_on_the_board(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x")
    with pytest.raises(Invalid):
        s.assign_card(OWNER, card["id"], STRANGER.email)
    assert s.assign_card(MEMBER, card["id"], "me")["assignee"] == MEMBER.email


# ---- activity log ---------------------------------------------------------------------------

def test_every_change_is_logged_with_who_what_before_and_after(svc):
    s, bid = svc
    card = s.add_card(MEMBER, bid, "Send invoice")
    s.update_card(MEMBER, card["id"], title="Send invoice to ACME", due_date="2026-11-12",
                  labels=["Finance", "finance", " money "])
    s.update_card(MEMBER, card["id"], title="Send invoice to ACME")  # no change, no log line
    s.assign_card(OWNER, card["id"], MEMBER.email)
    s.move_card(MEMBER, card["id"], "Doing")
    hist = s.card_details(OWNER, card["id"])["history"]
    seen = {(h["action"], h["field"], h["before"], h["after"], h["who"]) for h in hist}
    assert ("edited", "title", "Send invoice", "Send invoice to ACME", MEMBER.email) in seen
    assert ("edited", "due_date", None, "2026-11-12", MEMBER.email) in seen
    assert ("edited", "labels", None, "finance, money", MEMBER.email) in seen
    assert ("assigned", "assignee", None, MEMBER.email, OWNER.email) in seen
    assert ("moved", "list", "To do", "Doing", MEMBER.email) in seen
    assert len([h for h in hist if h["field"] == "title"]) == 1
    assert all(h["at"] for h in hist)


# ---- what needs attention -------------------------------------------------------------------

def test_my_plate_this_week_and_overdue(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "late", assignee=MEMBER.email, due_date="2026-11-08")
    s.add_card(OWNER, bid, "this week", assignee=MEMBER.email, due_date="2026-11-14")
    s.add_card(OWNER, bid, "next month", assignee=MEMBER.email, due_date="2026-12-20")
    s.add_card(OWNER, bid, "someday", assignee=MEMBER.email)
    s.add_card(OWNER, bid, "finished late", list_name="Done", assignee=MEMBER.email,
               due_date="2026-11-01")
    s.add_card(OWNER, bid, "someone else's", assignee=OWNER.email, due_date="2026-11-09")
    plate = s.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_due_date"]] == ["someday"]
    late = s.overdue(VIEWER)
    assert [(c["title"], c["days_late"]) for c in late] == [("late", 2), ("someone else's", 1)]
    assert all(c["overdue"] for c in late)


def test_search_by_words(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "Send invoice", description="ACME, net 30", labels=["finance"])
    s.add_card(OWNER, bid, "Fix login bug")
    assert [c["title"] for c in s.search_cards(VIEWER, "acme invoice")] == ["Send invoice"]
    assert [c["title"] for c in s.search_cards(VIEWER, "finance")] == ["Send invoice"]


# ---- dependencies ---------------------------------------------------------------------------

def test_blocked_by_chain_and_loops_refused(svc):
    s, bid = svc
    launch = s.add_card(OWNER, bid, "Launch")
    site = s.add_card(OWNER, bid, "Website")
    copy = s.add_card(OWNER, bid, "Copy")
    s.block_card(OWNER, launch["id"], site["id"])
    s.block_card(OWNER, site["id"], copy["id"])
    chain = s.what_is_blocking(VIEWER, launch["id"])
    assert [c["title"] for c in chain["blocking"]] == ["Website", "Copy"] and not chain["ready"]
    with pytest.raises(Invalid, match="loop"):
        s.block_card(OWNER, copy["id"], launch["id"])
    s.move_card(OWNER, copy["id"], "Done")
    s.move_card(OWNER, site["id"], "Done")
    assert s.what_is_blocking(OWNER, launch["id"])["ready"]


def test_blocking_stays_inside_one_board(svc):
    s, bid = svc
    other = s.create_board(OWNER, "Other")
    a = s.add_card(OWNER, bid, "a")
    b = s.add_card(OWNER, other["id"], "b")
    with pytest.raises(Invalid):
        s.block_card(OWNER, a["id"], b["id"])


# ---- checklist and AI -----------------------------------------------------------------------

def test_ai_checklist_is_only_a_suggestion_until_kept(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "Plan launch")
    sug = s.suggest_checklist(VIEWER, card["id"])["suggested_checklist"]
    assert sug and s.card_details(OWNER, card["id"])["checklist"] == []
    s.add_checklist(MEMBER, card["id"], sug)
    s.check_item(MEMBER, card["id"], 1)
    assert [i["done"] for i in s.card_details(OWNER, card["id"])["checklist"]] == [False, True]
    with pytest.raises(Invalid):
        s.check_item(MEMBER, card["id"], 5)


def test_similar_cards_by_meaning_and_embeddings_are_cached(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "Send invoice to ACME")
    s.add_card(OWNER, bid, "Chase unpaid invoice")
    s.add_card(OWNER, bid, "Design the launch page")
    hits = s.similar_cards(MEMBER, a["id"], limit=1)
    assert hits[0]["title"] == "Chase unpaid invoice"
    calls = s.assistant.embedded
    s.similar_cards(MEMBER, a["id"])
    assert s.assistant.embedded == calls   # nothing changed, nothing re-embedded
    s.update_card(OWNER, a["id"], title="Design launch banner")
    s.similar_cards(MEMBER, a["id"])
    assert s.assistant.embedded == calls + 1


def test_similar_cards_never_leak_other_peoples_boards(svc):
    s, bid = svc
    private = s.create_board(STRANGER, "Private")
    s.add_card(STRANGER, private["id"], "Send invoice secret")
    a = s.add_card(OWNER, bid, "Send invoice")
    assert all(h["board_id"] == bid for h in s.similar_cards(OWNER, a["id"]))


def test_daily_summary_counts_facts_itself(svc):
    s, bid = svc
    s.add_card(OWNER, bid, "late one", due_date="2026-11-01")
    out = s.board_summary(VIEWER, "Ops")
    assert out["overdue"] == 1 and "#1 late one" in out["summary"]


def test_deleting_a_card_clears_links_to_it(svc):
    s, bid = svc
    a = s.add_card(OWNER, bid, "a")
    b = s.add_card(OWNER, bid, "b")
    s.block_card(OWNER, a["id"], b["id"])
    s.delete_card(MEMBER, b["id"])
    assert s.card_details(OWNER, a["id"])["blocked_by"] == []


# ---- found by a security review ---------------------------------------------------------------

def test_card_numbers_are_never_reused_so_history_never_carries_over(svc):
    s, bid = svc
    old = s.add_card(OWNER, bid, "old secret")
    s.update_card(OWNER, old["id"], description="the password is in the safe")
    s.delete_card(OWNER, old["id"])
    new = s.add_card(MEMBER, bid, "fresh")
    assert new["number"] != old["number"]
    assert all(h["card"] == new["number"] for h in s.card_details(MEMBER, new["id"])["history"])


def test_a_stale_board_save_cannot_bring_back_a_removed_member(svc):
    s, bid = svc
    stale = s.repo.get(bid)                     # someone loaded the board before the removal
    s.remove_member(OWNER, bid, MEMBER.email)
    s.repo.save_board(stale)                    # ...and saves it afterwards
    s.add_list(OWNER, bid, "Later")
    with pytest.raises(NotFound):
        s.show_board(MEMBER, bid)


def test_a_failing_model_gives_a_clear_message_and_changes_nothing(svc):
    s, bid = svc
    card = s.add_card(OWNER, bid, "x")

    def down(*args):
        raise TimeoutError("model timed out")
    s.assistant.checklist = down
    s.assistant.summary = down
    with pytest.raises(Invalid, match="busy or slow"):
        s.suggest_checklist(OWNER, card["id"])
    assert "Board: Ops" in s.board_summary(OWNER, bid)["summary"]  # falls back to the facts