dodil/erp-brick-contracts
tests/test_rules.py
"""The rules with no database: one test per sentence a business person would agree to (modules/contracts stories and
domain_model invariants), against the domain objects alone."""
from datetime import date, datetime, timedelta, timezone
import pytest
from brick_contracts.domain import packs
from brick_contracts.domain.contract import Contract, ExecutedCopy, Section
from brick_contracts.domain.contract_type import ContractType
from brick_contracts.domain.errors import Invalid, NotFound, RuleBroken
from brick_contracts.domain.library import Clause, Template, VariableDef, slots_for
from brick_contracts.domain.policies import (ApprovalPolicy, ApprovalRule, Configuration, Consumer, RetentionRule)
from brick_contracts.domain.services import (CLAIM, FORBIDDEN_CLAIMS, CertificateBuilder, ChainVerifier, DeviationCheck,
Resolver)
from brick_contracts.domain.values import (ClausePosition, ConsentText, Fingerprint, VariableValue, contract_id_for,
namespace_of)
T0 = datetime(2026, 10, 19, 9, 0, tzinfo=timezone.utc)
CONSENT = ConsentText("v1", "I agree to sign this document electronically.")
POLICY = ApprovalPolicy("standard", (ApprovalRule(1, "*", "legal", "director", 2), ApprovalRule(2, "bound", "finance",
"director", 2)))
def config():
return Configuration(policies={"standard": POLICY}, retention={"simple-6y": RetentionRule("simple-6y", 6, 30),
"deed-12y": RetentionRule("deed-12y", 12, 30)})
def contract(**kw):
c = Contract("ct-1", "nda", "nda-std", 1, 42, "manual:F1", "owner.1@x", "owner.1@x", T0, title="NDA")
c.sections = [Section(1, "confidentiality", "conf_std", 1, "PREFERRED", 0, "Confidentiality", "Keep it secret."),
Section(2, "liability_cap", "liability_std", 1, "PREFERRED", 0, "Liability", "Capped.")]
c.add_signer(kind="EXTERNAL", side="THEM", signing_order=1, email="sam@nine.example", full_name="Sam Nine")
c.add_signer(kind="INTERNAL", side="US", signing_order=2, email="dana@acme.example", full_name="Dana Director")
for k, v in kw.items():
setattr(c, k, v)
c.review([], [], now=T0, by="owner.1@x")
return c
def sent(c=None):
c = c or contract()
c.freeze(object_key="k", sha256="a" * 64, size_bytes=10, text_body="words", now=T0, by="owner",
link_lifetime=timedelta(days=14))
return c
def fallback(c):
fb = Clause.new("liability_fb", "liability_cap", "FALLBACK", 1, "Liability", "Twice the fees.")
c.choose_position(2, fb)
c.review([], DeviationCheck(POLICY, None).check(c.sections, c.variables), now=T0, by="owner.1@x")
return c
# ── identity and values ────────────────────────────────────────────────────────────────────
def test_ct_50_the_contract_id_is_derived_from_source_ref_and_type():
assert contract_id_for("hr:onboarding:1#3", "employment_contract") == contract_id_for("hr:onboarding:1#3",
"employment_contract")
assert contract_id_for("hr:onboarding:1#3", "nda") != contract_id_for("hr:onboarding:1#3", "employment_contract")
assert contract_id_for("x", "y").startswith("ct-") and len(contract_id_for("x", "y")) == 19
assert namespace_of("hr:onboarding:1#3") == "hr" and namespace_of("manual:F1") == "manual"
def test_money_is_an_exact_decimal_with_its_currency_never_a_float():
v = VariableValue.parse("fee", "money", "750", "PAY", currency="gbp")
assert (v.value, v.currency, v.display()) == ("750.00", "GBP", "GBP 750.00")
with pytest.raises(Invalid):
VariableValue.parse("fee", "money", 750.5, "PAY", currency="GBP")
with pytest.raises(Invalid):
VariableValue.parse("fee", "money", "750.123", "PAY", currency="GBP")
with pytest.raises(Invalid):
VariableValue.parse("fee", "money", "750", "PAY")
assert VariableValue.parse("rate", "rate", "412.3077", "PAY", currency="GBP").value == "412.3077"
def test_an_enum_takes_the_callers_words_through_its_aliases():
v = VariableValue.parse("client_size_regime", "enum", "medium", "CALLER", enum_values=("SMALL", "MEDIUM_LARGE"),
aliases={"medium": "MEDIUM_LARGE"})
assert v.value == "MEDIUM_LARGE"
with pytest.raises(Invalid):
VariableValue.parse("x", "enum", "huge", "CALLER", enum_values=("SMALL",))
def test_positions_carry_their_deviation_levels():
assert not ClausePosition("PREFERRED", 0).deviates
assert ClausePosition("FALLBACK", 1).deviates and ClausePosition.override().deviation_level == 2
for bad in (("PREFERRED", 1), ("FALLBACK", 0), ("OVERRIDE", 1)):
with pytest.raises(Invalid):
ClausePosition(*bad)
# ── types and policies (CT-02) ─────────────────────────────────────────────────────────────
def test_ct_02_a_deed_type_is_never_signed_online():
t = ContractType.define({"type_code": "deed", "label": "deed", "allowed_bp_roles": ["supplier"],
"execution": "DEED", "esign_allowed": True, "retention_rule": "deed-12y",
"approval_policy": "standard"}, config())
assert t.esign_allowed is False and not t.can_esign()
def test_ct_02_every_type_names_a_retention_rule_and_an_approval_policy():
for row in ({"retention_rule": "nope", "approval_policy": "standard"},
{"retention_rule": "simple-6y", "approval_policy": "nope"}):
with pytest.raises(RuleBroken):
ContractType.define({"type_code": "t", "label": "t", "allowed_bp_roles": ["customer"], **row}, config())
def test_ct_02_a_freelance_contract_with_a_customer_only_partner_is_refused():
t = ContractType.define({"type_code": "freelance_agreement", "label": "freelance services agreement",
"allowed_bp_roles": ["worker", "supplier"], "retention_rule": "simple-6y",
"approval_policy": "standard"}, config())
with pytest.raises(RuleBroken, match="needs a partner with the supplier or worker role"):
t.check_counterparty({"roles": ["customer"], "kind": "organisation"})
t.check_counterparty({"roles": ["supplier"], "kind": "organisation"})
def test_a_starter_without_a_role_yet_is_allowed_where_the_type_says_so():
row = {"type_code": "employment_contract", "label": "employment contract", "allowed_bp_roles": ["employee"],
"retention_rule": "simple-6y", "approval_policy": "standard"}
with pytest.raises(RuleBroken):
ContractType.define(row, config()).check_counterparty({"roles": [], "kind": "person"})
ContractType.define({**row, "allow_roleless_person": True}, config()).check_counterparty({"roles": [], "kind": "person"})
with pytest.raises(RuleBroken, match="employee role"):
ContractType.define({**row, "allow_roleless_person": True}, config()).check_counterparty(
{"roles": ["supplier"], "kind": "person"})
def test_an_approval_policy_has_a_mandatory_fallback_and_a_catch_all():
with pytest.raises(Invalid, match="fallback"):
ApprovalRule(1, "*", "legal", "")
with pytest.raises(Invalid, match="catch-all"):
ApprovalPolicy("p", (ApprovalRule(1, "bound", "finance", "director"),))
assert POLICY.rule_for("bound", "rate_amount").approver_group == "finance"
assert POLICY.rule_for("position", "liability_cap", "FALLBACK").approver_group == "legal"
def test_a_consumer_is_matched_by_sub_role_or_email():
hr = Consumer("hr", ("sub:hr-system", "role:hr_service"))
assert hr.matches({"sub": "hr-system"}) and hr.matches({"sub": "x", "roles": ["hr_service"]})
assert not hr.matches({"sub": "crm-adapter", "roles": ["service"]})
# ── the library (CT-01, CT-03, CT-04) ──────────────────────────────────────────────────────
def test_ct_01_amending_cuts_a_new_version_and_old_ones_stay():
c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
assert c.amend(None, "Assigned on creation, worldwide.", by="sam", at=T0) == 2
assert c.versions[1].body == "Assigned on creation." and c.current_version == 2
assert c.amend(None, "Assigned on creation, worldwide.", by="sam", at=T0) == 2 # the same words: one version
def test_ct_01_editing_a_version_a_contract_holds_is_refused():
c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
with pytest.raises(RuleBroken, match="version 1 is on 3 contracts, amend it into version 2"):
c.edit_version(1, None, "new words", contracts_holding=3, templates_pinning=[])
def test_ct_01_a_required_clause_cannot_be_retired_while_a_template_requires_it():
c = Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "Assigned on creation.")
with pytest.raises(RuleBroken, match="required by template"):
c.retire(["freelance-std@1"])
c.retire([])
assert c.status == "retired"
def _template():
t = Template.new("t1", "nda", "NDA")
t.keep_draft([{"slot_no": 1, "family": "ip_assignment", "clause_id": "ip_full", "clause_version": 1}],
[{"name": "start_date", "type": "date"}], by="sam", at=T0)
return t
def test_ct_04_the_author_may_not_approve_their_own_template_version():
t = _template()
lookup = lambda cid: Clause.new("ip_full", "ip_assignment", "PREFERRED", 0, "IP", "words") # noqa: E731
with pytest.raises(RuleBroken, match="someone else approves it"):
t.approve(1, by="sam", at=T0, clause_lookup=lookup)
t.approve(1, by="priya", at=T0, clause_lookup=lookup)
assert t.current_approved().version == 1
def test_ct_04_an_approved_version_is_immutable_an_edit_is_a_new_version():
t = _template()
t.approve(1, by="priya", at=T0, clause_lookup=lambda cid: Clause.new(cid, "ip_assignment", "PREFERRED", 0, "IP", "w"))
n = t.keep_draft([{"slot_no": 1, "family": "ip_assignment", "clause_id": "ip_full", "clause_version": 2}], [],
by="sam", at=T0)
assert n == 2 and t.versions[1].slots[0].clause_version == 1 and t.versions[2].status == "DRAFT"
def test_ct_03_a_condition_chooses_the_alternative_slot():
t = Template.new("t1", "freelance_agreement", "F")
t.keep_draft([{"slot_no": 1, "family": "sub", "clause_id": "std", "clause_version": 1},
{"slot_no": 2, "family": "sub", "kind": "alternative_of", "alternative_of_slot": 1,
"clause_id": "psc", "clause_version": 1, "condition": {"var": "engagement_via", "eq": "PSC"}}],
[], by="sam", at=T0)
v = t.versions[1]
assert [s.clause_id for s in slots_for(v, {"engagement_via": "SOLE_TRADER"})] == ["std"]
assert [s.clause_id for s in slots_for(v, {"engagement_via": "PSC"})] == ["psc"]
# ── resolving values (CT-05, CT-51) ────────────────────────────────────────────────────────
def _version(*defs):
t = Template.new("t1", "nda", "NDA")
t.keep_draft([{"slot_no": 1, "family": "f", "clause_id": "c", "clause_version": 1}], list(defs), by="s", at=T0)
return t.versions[1]
def test_ct_51_the_counterparty_comes_from_the_partner_never_from_what_is_typed():
v = _version({"name": "counterparty_name", "type": "text", "source": "COUNTERPARTY"})
r = Resolver(v, {"legal_name": "Studio Nine Ltd"}, {"counterparty_name": "Typed Ltd"}, at=T0).resolve()
assert r.variables["counterparty_name"].value == "Studio Nine Ltd"
def test_ct_05_missing_required_values_are_listed_and_defaults_fill_the_rest():
v = _version({"name": "start_date", "type": "date", "source": "DATES"},
{"name": "cont", "type": "date", "source": "DATES", "validation": {"default_from": "start_date"}},
{"name": "holiday", "type": "text", "source": "TEMPLATE"},
{"name": "hours", "type": "text", "source": "TEMPLATE", "default_value": "37.5"})
r = Resolver(v, {}, {"start_date": "2026-11-02"}, at=T0).resolve()
assert r.missing == ["holiday"] and r.variables["cont"].value == "2026-11-02" and r.variables["hours"].value == "37.5"
def test_ct_07_a_value_above_its_band_and_a_fallback_are_deviations_with_their_approvers():
v = _version({"name": "rate_amount", "type": "rate", "source": "PAY",
"validation": {"min": "300", "max": "600", "currency": "GBP"}})
vars_ = {"rate_amount": VariableValue.parse("rate_amount", "rate", "750", "PAY", currency="GBP")}
c = fallback(contract())
devs = DeviationCheck(POLICY, v).check(c.sections, vars_)
assert [(d.kind, d.needs) for d in devs] == [("position", "legal"), ("bound", "finance")]
assert "liability cap is a Fallback" in devs[0].what and "above its band (300–600 GBP)" in devs[1].what
# ── approval (CT-07) ───────────────────────────────────────────────────────────────────────
def test_ct_07_an_approval_binds_to_the_fingerprint_and_an_edit_voids_it():
c = fallback(contract())
f1 = c.draft_fingerprint
a = c.open_approvals()[0]
assert c.state == "IN_APPROVAL" and a.draft_fingerprint == f1 and a.approver_group == "legal"
c.decide(a.seq, True, by="lee@legal", groups={"legal"}, now=T0)
assert c.state == "APPROVED" and c.approval_granted()
c.override_section(1, None, "Keep it very secret.")
c.review([], DeviationCheck(POLICY, None).check(c.sections, c.variables), now=T0, by="owner.1@x")
assert c.draft_fingerprint != f1 and a.state == "void" and c.state == "IN_APPROVAL" and not c.approval_granted()
def test_ct_07_an_owner_cannot_approve_their_own_deviation():
c = fallback(contract())
with pytest.raises(RuleBroken, match="you own this draft"):
c.decide(c.open_approvals()[0].seq, True, by="owner.1@x", groups={"legal"}, now=T0)
def test_ct_07_an_undecided_approval_goes_to_the_fallback_group_and_is_theirs():
c = fallback(contract())
a = c.open_approvals()[0]
assert c.escalate_due(a.due_at + timedelta(minutes=1)) == [a] and a.state == "escalated"
with pytest.raises(RuleBroken, match="went to director"):
c.decide(a.seq, True, by="lee", groups={"legal"}, now=T0)
c.decide(a.seq, True, by="dana", groups={"director"}, now=T0)
assert c.state == "APPROVED"
def test_ct_07_a_rejection_says_why_and_returns_to_draft():
c = fallback(contract())
with pytest.raises(Invalid):
c.decide(c.open_approvals()[0].seq, False, by="lee", groups={"legal"}, now=T0)
c.decide(c.open_approvals()[0].seq, False, by="lee", groups={"legal"}, now=T0, reason="too generous")
assert c.state == "DRAFT" and "too generous" in c.last_note
# ── send (CT-08) ───────────────────────────────────────────────────────────────────────────
def test_ct_08_send_is_refused_in_words_for_every_reason_at_once():
ct = ContractType("deed", "deed of assignment", ("supplier",), "DEED", False)
c = contract()
c.signers = [s for s in c.signers if s.side == "THEM"]
c.missing = ["start_date"]
out = c.send_refusals(ct, ["an outside determination … (sds_issued_on)"])
assert any("not signed online here" in r for r in out) and "missing: start_date" in out
assert "nobody on our side is named, so this cannot be complete" in out and out[-1].startswith("an outside")
def test_ct_08_freezing_makes_version_n_and_a_resend_is_n_plus_1():
c = sent()
assert c.state == "OUT_FOR_SIGNATURE" and c.current_version == 1
c.recall("typo", now=T0)
c2 = sent(c)
assert c2.current_version == 2 and c2.versions[1].status == "SUPERSEDED" and c2.versions[1].sha256 == "a" * 64
def test_editing_out_for_signature_is_refused_recall_it_first():
c = sent()
with pytest.raises(RuleBroken, match="version 1 is out for signature; recall it to"):
c.override_section(1, None, "new")
# ── signing (CT-09, CT-17) ─────────────────────────────────────────────────────────────────
def sign(c, n, via, **kw):
return c.sign(n, version=kw.pop("version", c.current_version), sha_shown=kw.pop("sha", c.current.sha256),
typed_name=kw.pop("name", "Sam Nine"), consented=kw.pop("consented", True), consent=CONSENT, now=T0,
via=via, **kw)
def test_ct_09_signing_out_of_order_is_refused_naming_who_it_waits_on():
c = sent()
with pytest.raises(RuleBroken, match="waiting on Sam Nine"):
sign(c, 2, "INTERNAL", name="Dana Director")
def test_ct_09_a_stale_version_is_refused():
c = sent()
with pytest.raises(RuleBroken, match="re-issued: open the new link"):
sign(c, 1, "EXTERNAL", version=0)
with pytest.raises(RuleBroken, match="re-issued"):
sign(c, 1, "EXTERNAL", sha="b" * 64)
def test_ct_09_consent_and_a_typed_name_are_required_and_twice_is_one_signature():
c = sent()
with pytest.raises(Invalid):
sign(c, 1, "EXTERNAL", consented=False)
with pytest.raises(Invalid):
sign(c, 1, "EXTERNAL", name=" ")
assert sign(c, 1, "EXTERNAL", ip="198.51.100.7", user_agent="Firefox") is True
assert sign(c, 1, "EXTERNAL") is False
ev = [e for e in c.events if e.kind == "SIGNED"]
assert len(ev) == 1 and ev[0].ip == "198.51.100.7" and ev[0].user_agent == "Firefox" and ev[0].version == 1
assert ev[0].version_sha256_shown == "a" * 64 and ev[0].auth_method == "EMAIL_LINK"
def test_ct_17_a_link_never_signs_for_our_side_and_the_app_never_for_theirs():
c = sent()
with pytest.raises(NotFound):
sign(c, 2, "EXTERNAL")
with pytest.raises(NotFound):
sign(c, 1, "INTERNAL")
def test_our_side_signs_as_its_pool_identity():
c = sent()
sign(c, 1, "EXTERNAL")
sign(c, 2, "INTERNAL", pool_sub="dana.sub")
ev = [e for e in c.events if e.kind == "SIGNED"][-1]
assert (ev.signer_kind, ev.auth_method, ev.pool_sub) == ("INTERNAL", "POOL_SESSION", "dana.sub") and c.all_signed
def test_our_side_must_be_internal_and_theirs_external():
c = contract()
with pytest.raises(RuleBroken):
c.add_signer(kind="EXTERNAL", side="US", signing_order=1, email="a@b.c", full_name="A")
with pytest.raises(RuleBroken):
c.add_signer(kind="INTERNAL", side="THEM", signing_order=1, email="a@b.c", full_name="A")
# ── evidence (CT-31, CT-32) ────────────────────────────────────────────────────────────────
def test_ct_32_events_are_hash_chained_and_a_changed_row_is_found():
c = sent()
sign(c, 1, "EXTERNAL", ip="198.51.100.7")
assert ChainVerifier.verify(c.contract_id, c.events) == (True, None)
c.events[3].ip = "203.0.113.66"
assert ChainVerifier.verify(c.contract_id, c.events) == (False, 4)
def test_ct_32_a_removed_row_breaks_the_chain_where_it_was():
c = sent()
sign(c, 1, "EXTERNAL")
del c.events[1]
assert ChainVerifier.verify(c.contract_id, c.events) == (False, 2)
def copy(provenance="GENERATED"):
return ExecutedCopy("k2", "e" * 64, 20, provenance, 1, "x", T0, "f.pdf", "application/pdf")
def test_ct_31_signed_needs_every_signature_and_a_valid_chain():
c = sent()
sign(c, 1, "EXTERNAL")
with pytest.raises(RuleBroken, match="not everyone has signed"):
c.execute(copy(), now=T0, chain_ok=True)
sign(c, 2, "INTERNAL")
with pytest.raises(RuleBroken, match="chain does not verify"):
c.execute(copy(), now=T0, chain_ok=False)
c.execute(copy(), now=T0, chain_ok=True)
assert c.state == "SIGNED" and c.executed.sha256 == "e" * 64 and c.current.status == "SIGNED"
def test_ct_12_signed_elsewhere_never_marks_a_signer_signed():
c = contract()
c.file_signed_elsewhere(copy("UPLOADED"), now=T0, by="owner")
assert c.state == "SIGNED_ELSEWHERE" and {s.status for s in c.signers} == {"SIGNED_ELSEWHERE"}
assert not [e for e in c.events if e.kind == "SIGNED"] and c.events[-1].kind == "SIGNED_ELSEWHERE"
def test_ct_13_the_certificate_says_signed_electronically_and_nothing_more():
c = sent()
sign(c, 1, "EXTERNAL", ip="198.51.100.7", user_agent="Firefox")
lines = "\n".join(CertificateBuilder.lines(contract_id=c.contract_id, title=c.title, version=c.current,
signers=c.signers, events=c.events, chain=(True, None)))
assert CLAIM in lines and "198.51.100.7" in lines and "a" * 64 in lines
assert not any(w in lines.lower() for w in FORBIDDEN_CLAIMS)
# ── lifecycle (CT-10, CT-14 … CT-16, CT-33, CT-34) ─────────────────────────────────────────
def test_ct_10_a_decline_revokes_the_other_links_and_changes_go_back_to_draft():
c = sent()
c.signers[1].link_id = "L2"
c.signers[0].link_id = "L1"
links = c.decline(1, "the fee is wrong", now=T0, via="EXTERNAL", version=1, sha_shown="a" * 64)
assert c.state == "DECLINED" and set(links) == {"L1", "L2"}
c2 = sent()
c2.request_changes(1, "add a break clause", now=T0, via="EXTERNAL", version=1, sha_shown="a" * 64)
assert c2.state == "DRAFT" and c2.versions[1].status == "SUPERSEDED"
def test_ct_16_a_signed_contract_cannot_be_cancelled():
c = sent()
sign(c, 1, "EXTERNAL")
sign(c, 2, "INTERNAL")
c.execute(copy(), now=T0, chain_ok=True)
with pytest.raises(RuleBroken, match="terminate it instead"):
c.cancel("no", now=T0)
def test_ct_15_the_notice_deadline_is_its_own_date_with_its_reminders():
c = contract(ends_on=date(2026, 12, 31), notice_days=90, starts_on=date(2026, 1, 1))
c.plan_key_dates(lambda k: (120, 90, 60, 30) if k == "NOTICE_DEADLINE" else (30,), "ops")
k = next(x for x in c.key_dates if x.kind == "NOTICE_DEADLINE")
assert k.due_on == date(2026, 10, 2) and k.next_remind_on == date(2026, 6, 4)
assert [d.isoformat() for _, d in k.pending_slots()] == ["2026-06-04", "2026-07-04", "2026-08-03", "2026-09-02"]
def test_ct_33_active_and_expired_are_derived_from_the_term():
c = contract(starts_on=date(2026, 11, 1), ends_on=date(2027, 3, 31))
c.state = "SIGNED"
assert c.derived_state(date(2026, 10, 20)) == "SIGNED"
assert c.derived_state(date(2026, 11, 15)) == "ACTIVE" and c.derived_state(date(2027, 4, 1)) == "EXPIRED"
def test_ct_34_retention_is_term_plus_six_years_and_twelve_for_a_deed():
c = contract(ends_on=date(2027, 3, 31))
c.state = "SIGNED"
assert c.retention_from(RetentionRule("s", 6, 30), date(2027, 1, 1)) == date(2033, 3, 31)
assert c.retention_from(RetentionRule("d", 12, 30), date(2027, 1, 1)) == date(2039, 3, 31)
c.state = "CANCELLED"
assert c.retention_from(RetentionRule("s", 6, 30), date(2027, 1, 1)) == date(2027, 1, 31)
# ── the gb pack (CT-35) ────────────────────────────────────────────────────────────────────
def _ftype(checks=("ir35",)):
return ContractType("freelance_agreement", "freelance", ("worker",), pack="gb", pack_checks=tuple(checks))
def _vars(**kw):
return {k: VariableValue.parse(k, "date" if k.endswith("_on") else "enum" if k != "sds_reference" else "text", v,
"CALLER") for k, v in kw.items()}
def test_ct_35_an_outside_determination_by_a_medium_or_large_client_needs_its_sds():
c = contract()
c.variables = _vars(client_size_regime="MEDIUM_LARGE", ir35_determination="OUTSIDE")
assert any("status determination statement" in r for r in packs.gb.send_refusals(_ftype(), c))
c.variables = _vars(client_size_regime="MEDIUM_LARGE", ir35_determination="OUTSIDE", sds_issued_on="2026-10-01")
assert packs.gb.send_refusals(_ftype(), c) == []
c.variables = _vars(client_size_regime="SMALL", ir35_determination="OUTSIDE")
assert packs.gb.send_refusals(_ftype(), c) == []
def test_ct_35_the_era_particulars_are_required_and_the_statement_is_due_on_day_one():
et = ContractType("employment_contract", "employment", ("employee",), pack="gb", pack_checks=("era_s1",))
assert "holiday_entitlement" in packs.gb.draft_missing(et, {})
c = contract(starts_on=date(2026, 11, 2))
c.ensure_key_date("PRINCIPAL_STATEMENT_DUE", date(2026, 11, 2), "hr", (1,))
assert packs.gb.status_warnings(et, c, date(2026, 11, 2)) == [
"the written statement has not been given and is due today"]
c.principal_statement_issued_at = T0
assert packs.gb.status_warnings(et, c, date(2026, 11, 2)) == []
def test_the_fingerprint_covers_title_sections_variables_and_signers():
c = contract()
f = c.fingerprint()
c.signers[0].email = "other@x.example"
assert c.fingerprint() != f and isinstance(f, Fingerprint)