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