Download intake/scripts/test_post_publication.py from ICSAC-Institute/editorial-system: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/intake/scripts/test_post_publication.py
- Command line
-
hf download hf://spaces/ICSAC-Institute/editorial-system/intake/scripts/test_post_publication.py
-
curl -L -o test_post_publication.py https://huggingface.co/spaces/ICSAC-Institute/editorial-system/resolve/main/intake/scripts/test_post_publication.py
19.8 kB
| #!/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()) | |