dodil/erp-brick-activity

tests/test_rules.py

"""The activity rules, with no database — named for the CRM spec's stories (modules/crm)."""
from datetime import datetime, timezone

import pytest

from brick_activity.domain.activity import (CANCELLED, DONE, OPEN, Activity, ActivityIncomplete,
                                            ActivityRuleBroken, Contact, normalise_email, task_board)

NOW = datetime(2026, 10, 16, 12, 0, tzinfo=timezone.utc)


def log(kind="CALL", **kw):
    return Activity.log("A-1", kind, kw.pop("subject", "Rang Anna"), "tom@co.example", 4242, now=NOW, **kw)


def test_crm02_a_call_is_logged_in_one_step_and_happened_now_unless_told_otherwise():
    a = log(note="wants a proposal by Friday")
    assert (a.kind, a.direction, a.occurred_at) == ("CALL", "outbound", NOW.isoformat())
    assert log(occurred_at="2026-10-15T09:00:00").occurred_at == "2026-10-15T09:00:00+00:00"


def test_something_that_happened_cannot_be_in_the_future_or_have_a_due_date():
    with pytest.raises(ActivityRuleBroken, match="future"):
        log(occurred_at="2026-10-17T09:00:00Z")
    with pytest.raises(ActivityRuleBroken, match="log a TASK"):
        log(due_on="2026-10-20")


def test_a_task_needs_a_due_date_or_it_is_a_note():
    with pytest.raises(ActivityIncomplete, match="due"):
        log("TASK")
    t = log("TASK", subject="Send proposal", due_on="2026-10-20")
    assert (t.status, t.due_on, t.occurred_at) == (OPEN, "2026-10-20", None)


def test_kinds_are_a_closed_list_and_a_subject_is_required():
    with pytest.raises(ActivityIncomplete, match="one of NOTE"):
        log("LUNCH")
    with pytest.raises(ActivityIncomplete, match="subject"):
        log(subject="  ")


def test_completing_a_task_keeps_what_it_said_and_is_idempotent():
    t = log("TASK", subject="Send proposal", due_on="2026-10-20", note="pricing v2")
    assert t.complete(NOW, "sent, she replied") and not t.complete(NOW)
    assert (t.status, t.note) == (DONE, "pricing v2\n\nsent, she replied")
    assert t.reopen() and t.status == OPEN and t.done_at is None
    assert t.cancel() and t.status == CANCELLED


def test_a_call_is_not_a_task():
    with pytest.raises(ActivityRuleBroken, match="record of what happened"):
        log().complete(NOW)


def test_crm12_tasks_grouped_overdue_today_this_week():
    t = lambda s, d: log("TASK", subject=s, due_on=d)
    board = task_board([t("late", "2026-10-10"), t("now", "2026-10-16"), t("soon", "2026-10-22"),
                        t("later", "2026-11-30")], "2026-10-16")
    assert {k: [a.subject for a in v] for k, v in board.items()} == {
        "overdue": ["late"], "today": ["now"], "this_week": ["soon"]}


def test_a_done_task_is_not_on_the_board():
    t = log("TASK", subject="x", due_on="2026-10-10")
    t.complete(NOW)
    assert task_board([t], "2026-10-16")["overdue"] == []


def test_emails_are_compared_lower_case_and_must_be_emails():
    assert normalise_email(" Anna@Acme.COM ") == "anna@acme.com"
    with pytest.raises(ActivityIncomplete):
        normalise_email("anna at acme")


def test_a_contact_who_left_is_unsubscribed_and_kept():
    c = Contact.new("C-1", 4242, "Anna  Berg", "anna@acme.com", "tom@co.example")
    c.subscribed = True
    c.left_company()
    assert (c.status, c.subscribed, c.full_name) == ("left", False, "Anna Berg")