270 lines
11 KiB
Python
270 lines
11 KiB
Python
import fcntl
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from page_state import find_marker
|
|
from publish_one import (
|
|
AmbiguousWrite,
|
|
DeterministicFailure,
|
|
PublicVerificationError,
|
|
Publisher,
|
|
)
|
|
|
|
|
|
WORKSPACE = "workspace"
|
|
PENDING_OLD = "anwarpy: status=pending | access=free | category=نماذج | queued_at=2026-10-08T09:00:00+03:00"
|
|
PENDING_NEW = "anwarpy: status=pending | access=vip | category=أدوات | queued_at=2026-10-09T09:00:00+03:00"
|
|
BLOCKED_OLDER = "anwarpy: status=blocked | access=free | category=نماذج | queued_at=2026-10-07T09:00:00+03:00 | blocked_reason=cover"
|
|
REPUBLISH = "anwarpy: status=republish | access=vip | category=أدوات | queued_at=2026-10-10T09:00:00+03:00 | slug=existing-guide | published_at=2026-10-09T12:00:00+03:00 | url=https://anwarpy.ai/posts/existing-guide"
|
|
|
|
|
|
def blocks(marker, evidence="Public source text"):
|
|
return [
|
|
{"id": "body", "type": "paragraph", "text": evidence, "depth": 0},
|
|
{"id": "marker", "type": "paragraph", "text": marker, "depth": 0},
|
|
]
|
|
|
|
|
|
class FakeAppFlowy:
|
|
def __init__(self, page_markers):
|
|
self.pages = [
|
|
{"view_id": page_id, "name": title}
|
|
for page_id, (title, _marker) in page_markers.items()
|
|
]
|
|
self.block_map = {
|
|
page_id: blocks(marker) for page_id, (_title, marker) in page_markers.items()
|
|
}
|
|
self.list_calls = 0
|
|
self.edits = []
|
|
|
|
def list_pages(self):
|
|
self.list_calls += 1
|
|
return self.pages
|
|
|
|
def get_blocks(self, page_id):
|
|
return [dict(block) for block in self.block_map[page_id]]
|
|
|
|
def edit_block(self, page_id, block_id, text):
|
|
self.edits.append((page_id, block_id, text))
|
|
for block in self.block_map[page_id]:
|
|
if block["id"] == block_id:
|
|
block["text"] = text
|
|
return
|
|
raise KeyError(block_id)
|
|
|
|
def marker(self, page_id):
|
|
return find_marker(self.get_blocks(page_id)).meta
|
|
|
|
|
|
class FakeGenerator:
|
|
def __init__(self, slugs=None):
|
|
self.slugs = slugs or {}
|
|
self.calls = []
|
|
|
|
def generate(self, title, evidence, meta):
|
|
self.calls.append((title, evidence, dict(meta)))
|
|
slug = self.slugs.get(title, "generated-guide")
|
|
return {
|
|
"title": f"دليل {title}",
|
|
"slug": slug,
|
|
"dek": "شرح عربي عملي ومباشر",
|
|
"teaser": "مقدمة مجانية مفيدة" if meta["access"] == "vip" else "",
|
|
"sections": [
|
|
{"heading": "المشكلة", "paragraphs": ["شرح واضح للمشكلة والحل."], "code": None},
|
|
{"heading": "التحقق", "paragraphs": ["تحقق من النتيجة بخطوة قابلة للقياس."], "code": None},
|
|
{"heading": "القيود", "paragraphs": ["راجع القيود قبل الاستخدام."], "code": None},
|
|
],
|
|
}
|
|
|
|
|
|
class FakeMedia:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def render(self, article, meta):
|
|
self.calls.append((article["slug"], dict(meta)))
|
|
return {
|
|
"coverImage": {"filename": f"{article['slug']}.jpg", "dataBase64": "/9j/2Q=="},
|
|
"video_path": f"/tmp/{article['slug']}.mp4",
|
|
}
|
|
|
|
|
|
class FakeApi:
|
|
def __init__(self, records=None, timeout_after_create=False, public_ok=True):
|
|
self.records = records or {}
|
|
self.timeout_after_create = timeout_after_create
|
|
self.public_ok = public_ok
|
|
self.create_calls = []
|
|
self.update_calls = []
|
|
self.verify_calls = []
|
|
|
|
def lookup(self, slug):
|
|
record = self.records.get(slug)
|
|
return dict(record) if record else None
|
|
|
|
def create(self, body):
|
|
self.create_calls.append(dict(body))
|
|
record = {
|
|
"id": len(self.records) + 1,
|
|
"title": body["title"],
|
|
"slug": body["slug"],
|
|
"status": "published" if body["publish"] else "draft",
|
|
"access": body["access"],
|
|
"category": body["category"],
|
|
"publishedAt": "2026-10-09T12:20:11+00:00",
|
|
"url": f"https://anwarpy.ai/posts/{body['slug']}",
|
|
}
|
|
self.records[body["slug"]] = record
|
|
if self.timeout_after_create:
|
|
self.timeout_after_create = False
|
|
raise AmbiguousWrite("timeout after create")
|
|
return dict(record)
|
|
|
|
def update(self, slug, body):
|
|
self.update_calls.append((slug, dict(body)))
|
|
old = self.records[slug]
|
|
old.update({
|
|
"title": body.get("title", old.get("title")),
|
|
"status": "published" if body.get("publish") else old["status"],
|
|
"access": body.get("access", old["access"]),
|
|
"category": body.get("category", old["category"]),
|
|
"publishedAt": old.get("publishedAt") or "2026-10-09T12:20:11+00:00",
|
|
"url": f"https://anwarpy.ai/posts/{slug}",
|
|
})
|
|
return dict(old)
|
|
|
|
def verify_public(self, record):
|
|
self.verify_calls.append(dict(record))
|
|
return self.public_ok
|
|
|
|
|
|
class PublisherTests(unittest.TestCase):
|
|
def make_publisher(self, app, api, generator=None, media=None, lock_path=None):
|
|
return Publisher(
|
|
appflowy=app,
|
|
api=api,
|
|
generator=generator or FakeGenerator(),
|
|
media=media or FakeMedia(),
|
|
lock_path=lock_path or Path(tempfile.mkdtemp()) / "publisher.lock",
|
|
)
|
|
|
|
def test_lock_loser_makes_no_external_calls(self):
|
|
lock_path = Path(tempfile.mkdtemp()) / "publisher.lock"
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi()
|
|
with lock_path.open("a+") as handle:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
result = self.make_publisher(app, api, lock_path=lock_path).run()
|
|
self.assertEqual(result, "busy")
|
|
self.assertEqual(app.list_calls, 0)
|
|
|
|
def test_fifo_uses_queued_at_and_skips_blocked(self):
|
|
app = FakeAppFlowy({
|
|
"blocked": ("Blocked", BLOCKED_OLDER),
|
|
"new": ("New", PENDING_NEW),
|
|
"old": ("Old", PENDING_OLD),
|
|
})
|
|
generator = FakeGenerator({"Old": "old-guide", "New": "new-guide"})
|
|
result = self.make_publisher(app, FakeApi(), generator=generator).run()
|
|
self.assertEqual(result, "published:old-guide")
|
|
self.assertEqual(generator.calls[0][0], "Old")
|
|
self.assertEqual(app.marker("blocked")["status"], "blocked")
|
|
self.assertEqual(app.marker("new")["status"], "pending")
|
|
|
|
def test_pending_absent_slug_posts_once_and_rerun_is_idle(self):
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi()
|
|
publisher = self.make_publisher(app, api)
|
|
self.assertEqual(publisher.run(), "published:generated-guide")
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
self.assertEqual(app.marker("page")["status"], "published")
|
|
self.assertEqual(publisher.run(), "idle")
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
|
|
def test_existing_draft_is_promoted_without_post(self):
|
|
record = {
|
|
"id": 1, "title": "Draft", "slug": "generated-guide", "status": "draft",
|
|
"access": "free", "category": "نماذج", "publishedAt": None,
|
|
}
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi({"generated-guide": record})
|
|
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
|
|
self.assertEqual(api.create_calls, [])
|
|
self.assertEqual(len(api.update_calls), 1)
|
|
|
|
def test_republish_updates_existing_slug_and_never_posts(self):
|
|
record = {
|
|
"id": 7, "title": "Old", "slug": "existing-guide", "status": "published",
|
|
"access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z",
|
|
"url": "https://anwarpy.ai/posts/existing-guide",
|
|
}
|
|
app = FakeAppFlowy({"page": ("Topic", REPUBLISH)})
|
|
api = FakeApi({"existing-guide": record})
|
|
generator = FakeGenerator({"Topic": "existing-guide"})
|
|
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "published:existing-guide")
|
|
self.assertEqual(api.create_calls, [])
|
|
self.assertEqual(len(api.update_calls), 1)
|
|
|
|
def test_ambiguous_create_reconciles_without_second_post(self):
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi(timeout_after_create=True)
|
|
self.assertEqual(self.make_publisher(app, api).run(), "published:generated-guide")
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
|
|
def test_public_failure_leaves_publishing_and_does_not_repost(self):
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi(public_ok=False)
|
|
publisher = self.make_publisher(app, api)
|
|
with self.assertRaises(PublicVerificationError):
|
|
publisher.run()
|
|
self.assertEqual(app.marker("page")["status"], "publishing")
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
with self.assertRaises(PublicVerificationError):
|
|
publisher.run()
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
|
|
def test_api_metadata_mismatch_leaves_publishing(self):
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi()
|
|
original_create = api.create
|
|
|
|
def mismatched(body):
|
|
result = original_create(body)
|
|
result["access"] = "vip"
|
|
api.records[body["slug"]]["access"] = "vip"
|
|
return result
|
|
|
|
api.create = mismatched
|
|
with self.assertRaisesRegex(ValueError, "access mismatch"):
|
|
self.make_publisher(app, api).run()
|
|
self.assertEqual(app.marker("page")["status"], "publishing")
|
|
self.assertEqual(len(api.create_calls), 1)
|
|
|
|
def test_generation_failure_blocks_page_without_api_write(self):
|
|
app = FakeAppFlowy({"page": ("Topic", PENDING_OLD)})
|
|
api = FakeApi()
|
|
generator = FakeGenerator()
|
|
|
|
def fail(*_args):
|
|
raise DeterministicFailure("generation", "model failed")
|
|
|
|
generator.generate = fail
|
|
self.assertEqual(self.make_publisher(app, api, generator=generator).run(), "blocked:generation")
|
|
self.assertEqual(app.marker("page")["status"], "blocked")
|
|
self.assertEqual(api.create_calls, [])
|
|
|
|
def test_malformed_marker_is_reported_and_valid_page_can_run(self):
|
|
app = FakeAppFlowy({
|
|
"bad": ("Bad", "Anwarpy: pending"),
|
|
"good": ("Good", PENDING_OLD),
|
|
})
|
|
publisher = self.make_publisher(app, FakeApi(), generator=FakeGenerator({"Good": "good-guide"}))
|
|
self.assertEqual(publisher.run(), "published:good-guide")
|
|
self.assertEqual(len(publisher.marker_errors), 1)
|
|
self.assertIn("bad", publisher.marker_errors[0])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|