Files
cayce-ops/tests/test_publisher.py
cayce aabd6bafc7
ci / lint-test-leaks (pull_request) Canceled after 0s
publisher: themed calendar, NIP-01 nostr adapter, gated brand images
- calendar: one draft per theme/channel (idempotent), rotated schedule
- human gate: approve() audited; AUTONOMOUS_MODE has no effect here by design
- nostr (primary): in-repo NIP-01 client (D4) — canonical serialization,
  sha256 id, BIP-340 schnorr sign via coincurve with zero aux randomness
  (deterministic), pure-python reference verification before publish,
  per-relay publish with isolated errors, event id stored as post_ref
- facebook/instagram adapters: exist but refuse to run unless explicitly
  enabled via env (default deployment never touches Meta)
- images: Pillow brand templates (bh gold / hc orange), 1080x1350,
  template-driven typeset design (not AI-generated media)
- failure isolation: config errors audited, item stays approved

tests: 12 new (event roundtrip+tamper, determinism, key validation,
calendar idempotence, approve gate, nostr happy path, future schedule,
clean config failure, disabled meta adapters, image render) — 41 total
2026-10-19 21:54:39 +00:00

163 lines
5.3 KiB
Python

"""publisher tests — calendar, human gate, nostr publish, disabled adapters."""
from __future__ import annotations
import os
import pytest
from cayceops.components.publisher import adapters, nostr, publisher
from cayceops.models import AuditEntry, ContentItem, ContentStatus
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml)
TEST_KEY = "11" * 32
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
return ContentItem(
theme=theme,
channel=channel,
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
link="bitcoinhotels.org/guides/atm",
status=status,
scheduled_for=scheduled,
)
# --- nostr client -------------------------------------------------------------
def test_event_build_verify_roundtrip():
event = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000)
assert nostr.verify_event(event)
assert len(event["id"]) == 64 and len(event["sig"]) == 128
tampered = {**event, "content": "gm (edited)"}
assert not nostr.verify_event(tampered)
def test_event_is_deterministic():
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
assert a == b
def test_key_validation():
with pytest.raises(ValueError):
nostr.normalize_key("not-a-key")
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
# --- calendar ------------------------------------------------------------------
def test_plan_creates_idempotent_drafts(cfg, sess):
config = cfg()
first = publisher._plan(config, sess)
second = publisher._plan(config, sess)
assert first["created"] == 4
assert second["created"] == 0 # same themes+channels already queued
def test_approve_gate(cfg, sess):
item = _item(status=ContentStatus.DRAFT)
sess.add(item)
sess.flush()
assert publisher._approve(item.id, "gustavo", sess) == 0
assert item.status is ContentStatus.APPROVED
assert publisher._approve(item.id, "gustavo", sess) == 1 # not draft anymore
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
assert actor == "gustavo"
# --- publishing ------------------------------------------------------------------
def test_publish_due_nostr_happy_path(cfg, sess):
item = _item() # approved, scheduled None -> due now
sess.add(item)
sess.flush()
relays_seen = []
def fake_sender(relays, event, timeout=10.0):
relays_seen.append(relays)
assert nostr.verify_event(event)
return [{"relay": r, "ok": "true"} for r in relays]
config = cfg(
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
)
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
assert stats["published"] == 1
assert item.status is ContentStatus.PUBLISHED
assert item.post_ref and len(item.post_ref) == 64
assert item.published_at is not None
assert "wss://relay.example.org" in relays_seen[0]
def test_future_scheduled_item_not_published(cfg, sess):
from datetime import UTC, datetime, timedelta
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
sess.add(item)
sess.flush()
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
assert stats["published"] == 0
assert item.status is ContentStatus.APPROVED
def test_nostr_without_config_fails_clean(cfg, sess):
item = _item()
sess.add(item)
sess.flush()
config = cfg() # no key, no relays
stats = publisher._publish_due(config, sess)
assert stats["failed"] == 1
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
assert item.error and "nostr" in item.error
def test_non_nostr_channels_skipped_by_default(cfg, sess):
item = _item(channel="facebook", theme="lightning-abroad")
sess.add(item)
sess.flush()
stats = publisher._publish_due(cfg(), sess)
assert stats["skipped"] == 1
assert item.status is ContentStatus.APPROVED
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one()
assert skip.detail["channel"] == "facebook"
def test_meta_adapters_disabled_by_default(cfg):
config = cfg()
with pytest.raises(PermissionError):
adapters.post_facebook(config, "hello")
with pytest.raises(PermissionError):
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
# --- images ------------------------------------------------------------------
def test_brand_image_render(tmp_path, cfg):
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
out = publisher._render_image(_item(), config)
assert os.path.exists(out)
from PIL import Image
with Image.open(out) as img:
assert img.size == (1080, 1350)
def test_unknown_brand_rejected():
from cayceops.components.publisher import images
with pytest.raises(ValueError):
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")