mobiledev/taskflow
TaskFlow, a team task board you run from the web or from Claude and ChatGPT. FastAPI + DataK3 + appid + Models + MCP.
A remix template · 0 remixes · Remix mobiledev/taskflow
tests/test_rules.py
"""TaskFlow's rules, through the use cases: roles, visibility, cards, history, dependencies."""
import pytest
from conftest import MEMBER, OWNER, STRANGER, VIEWER
from taskflow.application.use_cases import NotFound
from taskflow.domain.model import Forbidden, Invalid
# --- roles and visibility -------------------------------------------------------------------
def test_viewer_sees_the_board_but_cannot_change_it(app, ops):
card = app.add_card(OWNER, ops, "Send invoice")
assert app.show_board(VIEWER, ops)["role"] == "viewer"
for attempt in (lambda: app.add_card(VIEWER, ops, "nope"),
lambda: app.move_card(VIEWER, card["id"], "Done"),
lambda: app.update_card(VIEWER, card["id"], title="hijack"),
lambda: app.assign_card(VIEWER, card["id"], VIEWER.email),
lambda: app.add_checklist(VIEWER, card["id"], ["x"])):
with pytest.raises(Forbidden):
attempt()
def test_stranger_cannot_find_the_board_or_its_cards(app, ops):
card = app.add_card(OWNER, ops, "Secret")
assert app.my_boards(STRANGER) == []
with pytest.raises(NotFound):
app.show_board(STRANGER, ops)
with pytest.raises(NotFound):
app.show_board(STRANGER, "Ops") # not even by its title
with pytest.raises(NotFound):
app.show_card(STRANGER, card["id"])
with pytest.raises(NotFound):
app.show_card(STRANGER, "#1")
def test_only_the_owner_manages_members(app, ops):
with pytest.raises(Forbidden):
app.add_member(MEMBER, ops, "eve@x.io", "member")
with pytest.raises(Forbidden):
app.remove_member(MEMBER, ops, VIEWER.email)
with pytest.raises(Invalid):
app.add_member(OWNER, ops, "eve@x.io", "owner") # one owner per board
with pytest.raises(Invalid):
app.remove_member(OWNER, ops, OWNER.email)
def test_owner_promotes_a_viewer_and_it_is_logged(app, ops):
app.add_member(OWNER, ops, VIEWER.email, "member")
app.add_card(VIEWER, ops, "Now I can")
assert any("changed cy@x.io from viewer to member" in e["action"]
for e in app.board_activity(OWNER, ops))
def test_removed_member_loses_access_and_their_cards(app, ops):
card = app.add_card(MEMBER, ops, "Mine", assignee=MEMBER.email)
app.remove_member(OWNER, ops, MEMBER.email)
with pytest.raises(NotFound):
app.show_board(MEMBER, ops)
assert app.show_card(OWNER, card["id"])["assignee"] is None
def test_limits_keep_a_board_healthy(app, ops, monkeypatch):
import taskflow.application.use_cases as uc
monkeypatch.setattr(uc, "MAX_CARDS", 2)
app.add_card(OWNER, ops, "one")
app.add_card(OWNER, ops, "two")
with pytest.raises(Invalid, match="at most 2 cards"):
app.add_card(OWNER, ops, "three")
with pytest.raises(Invalid):
app.add_member(OWNER, ops, "a" * 250 + "@x.io", "member")
def test_viewers_and_strangers_cannot_hold_cards(app, ops):
card = app.add_card(OWNER, ops, "Ship it")
with pytest.raises(Invalid):
app.assign_card(OWNER, card["id"], VIEWER.email)
with pytest.raises(Invalid):
app.assign_card(OWNER, card["id"], STRANGER.email)
# --- boards, lists and cards ---------------------------------------------------------------
def test_cards_are_numbered_per_board_and_found_by_number(app, ops):
first = app.add_card(OWNER, ops, "One")
second = app.add_card(MEMBER, ops, "Two")
other = app.create_board(OWNER, "Marketing")["id"]
app.add_card(OWNER, other, "Other one")
assert (first["number"], second["number"]) == (1, 2)
assert app.show_card(MEMBER, "#2")["title"] == "Two" # only Bo's boards searched
with pytest.raises(Invalid, match="say which board"):
app.show_card(OWNER, "1") # Ana has #1 twice
assert app.show_card(OWNER, "1", board_ref="Marketing")["title"] == "Other one"
def test_an_all_digit_card_id_is_still_found_by_id(app, ops, monkeypatch):
import taskflow.application.use_cases as uc
monkeypatch.setattr(uc, "_new_id", lambda: "153445470148") # random hex can be all digits
card = app.add_card(OWNER, ops, "Digits")
assert app.move_card(OWNER, card["id"], "Done")["list_name"] == "Done"
assert app.show_card(OWNER, "#1")["title"] == "Digits" # "#1" is still a number
def test_card_fields_and_lists_by_name(app, ops):
card = app.add_card(MEMBER, "ops", "Send invoice", list_name="doing",
description="To ACME", assignee=MEMBER.email, due_date="2026-11-05",
labels=["Billing", "billing", "urgent"])
assert card["list_name"] == "Doing"
assert card["labels"] == ["billing", "urgent"]
assert card["due_date"] == "2026-11-05" and card["assignee"] == MEMBER.email
with pytest.raises(Invalid):
app.add_card(MEMBER, ops, "x", list_name="Someday")
with pytest.raises(Invalid):
app.add_card(MEMBER, ops, " ")
with pytest.raises(Invalid):
app.add_card(MEMBER, ops, "x", due_date="next friday")
def test_a_board_can_start_with_its_own_lists(app):
board = app.create_board(OWNER, "Writing", lists=["Ideas", "Drafting", "Published"])
assert [l["name"] for l in board["lists"]] == ["Ideas", "Drafting", "Published"]
card = app.add_card(OWNER, board["id"], "Old post", due_date="2026-01-01")["id"]
app.move_card(OWNER, card, "Published") # no Done list: the last one is done
assert app.overdue(OWNER) == []
def test_owner_adds_lists_and_the_last_list_means_done(app, ops):
app.add_list(OWNER, ops, "Review", before="Done")
board = app.show_board(MEMBER, ops)
assert [l["name"] for l in board["lists"]] == ["To do", "Doing", "Review", "Done"]
with pytest.raises(Forbidden):
app.add_list(MEMBER, ops, "Mine")
with pytest.raises(Invalid):
app.add_list(OWNER, ops, "review")
def test_done_stays_done_when_lists_are_added_after_it(app, ops):
card = app.add_card(OWNER, ops, "Late", due_date="2026-10-01")["id"]
app.add_list(OWNER, ops, "Review") # no position given: goes before Done
assert [l["name"] for l in app.show_board(OWNER, ops)["lists"]] == ["To do", "Doing", "Review", "Done"]
app.add_list(OWNER, ops, "Archive", before="Done")
board = app.repo.get(ops)
board.lists.append("Later") # a list after Done (older boards)
app.repo.save_board(board)
app.move_card(OWNER, card, "Done")
assert app.overdue(OWNER) == [] and app.show_card(OWNER, card)["overdue"] is False
def test_drag_and_drop_order_within_a_list(app, ops):
a, b, c = (app.add_card(OWNER, ops, t)["id"] for t in "abc")
app.move_card(OWNER, c, "To do", index=0)
app.move_card(OWNER, a, "To do", index=1)
titles = [x["title"] for x in app.show_board(OWNER, ops)["lists"][0]["cards"]]
assert titles == ["c", "a", "b"]
# --- history -------------------------------------------------------------------------------
def test_every_change_is_recorded_with_who_and_what(app, ops):
card = app.add_card(MEMBER, ops, "Send invoice")["id"]
app.update_card(MEMBER, card, title="Send invoice to ACME", due_date="2026-11-09")
app.assign_card(OWNER, card, MEMBER.email)
app.move_card(MEMBER, card, "Done")
app.update_card(MEMBER, card, title="Send invoice to ACME") # no real change, no entry
history = app.card_history(OWNER, card)
assert [h["actor"] for h in history] == [MEMBER.email, OWNER.email, MEMBER.email,
MEMBER.email]
moved, assigned, edited, added = history
assert moved["changes"] == {"list_name": ["To do", "Done"]}
assert assigned["changes"] == {"assignee": [None, MEMBER.email]}
assert edited["changes"]["due_date"] == [None, "2026-11-09"]
assert "added #1" in added["action"]
# --- what's on my plate, overdue -----------------------------------------------------------
def test_my_plate_this_week_and_overdue(app, ops):
app.add_card(OWNER, ops, "Late", assignee=MEMBER.email, due_date="2026-10-30")
app.add_card(OWNER, ops, "This week", assignee=MEMBER.email, due_date="2026-11-06")
app.add_card(OWNER, ops, "Next month", assignee=MEMBER.email, due_date="2026-12-01")
app.add_card(OWNER, ops, "Not mine", assignee=OWNER.email, due_date="2026-11-03")
done = app.add_card(OWNER, ops, "Finished late", assignee=MEMBER.email,
due_date="2026-10-01")
app.move_card(OWNER, done["id"], "Done")
week = [c["title"] for c in app.my_cards(MEMBER, due_within_days=7)]
assert week == ["Late", "This week"]
assert len(app.my_cards(MEMBER)) == 3
late = app.overdue(VIEWER)
assert [c["title"] for c in late] == ["Late"] and late[0]["overdue"] is True
# --- dependencies --------------------------------------------------------------------------
def test_what_is_blocking_the_launch(app, ops):
launch, site, copy, logo = (app.add_card(OWNER, ops, t)["id"]
for t in ("Launch", "Website", "Copy", "Logo"))
app.block_card(OWNER, launch, site)
app.block_card(OWNER, site, copy)
app.block_card(OWNER, site, logo)
app.move_card(OWNER, logo, "Done")
blockers = app.what_blocks(VIEWER, "#1")
assert sorted(b["title"] for b in blockers["blockers"]) == ["Copy", "Website"]
with pytest.raises(Invalid, match="loop"):
app.block_card(OWNER, copy, launch)
with pytest.raises(Invalid):
app.block_card(OWNER, launch, launch)
app.unblock_card(OWNER, launch, site)
assert app.what_blocks(OWNER, launch)["blocked"] is False
# --- checklist and AI ----------------------------------------------------------------------
def test_turn_a_card_into_a_checklist_and_tick_it(app, ops):
card = app.add_card(MEMBER, ops, "Launch site")["id"]
suggestion = app.suggest_checklist(VIEWER, card) # read-only, viewers may ask
assert suggestion["suggested_checklist"][0] == "plan Launch site"
assert app.show_card(OWNER, card)["checklist"] == []
view = app.checklist_from_ai(MEMBER, card)
assert view["checklist_progress"] == "0/3"
assert app.tick(MEMBER, card, 2)["checklist_progress"] == "1/3"
with pytest.raises(Invalid):
app.tick(MEMBER, card, 9)
def test_similar_cards_by_meaning(app, ops):
target = app.add_card(OWNER, ops, "Send invoice", description="bill the client")["id"]
app.add_card(OWNER, ops, "Pay the invoice from ACME")
app.add_card(OWNER, ops, "New logo design")
found = [c["title"] for c in app.similar_cards(MEMBER, target)]
assert found == ["Pay the invoice from ACME"]
def test_ai_outage_is_explained_and_the_summary_still_works(app, ops):
class Down:
def __getattr__(self, name):
def fail(*args):
raise RuntimeError("402 credits exhausted")
return fail
app.assistant = Down()
card = app.add_card(OWNER, ops, "Ship it", due_date="2026-10-01")["id"]
for attempt in (lambda: app.checklist_from_ai(OWNER, card),
lambda: app.suggest_checklist(OWNER, card),
lambda: app.similar_cards(OWNER, card)):
app.add_card(OWNER, ops, "another")
with pytest.raises(Invalid, match="AI credits"):
attempt()
summary = app.board_summary(OWNER, ops)
assert "Overdue: #1 Ship it" in summary["summary"] and summary["overdue"] == ["#1"]
def test_daily_summary_counts_facts(app, ops):
app.add_card(OWNER, ops, "Late", due_date="2026-10-01")
summary = app.board_summary(VIEWER, ops)
assert summary["counts"] == {"To do": 1, "Doing": 0, "Done": 0}
assert summary["overdue"] == ["#1"] and summary["summary"].startswith("SUMMARY")
# --- deleting and renaming -----------------------------------------------------------------
def test_delete_card_frees_what_waited_on_it(app, ops):
launch, site = (app.add_card(OWNER, ops, t)["id"] for t in ("Launch", "Site"))
app.block_card(OWNER, launch, site)
with pytest.raises(Forbidden):
app.delete_card(VIEWER, site)
app.delete_card(MEMBER, site)
assert app.what_blocks(OWNER, launch)["blocked"] is False
with pytest.raises(NotFound):
app.show_card(OWNER, site)
assert "deleted #2 'Site'" in app.board_activity(OWNER, ops)[0]["action"]
assert app.add_card(OWNER, ops, "Next")["number"] == 3 # numbers are never reused
def test_rename_and_delete_lists(app, ops):
card = app.add_card(OWNER, ops, "x", list_name="Doing")["id"]
app.rename_list(OWNER, ops, "doing", "In progress")
assert app.show_card(OWNER, card)["list_name"] == "In progress"
with pytest.raises(Forbidden):
app.rename_list(MEMBER, ops, "To do", "Backlog")
with pytest.raises(Invalid, match="still has 1 card"):
app.delete_list(OWNER, ops, "In progress") # never deletes work
app.move_card(OWNER, card, "Done")
app.delete_list(OWNER, ops, "In progress")
assert [l["name"] for l in app.show_board(OWNER, ops)["lists"]] == ["To do", "Done"]
def test_only_the_owner_deletes_a_board_and_must_name_it(app, ops):
app.add_card(OWNER, ops, "x")
with pytest.raises(Forbidden):
app.delete_board(MEMBER, ops, "Ops")
with pytest.raises(Invalid, match="type the board's title"):
app.delete_board(OWNER, ops, "ops?")
app.delete_board(OWNER, ops, "Ops")
assert app.my_boards(OWNER) == [] and app.my_boards(MEMBER) == []