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