feat(task-017): implement git publishing dry-run and commit flow
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from src.application.seed_data import seed_reference_data # noqa: E402
|
||||
from src.infrastructure.repositories import open_backend_repository # noqa: E402
|
||||
from src.presentation.dependencies import get_repository # noqa: E402
|
||||
from src.presentation.main import app # noqa: E402
|
||||
|
||||
|
||||
DEMO_EDITOR_EMAIL = "editor@example.com"
|
||||
DEMO_ADMIN_EMAIL = "admin@example.com"
|
||||
DEMO_USER_EMAIL_HEADER = "X-Demo-User-Email"
|
||||
|
||||
|
||||
class PublishingGitFlowPublicApiTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp_dir = tempfile.TemporaryDirectory()
|
||||
self.objects_root = Path(self.tmp_dir.name) / "objects"
|
||||
self.bare_repo_path = Path(self.tmp_dir.name) / "demo-site.git"
|
||||
self.seed_repo_path = Path(self.tmp_dir.name) / "demo-site-seed"
|
||||
|
||||
os.environ["OBJECT_STORAGE_LOCAL_ROOT"] = str(self.objects_root)
|
||||
self._prepare_local_bare_git_repo()
|
||||
|
||||
dsn = f"sqlite:///{Path(self.tmp_dir.name) / 'publishing-git-flow.db'}"
|
||||
self.repository = open_backend_repository(dsn)
|
||||
self.repository.setup()
|
||||
seed_reference_data(self.repository)
|
||||
app.dependency_overrides[get_repository] = lambda: self.repository
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
app.dependency_overrides.clear()
|
||||
os.environ.pop("OBJECT_STORAGE_LOCAL_ROOT", None)
|
||||
self.tmp_dir.cleanup()
|
||||
|
||||
def test_publishing_dry_run_blocked_before_final_approval(self) -> None:
|
||||
article_id, _ = self._prepare_article_ready_for_final_approval()
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("final approval", response.text.lower())
|
||||
|
||||
def test_publishing_dry_run_validation_failure_sets_failed_status(self) -> None:
|
||||
article_id, draft = self._prepare_final_approved_article()
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/drafts/{draft['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"body_markdown": "Only plain text, no markdown heading present."},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertFalse(body["content_shape_valid"])
|
||||
self.assertIn("best-effort", body["validation_label"].lower())
|
||||
self.assertIn("heading", " ".join(body["errors"]).lower())
|
||||
self.assertEqual("PUBLISH_DRY_RUN_FAILED", body["article"]["publishing_status"])
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", body["article"]["status"])
|
||||
|
||||
def test_publish_commit_blocked_before_successful_dry_run(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertIn("dry run", response.text.lower())
|
||||
|
||||
def test_final_approved_article_can_create_commit_in_local_git_repository(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
self.assertTrue(dry_run_response.json()["content_shape_valid"])
|
||||
self.assertEqual("PUBLISH_COMMIT_READY", dry_run_response.json()["article"]["status"])
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||
body = commit_response.json()
|
||||
commit = body["publish_commit"]
|
||||
commit_sha = commit["commit_sha"]
|
||||
self.assertTrue(commit_sha)
|
||||
self.assertEqual(str(self.bare_repo_path), commit["repository_url"])
|
||||
self.assertEqual("main", commit["branch"])
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", commit["status"])
|
||||
|
||||
check = subprocess.run(
|
||||
["git", "--git-dir", str(self.bare_repo_path), "cat-file", "-e", f"{commit_sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(0, check.returncode, check.stderr)
|
||||
|
||||
def test_non_fast_forward_conflict_fails_without_rebase(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
self._push_remote_commit("competing remote update")
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(409, commit_response.status_code, commit_response.text)
|
||||
self.assertIn("non-fast-forward", commit_response.text.lower())
|
||||
|
||||
def test_status_and_commits_endpoints_expose_required_metadata(self) -> None:
|
||||
article_id, _ = self._prepare_final_approved_article()
|
||||
|
||||
dry_run_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/dry-run",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, dry_run_response.status_code, dry_run_response.text)
|
||||
|
||||
commit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/publishing/create-commit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, commit_response.status_code, commit_response.text)
|
||||
|
||||
status_response = self.client.get(
|
||||
f"/api/articles/{article_id}/publishing/status",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, status_response.status_code, status_response.text)
|
||||
status_body = status_response.json()
|
||||
self.assertIn("best-effort", status_body["validation_label"].lower())
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["status"])
|
||||
self.assertEqual("PUBLISH_COMMIT_CREATED", status_body["article"]["publishing_status"])
|
||||
self.assertIsNotNone(status_body["latest_dry_run"])
|
||||
self.assertIsNotNone(status_body["latest_publish_commit"])
|
||||
|
||||
commits_response = self.client.get(
|
||||
f"/api/articles/{article_id}/publishing/commits",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, commits_response.status_code, commits_response.text)
|
||||
commits = commits_response.json()["commits"]
|
||||
self.assertGreaterEqual(len(commits), 2)
|
||||
|
||||
commit_rows = {row["status"]: row for row in commits}
|
||||
self.assertIn("PUBLISH_COMMIT_READY", commit_rows)
|
||||
self.assertIn("PUBLISH_COMMIT_CREATED", commit_rows)
|
||||
|
||||
created = commit_rows["PUBLISH_COMMIT_CREATED"]
|
||||
self.assertEqual(str(self.bare_repo_path), created["repository_url"])
|
||||
self.assertEqual("main", created["branch"])
|
||||
self.assertTrue(created["commit_sha"])
|
||||
manifest = created["content_bundle_manifest"]
|
||||
self.assertIn("content", manifest)
|
||||
self.assertIn("frontmatter", manifest)
|
||||
self.assertIn("assets", manifest)
|
||||
self.assertIn("config_version", manifest)
|
||||
self.assertIn("git", manifest)
|
||||
self.assertEqual("main", manifest["git"]["branch"])
|
||||
self.assertEqual(str(self.bare_repo_path), manifest["git"]["repository_url"])
|
||||
self.assertIn("best-effort", manifest["validation"]["label"].lower())
|
||||
self.assertEqual("content/articles/git-publishing-flow.mdx", manifest["content"]["path"])
|
||||
|
||||
def _prepare_local_bare_git_repo(self) -> None:
|
||||
self._run_git(["init", "--bare", str(self.bare_repo_path)])
|
||||
self.seed_repo_path.mkdir(parents=True, exist_ok=True)
|
||||
self._run_git(["init"], cwd=self.seed_repo_path)
|
||||
self._run_git(["config", "user.name", "Pipeline Bot"], cwd=self.seed_repo_path)
|
||||
self._run_git(["config", "user.email", "pipeline-bot@example.com"], cwd=self.seed_repo_path)
|
||||
(self.seed_repo_path / "README.md").write_text("# Demo Site\n", encoding="utf-8")
|
||||
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||
self._run_git(["commit", "-m", "seed"], cwd=self.seed_repo_path)
|
||||
self._run_git(["branch", "-M", "main"], cwd=self.seed_repo_path)
|
||||
self._run_git(["remote", "add", "origin", str(self.bare_repo_path)], cwd=self.seed_repo_path)
|
||||
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||
|
||||
def _run_git(self, args: list[str], *, cwd: Path | None = None) -> None:
|
||||
subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _push_remote_commit(self, message: str) -> None:
|
||||
readme_path = self.seed_repo_path / "README.md"
|
||||
readme_path.write_text(
|
||||
readme_path.read_text(encoding="utf-8") + f"\n{message}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._run_git(["add", "README.md"], cwd=self.seed_repo_path)
|
||||
self._run_git(["commit", "-m", message], cwd=self.seed_repo_path)
|
||||
self._run_git(["push", "origin", "main"], cwd=self.seed_repo_path)
|
||||
|
||||
def _prepare_final_approved_article(self) -> tuple[str, dict[str, Any]]:
|
||||
article_id, draft = self._prepare_article_ready_for_final_approval()
|
||||
self._submit_final_approval(article_id, draft=draft)
|
||||
return article_id, draft
|
||||
|
||||
def _prepare_article_ready_for_final_approval(self) -> tuple[str, dict[str, Any]]:
|
||||
site_id = self._configure_local_git_repository_for_site()
|
||||
article_id = self._create_article_with_approved_plan(site_id)
|
||||
self._ensure_evidence_ready(article_id)
|
||||
self._approve_all_evidence(article_id)
|
||||
|
||||
section_jobs = self._start_parallel_production(article_id)
|
||||
self.assertGreaterEqual(len(section_jobs), 1)
|
||||
for index, job in enumerate(section_jobs, start=1):
|
||||
completed = self._complete_job(
|
||||
job["id"],
|
||||
output={
|
||||
"status": "SUCCEEDED",
|
||||
"output_files": [{"path": f"outputs/section-{index}.md"}],
|
||||
"payload": {
|
||||
"used_evidence_ids": job["payload"]["used_evidence_ids"],
|
||||
"unsupported_claims": [],
|
||||
"draft_markdown": (
|
||||
f"## {job['payload']['heading']}\n\n"
|
||||
"Publishing-ready section with [internal link](/guides/internal)."
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual("SUCCEEDED", completed["status"])
|
||||
|
||||
assemble_response = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/assemble",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assemble_response.status_code, assemble_response.text)
|
||||
draft = assemble_response.json()["draft"]
|
||||
|
||||
assets_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/generate-specs",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, assets_response.status_code, assets_response.text)
|
||||
assets = assets_response.json()["assets"]
|
||||
self.assertTrue(assets)
|
||||
for asset in assets:
|
||||
upload_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/upload",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"filename": f"{asset['id']}.png",
|
||||
"content_base64": base64.b64encode(b"asset-binary").decode("utf-8"),
|
||||
"content_type": "image/png",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, upload_response.status_code, upload_response.text)
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/assets/{asset['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
|
||||
return article_id, draft
|
||||
|
||||
def _submit_final_approval(self, article_id: str, *, draft: dict[str, Any]) -> None:
|
||||
approval_response = self.client.post(
|
||||
f"/api/articles/{article_id}/final-approval",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"draft_version": draft["version"],
|
||||
"publishing_settings": {
|
||||
"content_path": "/guides/git-publishing-flow",
|
||||
"author": "Editorial Team",
|
||||
"publishing_mode": "MANUAL",
|
||||
"frontmatter": {"title": draft["title"], "category": "Guides"},
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, approval_response.status_code, approval_response.text)
|
||||
self.assertEqual("PUBLISH_DRY_RUN_REQUIRED", approval_response.json()["article"]["status"])
|
||||
|
||||
def _configure_local_git_repository_for_site(self) -> UUID:
|
||||
sites_response = self.client.get(
|
||||
"/api/sites",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, sites_response.status_code, sites_response.text)
|
||||
site = sites_response.json()[0]["site"]
|
||||
|
||||
publishing_rules = dict(site["publishing_rules"])
|
||||
publishing_rules["repository_url"] = str(self.bare_repo_path)
|
||||
publishing_rules["production_branch"] = "main"
|
||||
|
||||
patch_response = self.client.patch(
|
||||
f"/api/sites/{site['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_ADMIN_EMAIL},
|
||||
json={"publishing_rules": publishing_rules},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
return UUID(site["id"])
|
||||
|
||||
def _create_article_with_approved_plan(self, site_id: UUID) -> str:
|
||||
article_response = self.client.post(
|
||||
"/api/articles",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={
|
||||
"target_site_id": str(site_id),
|
||||
"brief_description": "Publish final-approved content into local git repository.",
|
||||
"working_title": "Git Publishing Dry Run And Commit",
|
||||
"content_type": "longform_guide",
|
||||
"primary_keyword": "git publishing dry run",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, article_response.status_code, article_response.text)
|
||||
article_id = article_response.json()["article"]["id"]
|
||||
|
||||
questions_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, questions_response.status_code, questions_response.text)
|
||||
questions = questions_response.json()["questions"]
|
||||
for question in questions:
|
||||
if question["is_required"]:
|
||||
patch_response = self.client.patch(
|
||||
f"/api/articles/{article_id}/boundary-questions/{question['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"answer": f"Answer for {question['category']}"},
|
||||
)
|
||||
self.assertEqual(200, patch_response.status_code, patch_response.text)
|
||||
|
||||
submit_response = self.client.post(
|
||||
f"/api/articles/{article_id}/boundary-questions/submit",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, submit_response.status_code, submit_response.text)
|
||||
|
||||
plan_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plan/generate",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, plan_response.status_code, plan_response.text)
|
||||
plan = plan_response.json()["plan"]
|
||||
self.assertGreaterEqual(len(plan["sections"]), 1)
|
||||
|
||||
approve_response = self.client.post(
|
||||
f"/api/articles/{article_id}/plans/{plan['id']}/approve",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, approve_response.status_code, approve_response.text)
|
||||
return article_id
|
||||
|
||||
def _ensure_evidence_ready(self, article_id: str) -> None:
|
||||
research_response = self.client.post(
|
||||
f"/api/articles/{article_id}/research/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(201, research_response.status_code, research_response.text)
|
||||
|
||||
evidence_response = self.client.get(
|
||||
f"/api/articles/{article_id}/evidence",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, evidence_response.status_code, evidence_response.text)
|
||||
self.assertEqual("EVIDENCE_MATRIX_READY", evidence_response.json()["article"]["status"])
|
||||
|
||||
def _approve_all_evidence(self, article_id: str) -> None:
|
||||
response = self.client.get(
|
||||
f"/api/articles/{article_id}/evidence",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
for item in response.json()["evidence"]:
|
||||
patch = self.client.patch(
|
||||
f"/api/articles/{article_id}/evidence/{item['id']}",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
json={"review_status": "APPROVED"},
|
||||
)
|
||||
self.assertEqual(200, patch.status_code, patch.text)
|
||||
|
||||
def _start_parallel_production(self, article_id: str) -> list[dict[str, Any]]:
|
||||
response = self.client.post(
|
||||
f"/api/articles/{article_id}/draft/start",
|
||||
headers={DEMO_USER_EMAIL_HEADER: DEMO_EDITOR_EMAIL},
|
||||
)
|
||||
self.assertEqual(202, response.status_code, response.text)
|
||||
return [job for job in response.json()["jobs"] if job["job_type"] == "SECTION_SCAFFOLD"]
|
||||
|
||||
def _complete_job(self, job_id: str, *, output: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.client.post(
|
||||
f"/internal/agent-jobs/{job_id}/complete",
|
||||
json={
|
||||
"workspace_path": f"/tmp/{job_id}",
|
||||
"stdout": "fake section scaffolding runner\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 1,
|
||||
"output": output,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
return response.json()["job"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user