#!/usr/bin/env python3 """Offline tests for intake/post_publication.py and the two notice functions in intake/notify_author.py: the follow-up state machine, its stop conditions, idempotence, crash recovery, tier routing, the registry lookup, and the real renderers with only the transport (email_send.send_email) stubbed. No Gmail, no Telegram, no network. Run from the repo root: .venv/bin/python intake/scripts/test_post_publication.py """ from __future__ import annotations import datetime as dt import json import sys import tempfile from pathlib import Path REPO = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO)) import intake.post_publication as pp # noqa: E402 import intake.notify_author as na # noqa: E402 NOW = dt.datetime(2026, 10, 15, 12, 0, tzinfo=dt.timezone.utc) ORCID_MEMBER = "0000-0002-1825-0097" ORCID_REVOKED = "0000-0001-5109-3700" ORCID_SUPPORTER = "0000-0002-9079-593X" ORCID_FREE = "0000-0003-1415-9269" BASE = {"decision": "accept", "author_approval_status": "approved", "author_exclusions": []} def iso(t): return t.strftime("%Y-%m-%dT%H:%M:%SZ") def mk(root, sub_id, *, state, orcid=ORCID_FREE, email="author@example.com", tier=None, test_mode=False, offered=True, record_exclusions=None, record_status=None): """A submission dir. `offered` writes author_approval.json (the author saw the response page); its exclusions/status mirror the state unless overridden.""" d = root / sub_id d.mkdir(parents=True) sub = {"sub_id": sub_id, "title": f"Paper {sub_id}", "source": "upload", "license": "cc-by-4.0", "form": {"name": "Test Author", "email": email, "orcid": orcid}, "auth": {"orcid": orcid, "verified": True}, "creators": [{"name": "Test Author", "orcid": orcid}]} if tier is not None: sub["tier"] = tier if test_mode: sub["test_mode"] = True (d / "submission.json").write_text(json.dumps(sub)) (d / "state.json").write_text(json.dumps(state)) if offered: status = record_status or state.get("author_approval_status") or "pending" excl = record_exclusions if record_exclusions is not None else state.get("author_exclusions") or [] (d / "author_approval.json").write_text(json.dumps({ "token_sha256": "x" * 64, "issued_at": iso(NOW), "deadline": iso(NOW), "status": status, "responses": [{"at": iso(NOW), "choice": "approve", "exclusions": excl, "note": ""}]})) return d def main() -> int: tmp = Path(tempfile.mkdtemp(prefix="pp-test-")) prod = tmp / "subs"; test = prod / "test" prod.mkdir(); test.mkdir(); (prod / "queue").mkdir(); (test / "_outbox").mkdir() repo = tmp / "site"; (repo / "src" / "data").mkdir(parents=True) (repo / "src" / "data" / "members.yaml").write_text( "_counters:\n next: 3\nmembers:\n" f' "ICSAC-00001":\n holder: "M"\n tier: "Founding Member"\n tier_code: FM\n status: active\n orcid: "{ORCID_MEMBER}"\n' f' "ICSAC-00002":\n holder: "R"\n tier: "Reviewer"\n tier_code: RV\n status: revoked\n orcid: "{ORCID_REVOKED}"\n') (repo / "src" / "data" / "supporters.yaml").write_text( f"supporters:\n- display_name: S\n tier: supporter\n status: active\n orcid: https://orcid.org/{ORCID_SUPPORTER}\n") reg15 = iso(NOW - dt.timedelta(days=15)) reg3 = iso(NOW - dt.timedelta(days=3)) A = mk(prod, "ICSAC-SUB-00101", state={**BASE, "crossref_registered_at": reg15}) B = mk(prod, "ICSAC-SUB-00102", state={**BASE, "crossref_registered_at": reg15, "author_exclusions": ["newsletter", "social"]}) C = mk(prod, "ICSAC-SUB-00103", state={**BASE, "crossref_registered_at": reg3}) D = mk(prod, "ICSAC-SUB-00104", state={**BASE, "crossref_registered_at": reg15, "author_approval_status": "withdrawn", "withdrawn_by_author": True}) E = mk(prod, "ICSAC-SUB-00105", state={**BASE, "crossref_registered_at": reg15}, orcid=ORCID_MEMBER) F = mk(prod, "ICSAC-SUB-00106", state={**BASE, "crossref_registered_at": reg15}, orcid=ORCID_REVOKED) G = mk(prod, "ICSAC-SUB-00107", state={**BASE}) # never registered Hs = mk(prod, "ICSAC-SUB-00108", state={**BASE, "crossref_registered_at": reg15}, orcid=ORCID_SUPPORTER) I = mk(prod, "ICSAC-SUB-00109", state={**BASE, "crossref_registered_at": reg15, "author_approval_status": "hold"}) J = mk(prod, "ICSAC-SUB-00110", state={"decision": "revise", "crossref_registered_at": reg15}) K = mk(prod, "ICSAC-SUB-00111", state={**BASE, "crossref_registered_at": reg15}, email="") T3 = mk(test, "ICSAC-SUB-TEST-1700000003", state={**BASE, "crossref_registered_at": reg15}, tier=3, test_mode=True) T2 = mk(test, "ICSAC-SUB-TEST-1700000002", state={**BASE, "crossref_registered_at": reg15}, tier=2, test_mode=True) TX = mk(test, "ICSAC-SUB-TEST-1700000009", state={**BASE, "crossref_registered_at": reg15}, test_mode=True) # no tier FAIL = mk(prod, "ICSAC-SUB-00112", state={**BASE, "crossref_registered_at": reg15}) LEGACY = mk(prod, "ICSAC-SUB-00113", state={"decision": "accept", "crossref_registered_at": reg15}, offered=False) MISMATCH = mk(prod, "ICSAC-SUB-00114", state={**BASE, "crossref_registered_at": reg15}, record_exclusions=["newsletter"]) RAISE = mk(prod, "ICSAC-SUB-00115", state={**BASE, "crossref_registered_at": reg15}) STALE = mk(prod, "ICSAC-SUB-00116", state={**BASE, "crossref_registered_at": reg15, "followup_draft_started_at": iso(NOW - dt.timedelta(hours=2))}) FRESH = mk(prod, "ICSAC-SUB-00117", state={**BASE, "crossref_registered_at": reg15, "followup_draft_started_at": iso(NOW - dt.timedelta(minutes=5))}) CORRUPT = mk(prod, "ICSAC-SUB-00118", state={**BASE, "crossref_registered_at": reg15}) (CORRUPT / "state.json").write_text("{not json") # ── stubs (transport and messaging only) ── pp.SUBMISSIONS_ROOT, pp.TEST_SUBMISSIONS_ROOT = prod, test pp.AUDIT_LOG, pp.TEST_AUDIT_LOG = tmp / "audit.jsonl", tmp / "audit-test.jsonl" pp.WEBSITE_REPO = str(repo) pings, drafts = [], [] pp._ping = lambda msg, **kw: pings.append((msg, kw)) real_send_followup = na.send_followup def fake_send_followup(**kw): drafts.append(kw) if kw["sub_id"] == FAIL.name: return False, "SMTP_USER or SMTP_PASSWORD not configured" if kw["sub_id"] == RAISE.name: raise na.TemplateUnfilledKeysError("unfilled keys in submission_followup.md: ['{{x}}']") return True, f"draft saved (tier {kw['tier']})" na.send_followup = fake_send_followup failures = [] def check(cond, msg): (failures if not cond else []).append(msg) print((" ok " if cond else " FAIL ") + msg) st = lambda d: json.loads((d / "state.json").read_text()) # noqa: E731 # ── phases and visibility ── check(pp.phase(st(A), NOW)[0] == "due", "A registered 15 d ago is due") check(pp.phase(st(C), NOW)[0] == "waiting", "C registered 3 d ago is waiting") check(pp.phase(st(G), NOW)[0] == "not_published", "G never registered is not_published") due = {d.name for d in pp.due_followups(NOW)} check(T3.name not in due and T2.name not in due, "test subs are invisible without include_test") due_t = {d.name for d in pp.due_followups(NOW, include_test=True)} check({T3.name, T2.name, TX.name} <= due_t, "test subs are visible with include_test") check(CORRUPT.name not in due, "a corrupt state.json is not due and does not abort the listing") # ── time handling ── off = dt.datetime(2026, 10, 15, 12, 0, tzinfo=dt.timezone(dt.timedelta(hours=-4))) check(pp._iso(off) == "2026-10-15T16:00:00Z", "_iso converts an offset-aware time to UTC") check(pp._parse("2026-10-15T12:00:00") == NOW, "_parse treats a naive timestamp as UTC") # ── registry lookup ── check(pp.already_affiliated(ORCID_MEMBER)[0] == "members:ICSAC-00001", "active member ORCID is found") check(pp.already_affiliated(ORCID_REVOKED)[0] is None, "revoked member ORCID does not count") check(pp.already_affiliated(f"https://orcid.org/{ORCID_SUPPORTER}")[0] == "supporters:supporter", "supporter ORCID (URL form) is found") check(pp.already_affiliated(ORCID_FREE)[0] is None, "unknown ORCID is not found") check(pp.already_affiliated("TEST:abc")[0] is None, "test ORCID is ignored") for label, bad in (("missing dir", str(tmp / "nope")), ("empty setting", "")): try: pp.already_affiliated(ORCID_MEMBER, repo=bad); raised = False except pp.RegistryUnavailable: raised = True check(raised, f"registry {label} raises RegistryUnavailable (unknown, not 'no')") badrepo = tmp / "badsite"; (badrepo / "src" / "data").mkdir(parents=True) (badrepo / "src" / "data" / "members.yaml").write_text("members:\n- not: a mapping\n") (badrepo / "src" / "data" / "supporters.yaml").write_text("supporters:\n- orcid: x\n") try: pp.already_affiliated(ORCID_MEMBER, repo=str(badrepo)); raised = False except pp.RegistryUnavailable: raised = True check(raised, "a registry file of the wrong shape is unavailable, not empty") # ── author response: the record wins over the state mirror ── check(pp.author_response(MISMATCH)["exclusions"] == ["newsletter"], "author_approval.json exclusions win over the state mirror") check(pp.author_response(LEGACY)["offered"] is False, "no author_approval.json = never offered") # ── stay-involved paragraph (fail-closed) ── check(pp.wants_stay_involved(A) is True, "A gets the stay-involved paragraph") check(pp.wants_stay_involved(B) is False, "B (newsletter excluded) gets no paragraph") check(pp.wants_stay_involved(E) is False, "E (already a member) gets no paragraph") check(pp.wants_stay_involved(I) is False, "I (hold) gets no paragraph") check(pp.wants_stay_involved(D) is False, "D (withdrawn) gets no paragraph") check(pp.wants_stay_involved(LEGACY) is False, "legacy author (never offered the choices) gets no paragraph") check(pp.wants_stay_involved(MISMATCH) is False, "record-level newsletter exclusion drops the paragraph") check(pp.wants_stay_involved(CORRUPT) is False, "unreadable state drops the paragraph (fail-closed)") saved = pp.WEBSITE_REPO; pp.WEBSITE_REPO = "" check(pp.wants_stay_involved(A) is False, "registry unavailable drops the paragraph (fail-closed)") pp.WEBSITE_REPO = saved # ── first tick ── s = pp.run_tick(NOW, include_test=True, log=lambda m: None) check(s["drafted"] == 5, f"drafted A, F, T3, T2, TX = 5 (got {s['drafted']})") check(s["skipped"] == 10, f"skipped B, D, E, Hs, I, J, K, LEGACY, MISMATCH, STALE = 10 (got {s['skipped']})") check(s["errors"] == 3, f"FAIL + RAISE + CORRUPT counted as errors (got {s['errors']})") check(st(A).get("followup_drafted_at") == iso(NOW) and st(A).get("followup_draft_started_at"), "A carries the reservation and followup_drafted_at") check("newsletter" in st(B).get("followup_skip_reason", ""), "B skipped for the newsletter exclusion") check("newsletter" in st(MISMATCH).get("followup_skip_reason", ""), "MISMATCH skipped on the record's exclusion") check("never offered" in st(LEGACY).get("followup_skip_reason", ""), "LEGACY skipped: never offered the choices") check("withdrew" in st(D).get("followup_skip_reason", ""), "D skipped for withdrawal") check("registry" in st(E).get("followup_skip_reason", ""), "E skipped as already on the registry") check(st(F).get("followup_drafted_at"), "F (revoked record) still drafted") check("registry" in st(Hs).get("followup_skip_reason", ""), "supporter skipped") check("hold" in st(I).get("followup_skip_reason", ""), "hold skipped") check("accept" in st(J).get("followup_skip_reason", ""), "non-accept skipped") check("email" in st(K).get("followup_skip_reason", ""), "no email skipped") check("never recorded" in st(STALE).get("followup_skip_reason", ""), "stale reservation closes the record instead of drafting blind") check(st(FRESH).get("followup_drafted_at") is None and st(FRESH).get("followup_skipped_at") is None, "fresh reservation (5 min) is left alone") check(st(C).get("followup_drafted_at") is None and st(C).get("followup_skipped_at") is None, "C untouched while waiting") check(st(RAISE).get("followup_draft_attempts") == 1 and st(RAISE).get("followup_draft_started_at") is None, "a raising draft counts as an attempt and releases the reservation") check(st(FAIL).get("followup_draft_attempts") == 1 and not st(FAIL).get("followup_skipped_at"), "failed draft: attempt 1 recorded, not closed") tiers = {d["sub_id"]: d["tier"] for d in drafts} check(tiers.get(T3.name) == 3 and tiers.get(T2.name) == 2 and tiers.get(A.name) == 1, f"tier routing {tiers}") check(tiers.get(TX.name) == 3, "a test record without a tier is delivered the T3 way") check(all(d["author_name"] == "Test Author" and d["published_date"] for d in drafts), "drafts carry the author name and a published date") prod_pings = [p for p in pings if not p[1].get("test_mode")] test_pings = [p for p in pings if p[1].get("test_mode")] check(sum("DRAFT ready" in p[0] for p in prod_pings) == 2, "one prod ping per drafted prod paper") check(sum("NOT drafted" in p[0] for p in prod_pings) == 1 and "never recorded" in next(p[0] for p in prod_pings if "NOT drafted" in p[0]), "the stale reservation pinged once; ordinary skips are silent") check(len(test_pings) == 3 and all(p[1]["tier"] in (2, 3) for p in test_pings), "test pings carry their tier for routing") audit = [json.loads(l) for l in (tmp / "audit.jsonl").read_text().splitlines()] check(sum(1 for a in audit if a["event"] == "followup_drafted") == 2, "audit: 2 prod drafted events") check(sum(1 for a in audit if a["event"] == "followup_skipped") == 10, "audit: 10 skipped events") taudit = [json.loads(l) for l in (tmp / "audit-test.jsonl").read_text().splitlines()] check(all(a.get("test") for a in taudit) and len(taudit) == 3, "test audit lines are flagged test") # ── second tick: idempotent ── n_drafts, n_pings = len(drafts), len(pings) s2 = pp.run_tick(NOW + dt.timedelta(days=1), include_test=True, log=lambda m: None) check(s2["drafted"] == 0 and s2["skipped"] == 1, f"second tick drafts nothing; the only skip is the now-stale FRESH reservation ({s2})") check("never recorded" in st(FRESH).get("followup_skip_reason", ""), "a reservation that aged past the window closes the record") check(len(drafts) == n_drafts + 2, "second tick only retried the two failing drafts") check(st(FAIL).get("followup_draft_attempts") == 2, "failure attempt 2 recorded") pp.run_tick(NOW + dt.timedelta(days=2), include_test=True, log=lambda m: None) check(st(FAIL).get("followup_skipped_at") and "failed 3 times" in st(FAIL)["followup_skip_reason"], "third failure closes the record") check(sum("FAILED 3 times" in p[0] for p in pings[n_pings:]) == 2, "third failure pinged once per closed record (FAIL, RAISE)") s4 = pp.run_tick(NOW + dt.timedelta(days=3), include_test=True, log=lambda m: None) check(s4["checked"] == 0 and s4["drafted"] == 0, "nothing is due once every record is closed (the corrupt one only counts as an error)") # ── registry unavailable at tick time: skip + one ping ── U = mk(prod, "ICSAC-SUB-00119", state={**BASE, "crossref_registered_at": reg15}) pp.WEBSITE_REPO = "" n_p = len(pings) pp.run_tick(NOW, log=lambda m: None) pp.WEBSITE_REPO = str(repo) check("could not be checked" in st(U).get("followup_skip_reason", ""), "registry unavailable at tick: skipped, not asked blind") check(len(pings) == n_p + 1 and "NOT drafted" in pings[-1][0], "registry unavailable: the curation team is told once") # ── dry run writes nothing ── L = mk(prod, "ICSAC-SUB-00120", state={**BASE, "crossref_registered_at": reg15}) s5 = pp.run_tick(NOW, dry_run=True, log=lambda m: None) check(L.name in s5["transitions"] and st(L).get("followup_drafted_at") is None and st(L).get("followup_draft_started_at") is None, "dry run reports L as due and writes nothing") # ── the real renderers, transport stubbed ── sent = [] def fake_send_email(**kw): sent.append(kw); return True, "stub" na.email_send.send_email = fake_send_email common = dict(to="a@example.com", title="T", author_name="A", published_date="October 1, 2026") real_send_followup(sub_id="ICSAC-SUB-00001", tier=1, **common) real_send_followup(sub_id="ICSAC-SUB-TEST-1", tier=2, **common) real_send_followup(sub_id="ICSAC-SUB-TEST-1", tier=3, **common) real_send_followup(sub_id="ICSAC-SUB-TEST-1", tier=1, **common) # a test id can never go out plain na.send_published(to="a@example.com", sub_id="ICSAC-SUB-00001", title="T", author_name="A", deposit_doi="10.5555/icsac.2026.001", deposit_url="https://doi.org/x", publications_url="https://icsacinstitute.org/publications/x", stay_involved=True) na.send_published(to="a@example.com", sub_id="ICSAC-SUB-00001", title="T", author_name="A", deposit_doi="10.5555/icsac.2026.001", deposit_url="https://doi.org/x", publications_url="https://icsacinstitute.org/publications/x", stay_involved=False) na.send_published(to="a@example.com", sub_id="ICSAC-SUB-TEST-1", title="T", author_name="A", deposit_doi="10.5555/TEST.x", deposit_url="https://doi.org/x", publications_url="https://icsacinstitute.org/publications/x", tier=3) na.send_published(to="a@example.com", sub_id="ICSAC-SUB-TEST-1", title="T", author_name="A", deposit_doi="10.5555/TEST.x", deposit_url="https://doi.org/x", publications_url="https://icsacinstitute.org/publications/x") # tier omitted check(len(sent) == 8, f"eight renders reached the transport ({len(sent)})") check(all(not k.get("send") for k in sent), "no render ever asks the transport to SEND") check(sent[0]["draft"] and not sent[0]["subject"].startswith("[T3"), "prod follow-up: Gmail draft, plain subject") check(sent[1].get("outbox_dir") and sent[1]["eml_filename"].endswith("-followup.eml"), "T2 follow-up: outbox .eml") check(sent[2]["draft"] and sent[2]["subject"].startswith("[T3 TEST] An invitation from ICSAC"), "T3 follow-up: prefixed draft") check(sent[3]["subject"].startswith("[T3 TEST]"), "a test sub id with tier 1 is still prefixed") check("community/donate" in sent[4]["body_md"] and "membership-affiliation" in sent[4]["body_md"], "published notice renders the two links when stay_involved") check("community/donate" not in sent[5]["body_md"] and "{{" not in sent[5]["body_md"], "published notice omits the paragraph when not, with no unfilled keys") check(sent[6]["subject"].startswith("[T3 TEST] Your ICSAC paper is live"), "T3 published notice: prefixed draft") check(sent[7]["subject"].startswith("[T3 TEST]"), "published notice for a test id defaults to the T3 way") check("unless it is included" in sent[0]["body_md"] and "help@icsacinstitute.org" in sent[0]["body_md"] and "reply" not in sent[0]["body_md"].lower(), "follow-up copy: no reply invited, support via help@ with the ID") print(f"\n{'PASS' if not failures else 'FAILED'}: {len(failures)} failure(s) in {tmp}") return 0 if not failures else 1 if __name__ == "__main__": sys.exit(main())