From 94c31d528f507b5185edb71d24bee0184da57554 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 00:59:50 +0200 Subject: [PATCH 1/3] feat(blog): goal-driven keyword planning and article approval --- src/fopost/__init__.py | 6 +++ src/fopost/models.py | 30 +++++++++++++- src/fopost/resources/blog.py | 59 ++++++++++++++++++++++---- tests/test_blog.py | 80 +++++++++++++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 11 deletions(-) diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index de0413d..6936c48 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -32,6 +32,7 @@ ) from .models import ( BLOG_ARTICLE_STATUSES, + BLOG_GOALS, BLOG_KEYWORD_STATUSES, BLOG_TARGET_KINDS, PLATFORMS, @@ -71,8 +72,10 @@ BlogArticleFull, BlogArticleImage, BlogArticleStatus, + BlogGoal, BlogKeyword, BlogKeywordStatus, + BlogPlanRun, BlogSettings, BlogSettingsUpdate, BlogTargetKind, @@ -469,6 +472,7 @@ "ReachEstimate", "SupplyForecast", "BLOG_ARTICLE_STATUSES", + "BLOG_GOALS", "BLOG_KEYWORD_STATUSES", "BLOG_TARGET_KINDS", "ArticlePublishedPayload", @@ -476,8 +480,10 @@ "BlogArticleFull", "BlogArticleImage", "BlogArticleStatus", + "BlogGoal", "BlogKeyword", "BlogKeywordStatus", + "BlogPlanRun", "BlogSettings", "BlogSettingsUpdate", "BlogTargetKind", diff --git a/src/fopost/models.py b/src/fopost/models.py index f323c39..c06f43b 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -2343,6 +2343,7 @@ class GoogleConversionAction(FopostModel): "queued", "writing", "draft", + "in_review", "scheduled", "publishing", "published", @@ -2354,6 +2355,7 @@ class GoogleConversionAction(FopostModel): "queued", "writing", "draft", + "in_review", "scheduled", "publishing", "published", @@ -2369,6 +2371,21 @@ class GoogleConversionAction(FopostModel): BLOG_TARGET_KINDS: tuple[str, ...] = ("product_blog", "account", "website") +BlogGoal = Literal["traffic", "leads", "sales", "signups", "authority"] + +BLOG_GOALS: tuple[str, ...] = ("traffic", "leads", "sales", "signups", "authority") + + +class BlogPlanRun(FopostModel): + """The last keyword research run.""" + + #: ``queued``, ``running``, ``done`` or ``failed``. + status: str + error: str | None = None + #: New keywords the run found. + added: int | None = None + finished_at: datetime | None = None + class BlogSettings(FopostModel): """How a workspace plans, writes and publishes articles.""" @@ -2376,13 +2393,18 @@ class BlogSettings(FopostModel): site_url: str | None = None topic: str | None = None audience: str | None = None + #: One of ``BLOG_GOALS``; steers suggested terms and keyword ranking. + goal: str | None = None + #: What the blog wants to write about. + focus_topics: str | None = None + #: What the blog never writes about. + avoid_topics: str | None = None language: str = "en" location_code: int | None = None #: Articles planned per week; 0 turns planning off. cadence_per_week: int = 0 - #: Days a draft waits for review before it may auto-publish. + #: Days before its planned date that a picked keyword starts being written. review_hold_days: int = 0 - auto_publish: bool = False image_tier: str | None = None in_body_images: int | None = None min_words: int | None = None @@ -2394,6 +2416,8 @@ class BlogSettings(FopostModel): target_platform: str | None = None website_url: str | None = None has_website_secret: bool = False + #: ``None`` before the first run. + plan_run: BlogPlanRun | None = None keyword_data_available: bool = False updated_at: datetime | None = None @@ -2426,6 +2450,8 @@ class BlogKeyword(FopostModel): #: ``suggested``, ``planned``, ``written`` or ``dismissed``. status: str = "suggested" planned_for: date | None = None + #: Why the keyword serves the blog goal. + fit_reason: str | None = None created_at: datetime | None = None diff --git a/src/fopost/resources/blog.py b/src/fopost/resources/blog.py index b8daa8b..c1d5cab 100644 --- a/src/fopost/resources/blog.py +++ b/src/fopost/resources/blog.py @@ -1,8 +1,9 @@ """``client.blog`` — plan keywords, request articles and publish them to a website. A post goes to a social network; an article goes to a website. Everything here -is an article. Planning, requesting and regenerating spend AI credits and need -the ``ai`` scope; publishing needs ``publish``; the rest needs ``posts``. +is an article. Suggesting seeds, planning, requesting and regenerating spend AI +credits and need the ``ai`` scope; publishing needs ``publish``; the rest needs +``posts``. A draft is submitted for approval before it can be published. These routes take ``workspaceId`` in camelCase, as a query parameter on reads and per-id routes and as a body field on the collection ``POST``/``PATCH`` @@ -54,11 +55,13 @@ def update_settings( site_url: str | None | Any = UNSET, topic: str | None | Any = UNSET, audience: str | None | Any = UNSET, + goal: str | None | Any = UNSET, + focus_topics: str | None | Any = UNSET, + avoid_topics: str | None | Any = UNSET, language: str | Any = UNSET, location_code: int | Any = UNSET, cadence_per_week: int | Any = UNSET, review_hold_days: int | Any = UNSET, - auto_publish: bool | Any = UNSET, image_tier: str | Any = UNSET, in_body_images: int | Any = UNSET, min_words: int | Any = UNSET, @@ -72,17 +75,21 @@ def update_settings( ``website_secret`` on the result is set only the first time ``website_url`` is saved. Store it then; it is never shown again. + ``review_hold_days`` is how many days before its planned date a picked + keyword starts being written. """ body = _camel_body( { "site_url": site_url, "topic": topic, "audience": audience, + "goal": goal, + "focus_topics": focus_topics, + "avoid_topics": avoid_topics, "language": language, "location_code": location_code, "cadence_per_week": cadence_per_week, "review_hold_days": review_hold_days, - "auto_publish": auto_publish, "image_tier": image_tier, "in_body_images": in_body_images, "min_words": min_words, @@ -126,10 +133,35 @@ def list_keywords( ) return parse_list(BlogKeyword, unwrap(body)) + def suggest_seeds( + self, + workspace_id: str, + *, + seeds: Sequence[str] | None = None, + prompt: str | None = None, + ) -> builtins.list[str]: + """About 10 search terms from the blog goal and topics. + + Pass the current ``seeds`` (up to 20) to refine them and ``prompt`` (up to + 500 characters) to steer. An empty balance raises ``PaymentRequiredError``. + """ + payload: dict[str, Any] = {"workspaceId": workspace_id} + if seeds is not None: + payload["seeds"] = builtins.list(seeds) + if prompt is not None: + payload["prompt"] = prompt + data = unwrap(self._http.post("/blog/keywords/suggest", payload)) + found = data.get("seeds") if isinstance(data, dict) else None + return [str(seed) for seed in found] if isinstance(found, builtins.list) else [] + def plan_keywords( self, workspace_id: str, *, seeds: Sequence[str] | None = None ) -> dict[str, Any]: - """Queue a research run; suggestions land in ``list_keywords`` as they arrive.""" + """Queue a research run; results land as ``suggested`` keywords. + + The calendar is untouched: setting a keyword's status to ``planned`` picks + it. Follow the run in ``plan_run`` on the settings. + """ payload: dict[str, Any] = {"workspaceId": workspace_id} if seeds is not None: payload["seeds"] = builtins.list(seeds) @@ -152,6 +184,7 @@ def update_keyword( status: str | Any = UNSET, planned_for: str | None | Any = UNSET, ) -> BlogKeyword: + """Setting ``status`` to ``planned`` picks the keyword; it takes the next free date.""" body = self._http.request( "PATCH", f"/blog/keywords/{keyword_id}", @@ -219,7 +252,11 @@ def update_article( tags: Sequence[str] | Any = UNSET, publish_at: str | None | Any = UNSET, ) -> BlogArticleFull: - """Edit a draft. ``publish_at`` puts it on the calendar; ``None`` returns it to review.""" + """Edit an article. + + ``publish_at`` approves an article in review and puts it on the calendar; + ``None`` returns it to review. A draft answers 409 until it is submitted. + """ body = self._http.request( "PATCH", f"/blog/articles/{article_id}", @@ -242,8 +279,16 @@ def update_article( def regenerate_article(self, workspace_id: str, article_id: str) -> BlogArticle: return self._article_action(workspace_id, article_id, "regenerate") + def submit_article(self, workspace_id: str, article_id: str) -> BlogArticle: + """Move a draft, failed or rejected article to ``in_review``; any other state is a 409.""" + return self._article_action(workspace_id, article_id, "submit") + def publish_article(self, workspace_id: str, article_id: str) -> BlogArticle: - """Publish now; a 409 ``no_target`` means the workspace has no target set.""" + """Publish an article in review now. + + A draft is a 409 until it is submitted; a 409 ``no_target`` means the + workspace has no target set. + """ return self._article_action(workspace_id, article_id, "publish") def reject_article(self, workspace_id: str, article_id: str) -> BlogArticle: diff --git a/tests/test_blog.py b/tests/test_blog.py index 2ae50bd..10c54c1 100644 --- a/tests/test_blog.py +++ b/tests/test_blog.py @@ -14,11 +14,13 @@ "siteUrl": "https://yourbrand.com", "topic": "Running shoes", "audience": "Weekend runners", + "goal": "sales", + "focusTopics": "Trail and road shoes", + "avoidTopics": "Injury advice", "language": "en", "locationCode": 2840, "cadencePerWeek": 2, "reviewHoldDays": 3, - "autoPublish": False, "imageTier": "standard", "inBodyImages": 2, "minWords": 900, @@ -29,6 +31,12 @@ "targetPlatform": None, "websiteUrl": "https://yourbrand.com/api/fopost", "hasWebsiteSecret": True, + "planRun": { + "status": "done", + "error": None, + "added": 12, + "finishedAt": "2026-10-10T09:30:00.000Z", + }, "keywordDataAvailable": True, "updatedAt": "2026-10-10T09:00:00.000Z", } @@ -43,6 +51,7 @@ "source": "provider", "status": "planned", "plannedFor": "2026-10-14", + "fitReason": "Buyers compare shoes before they pick one.", "createdAt": "2026-10-10T09:00:00.000Z", } @@ -111,11 +120,20 @@ def test_settings_round_trip(client: Fopost) -> None: assert settings.cadence_per_week == 2 assert settings.target_kind == "website" assert settings.has_website_secret is True + assert settings.goal == "sales" + assert settings.focus_topics == "Trail and road shoes" + assert settings.plan_run is not None and settings.plan_run.added == 12 assert dict(get_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} assert get_route.calls.last.request.headers["X-API-Key"] == "osk_test_key" updated = client.blog.update_settings( - "ws_1", website_url="https://yourbrand.com/api/fopost", cadence_per_week=2, topic=None + "ws_1", + website_url="https://yourbrand.com/api/fopost", + cadence_per_week=2, + topic=None, + goal="leads", + focus_topics="Trail shoes", + avoid_topics=None, ) assert updated.website_secret == "whsec_once" assert updated.settings.site_url == "https://yourbrand.com" @@ -124,6 +142,9 @@ def test_settings_round_trip(client: Fopost) -> None: "websiteUrl": "https://yourbrand.com/api/fopost", "cadencePerWeek": 2, "topic": None, + "goal": "leads", + "focusTopics": "Trail shoes", + "avoidTopics": None, } assert client.blog.rotate_website_secret("ws_1") == "whsec_new" @@ -167,6 +188,7 @@ def test_keywords_round_trip(client: Fopost) -> None: keywords = client.blog.list_keywords("ws_1", status=["suggested", "planned"], limit=50) assert keywords[0].search_volume == 12000 + assert keywords[0].fit_reason == "Buyers compare shoes before they pick one." assert str(keywords[0].planned_for) == "2026-10-14" assert dict(list_route.calls.last.request.url.params) == { "workspaceId": "ws_1", @@ -194,6 +216,37 @@ def test_keywords_round_trip(client: Fopost) -> None: assert dict(delete_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} +@respx.mock +def test_suggest_seeds(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/blog/keywords/suggest").mock( + return_value=httpx.Response(200, json={"seeds": ["trail running shoes", "shoe sizing"]}) + ) + + seeds = client.blog.suggest_seeds( + "ws_1", seeds=["trail shoes"], prompt="Lean towards beginners." + ) + assert seeds == ["trail running shoes", "shoe sizing"] + assert _json(route.calls.last.request) == { + "workspaceId": "ws_1", + "seeds": ["trail shoes"], + "prompt": "Lean towards beginners.", + } + + client.blog.suggest_seeds("ws_1") + assert _json(route.calls.last.request) == {"workspaceId": "ws_1"} + + +@respx.mock +def test_suggesting_on_an_empty_balance_is_a_payment_error(client: Fopost) -> None: + respx.post(f"{BASE_URL}/blog/keywords/suggest").mock( + return_value=httpx.Response( + 402, json={"error": "insufficient_credits", "message": "Your balance ran out."} + ) + ) + with pytest.raises(PaymentRequiredError): + client.blog.suggest_seeds("ws_1") + + @respx.mock def test_planning_on_an_empty_balance_is_a_payment_error(client: Fopost) -> None: respx.post(f"{BASE_URL}/blog/keywords/plan").mock( @@ -288,6 +341,29 @@ def test_articles_round_trip(client: Fopost) -> None: assert dict(delete_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} +@respx.mock +def test_submit_article(client: Fopost) -> None: + route = respx.post(f"{BASE_URL}/blog/articles/art_1/submit").mock( + return_value=httpx.Response(200, json={"data": {**ARTICLE_FIXTURE, "status": "in_review"}}) + ) + assert client.blog.submit_article("ws_1", "art_1").status == "in_review" + assert dict(route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert route.calls.last.request.content == b"" + + +@respx.mock +def test_submitting_a_published_article_is_a_conflict(client: Fopost) -> None: + respx.post(f"{BASE_URL}/blog/articles/art_1/submit").mock( + return_value=httpx.Response( + 409, json={"error": "invalid_state", "message": "Only a draft can be submitted."} + ) + ) + with pytest.raises(FopostError) as err: + client.blog.submit_article("ws_1", "art_1") + assert err.value.status == 409 + assert err.value.code == "invalid_state" + + @respx.mock def test_publishing_without_a_target_is_a_conflict(client: Fopost) -> None: respx.post(f"{BASE_URL}/blog/articles/art_1/publish").mock( From 1650664a72b572e720ab82e7f1dd5d1ba905bd8a Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 00:59:50 +0200 Subject: [PATCH 2/3] docs(blog): document seed suggestions, article approval and the auto_publish removal --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 799f36b..01abe1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,24 @@ All notable changes to `fopost` are documented here. ## Unreleased +### Removed + +- **Breaking:** `BlogSettings.auto_publish` and the `auto_publish` argument of + `blog.update_settings` are gone. Nothing publishes without approval: a draft goes to + `in_review` through `blog.submit_article` first. + ### Added +- `blog.suggest_seeds(workspace_id, seeds=None, prompt=None)` returns about 10 search terms + from the blog goal and topics (`ai` scope; an empty balance raises `PaymentRequiredError`). +- `blog.submit_article` moves a draft, failed or rejected article to `in_review` (`posts` + scope, 409 from any other state). `publish_article` and `publish_at` now need an article + in review, so a draft is a 409 until it is submitted. +- `BlogSettings` carries `goal`, `focus_topics`, `avoid_topics` and `plan_run` (a + `BlogPlanRun`), and `blog.update_settings` takes `goal`, `focus_topics` and `avoid_topics`. + `BlogKeyword.fit_reason`, `BLOG_GOALS`, and `in_review` in `BLOG_ARTICLE_STATUSES`. + `plan_keywords` no longer fills the calendar: setting a keyword to `planned` picks it, and + `review_hold_days` is how many days before its date it starts being written. - `media.limits(workspace_id=None)` returns an `UploadLimits` model: `max_file_bytes`, `multipart_max_file_bytes`, `max_files_per_upload`, `next_tier`, `allowed_types` and the source of the ceiling. diff --git a/README.md b/README.md index 31634ec..3ebc6c2 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,10 @@ repurposed = client.ai.repurpose_url( ## Blog `client.blog` plans keywords, writes articles and publishes them to a website. A post goes -to a social network; an article goes to a website. +to a social network; an article goes to a website. `suggest_seeds` proposes search terms from +the blog `goal` and topics, `plan_keywords` researches them into `suggested` keywords, and +setting a keyword's status to `planned` puts it on the calendar. A draft is submitted for +approval before it can be published. ```python article = client.blog.request_article( @@ -136,6 +139,7 @@ article = client.blog.request_article( print(article.status) # "queued"; it becomes a draft once written for draft in client.blog.list_articles(workspace.id, status="draft"): + client.blog.submit_article(workspace.id, draft.id) client.blog.publish_article(workspace.id, draft.id) ``` @@ -324,7 +328,7 @@ except FopostError as err: | `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` | | `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | | `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | -| `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `publish_article`, `reject_article`, `delete_article` | +| `blog` | `get_settings`, `update_settings`, `rotate_website_secret`, `test_website`, `list_keywords`, `suggest_seeds`, `plan_keywords`, `add_keyword`, `update_keyword`, `delete_keyword`, `list_articles`, `request_article`, `get_article`, `update_article`, `regenerate_article`, `submit_article`, `publish_article`, `reject_article`, `delete_article` | | `validate` | `post`, `length`, `media` | | `whatsapp` | `get_profile`, `update_profile`, `request_display_name`, `set_username`, `list_templates`, `list_template_library`, `get_template`, `create_template`, `import_template`, `update_template`, `delete_template`, `list_groups`, `create_group`, `get_group`, `update_group`, `delete_group`, `get_group_invite_link`, `reset_group_invite_link`, `remove_group_participants`, `list_blocked`, `block_users`, `unblock_users`, `get_commerce_settings`, `update_commerce_settings`, `link_catalog`, `list_flows`, `get_flow`, `create_flow`, `update_flow`, `delete_flow`, `upload_flow_json`, `publish_flow`, `deprecate_flow`, `list_flow_responses`, `get_encryption_key_status`, `set_encryption_key`, `get_account_events`, `list_sandbox_sessions`, `create_sandbox_session` | | `analytics` | `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` | From 0ea43a34a50e949a7a5cce33d349aee554e2b273 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 14:36:38 +0200 Subject: [PATCH 3/3] fix(blog): match the live API contract with workspace_id and snake_case responses --- CHANGELOG.md | 2 + src/fopost/models.py | 6 +- src/fopost/resources/blog.py | 34 +++++---- tests/test_blog.py | 137 ++++++++++++++++++----------------- 4 files changed, 93 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01abe1d..3346b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ All notable changes to `fopost` are documented here. and `upgrade_url`. - `client.blog`: settings, keyword planning and articles under `/v1/blog`, with the `BlogSettings`, `BlogKeyword`, `BlogArticle`, `BlogArticleFull` and `BlogArticleImage` models. + The workspace travels as `workspace_id` on the query string; `test_website` returns the + site's `http_status`. - `fopost.webhooks.verify` (also exported as `verify_webhook`): verifies any FoPost webhook delivery over the raw body and returns the `{event, data, timestamp}` envelope, with the typed `ArticlePublishedPayload` for `article.published`. diff --git a/src/fopost/models.py b/src/fopost/models.py index c06f43b..f72b0f4 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -2430,11 +2430,9 @@ class BlogSettingsUpdate(FopostModel): class BlogWebsiteTest(FopostModel): - """Outcome of a test delivery to the website target.""" + """What the website answered a test delivery it accepted.""" - ok: bool = False - status: int | None = None - error: str | None = None + http_status: int class BlogKeyword(FopostModel): diff --git a/src/fopost/resources/blog.py b/src/fopost/resources/blog.py index c1d5cab..0981c5f 100644 --- a/src/fopost/resources/blog.py +++ b/src/fopost/resources/blog.py @@ -5,9 +5,10 @@ credits and need the ``ai`` scope; publishing needs ``publish``; the rest needs ``posts``. A draft is submitted for approval before it can be published. -These routes take ``workspaceId`` in camelCase, as a query parameter on reads -and per-id routes and as a body field on the collection ``POST``/``PATCH`` -routes. Request fields are camelCase on the wire too; pass them snake_case here. +The workspace travels as ``workspace_id`` on the query string of reads and per-id +routes and as ``workspaceId`` in the body of the collection ``POST``/``PATCH`` +routes. Request bodies are camelCase on the wire and responses snake_case; pass +arguments snake_case here. """ from __future__ import annotations @@ -45,7 +46,7 @@ class BlogResource(Resource): # ── Settings ────────────────────────────────────────────────── def get_settings(self, workspace_id: str) -> BlogSettings: - body = self._http.get("/blog/settings", {"workspaceId": workspace_id}) + body = self._http.get("/blog/settings", {"workspace_id": workspace_id}) return BlogSettings.model_validate(unwrap(body)) def update_settings( @@ -102,7 +103,7 @@ def update_settings( ) body["workspaceId"] = workspace_id raw = self._http.patch("/blog/settings", body) - secret = raw.get("websiteSecret") if isinstance(raw, dict) else None + secret = raw.get("website_secret") if isinstance(raw, dict) else None return BlogSettingsUpdate( settings=BlogSettings.model_validate(unwrap(raw)), website_secret=secret ) @@ -111,10 +112,13 @@ def rotate_website_secret(self, workspace_id: str) -> str: """Issue a new website secret; the previous one stops verifying at once.""" body = self._http.post("/blog/settings/website-secret", {"workspaceId": workspace_id}) data = unwrap(body) - return str(data["websiteSecret"]) if isinstance(data, dict) else "" + return str(data["website_secret"]) if isinstance(data, dict) else "" def test_website(self, workspace_id: str) -> BlogWebsiteTest: - """Send a signed ``article.test`` delivery to the website target.""" + """Send a signed ``article.test`` delivery to the website target. + + A refusal raises a 502 ``website_test_failed`` carrying the site's answer. + """ body = self._http.post("/blog/settings/website-test", {"workspaceId": workspace_id}) return BlogWebsiteTest.model_validate(unwrap(body)) @@ -129,7 +133,7 @@ def list_keywords( ) -> builtins.list[BlogKeyword]: body = self._http.get( "/blog/keywords", - {"workspaceId": workspace_id, "status": _statuses(status), "limit": limit}, + {"workspace_id": workspace_id, "status": _statuses(status), "limit": limit}, ) return parse_list(BlogKeyword, unwrap(body)) @@ -189,13 +193,13 @@ def update_keyword( "PATCH", f"/blog/keywords/{keyword_id}", json=_camel_body({"status": status, "planned_for": planned_for}), - params={"workspaceId": workspace_id}, + params={"workspace_id": workspace_id}, ) return BlogKeyword.model_validate(unwrap(body)) def delete_keyword(self, workspace_id: str, keyword_id: str) -> None: self._http.request( - "DELETE", f"/blog/keywords/{keyword_id}", params={"workspaceId": workspace_id} + "DELETE", f"/blog/keywords/{keyword_id}", params={"workspace_id": workspace_id} ) # ── Articles ────────────────────────────────────────────────── @@ -209,7 +213,7 @@ def list_articles( ) -> builtins.list[BlogArticle]: body = self._http.get( "/blog/articles", - {"workspaceId": workspace_id, "status": _statuses(status), "limit": limit}, + {"workspace_id": workspace_id, "status": _statuses(status), "limit": limit}, ) return parse_list(BlogArticle, unwrap(body)) @@ -235,7 +239,7 @@ def request_article( return BlogArticle.model_validate(unwrap(self._http.post("/blog/articles", payload))) def get_article(self, workspace_id: str, article_id: str) -> BlogArticleFull: - body = self._http.get(f"/blog/articles/{article_id}", {"workspaceId": workspace_id}) + body = self._http.get(f"/blog/articles/{article_id}", {"workspace_id": workspace_id}) return BlogArticleFull.model_validate(unwrap(body)) def update_article( @@ -272,7 +276,7 @@ def update_article( "publish_at": publish_at, } ), - params={"workspaceId": workspace_id}, + params={"workspace_id": workspace_id}, ) return BlogArticleFull.model_validate(unwrap(body)) @@ -296,11 +300,11 @@ def reject_article(self, workspace_id: str, article_id: str) -> BlogArticle: def delete_article(self, workspace_id: str, article_id: str) -> None: self._http.request( - "DELETE", f"/blog/articles/{article_id}", params={"workspaceId": workspace_id} + "DELETE", f"/blog/articles/{article_id}", params={"workspace_id": workspace_id} ) def _article_action(self, workspace_id: str, article_id: str, action: str) -> BlogArticle: body = self._http.request( - "POST", f"/blog/articles/{article_id}/{action}", params={"workspaceId": workspace_id} + "POST", f"/blog/articles/{article_id}/{action}", params={"workspace_id": workspace_id} ) return BlogArticle.model_validate(unwrap(body)) diff --git a/tests/test_blog.py b/tests/test_blog.py index 10c54c1..2b38fc0 100644 --- a/tests/test_blog.py +++ b/tests/test_blog.py @@ -11,48 +11,48 @@ from tests.conftest import BASE_URL SETTINGS_FIXTURE: dict[str, Any] = { - "siteUrl": "https://yourbrand.com", + "site_url": "https://yourbrand.com", "topic": "Running shoes", "audience": "Weekend runners", "goal": "sales", - "focusTopics": "Trail and road shoes", - "avoidTopics": "Injury advice", + "focus_topics": "Trail and road shoes", + "avoid_topics": "Injury advice", "language": "en", - "locationCode": 2840, - "cadencePerWeek": 2, - "reviewHoldDays": 3, - "imageTier": "standard", - "inBodyImages": 2, - "minWords": 900, - "maxWords": 1600, - "brandVoiceId": None, - "targetKind": "website", - "targetAccountId": None, - "targetPlatform": None, - "websiteUrl": "https://yourbrand.com/api/fopost", - "hasWebsiteSecret": True, - "planRun": { + "location_code": 2840, + "cadence_per_week": 2, + "review_hold_days": 3, + "image_tier": "standard", + "in_body_images": 2, + "min_words": 900, + "max_words": 1600, + "brand_voice_id": None, + "target_kind": "website", + "target_account_id": None, + "target_platform": None, + "website_url": "https://yourbrand.com/api/fopost", + "has_website_secret": True, + "plan_run": { "status": "done", "error": None, "added": 12, - "finishedAt": "2026-10-10T09:30:00.000Z", + "finished_at": "2026-10-10T09:30:00.000Z", }, - "keywordDataAvailable": True, - "updatedAt": "2026-10-10T09:00:00.000Z", + "keyword_data_available": True, + "updated_at": "2026-10-10T09:00:00.000Z", } KEYWORD_FIXTURE: dict[str, Any] = { "id": "kw_1", "keyword": "best trail running shoes", - "searchVolume": 12000, + "search_volume": 12000, "difficulty": 41, "cpc": 1.2, "intent": "commercial", "source": "provider", "status": "planned", - "plannedFor": "2026-10-14", - "fitReason": "Buyers compare shoes before they pick one.", - "createdAt": "2026-10-10T09:00:00.000Z", + "planned_for": "2026-10-14", + "fit_reason": "Buyers compare shoes before they pick one.", + "created_at": "2026-10-10T09:00:00.000Z", } ARTICLE_FIXTURE: dict[str, Any] = { @@ -61,20 +61,20 @@ "slug": "best-trail-running-shoes", "keyword": "best trail running shoes", "excerpt": "What to look for on rock, mud and root.", - "metaTitle": None, - "metaDescription": None, - "featuredImage": {"mediaId": "med_1", "url": "https://cdn.test/f.jpg", "alt": "Trail"}, + "meta_title": None, + "meta_description": None, + "featured_image": {"media_id": "med_1", "url": "https://cdn.test/f.jpg", "alt": "Trail"}, "tags": ["shoes", "trail"], - "wordCount": 1240, + "word_count": 1240, "status": "draft", "origin": "plan", - "reviewUntil": "2026-10-13T09:00:00.000Z", - "publishAt": None, - "publishedAt": None, - "publishedUrl": None, + "review_until": "2026-10-13T09:00:00.000Z", + "publish_at": None, + "published_at": None, + "published_url": None, "error": None, - "createdAt": "2026-10-10T09:00:00.000Z", - "updatedAt": "2026-10-10T10:00:00.000Z", + "created_at": "2026-10-10T09:00:00.000Z", + "updated_at": "2026-10-10T10:00:00.000Z", } ARTICLE_FULL_FIXTURE: dict[str, Any] = { @@ -82,10 +82,10 @@ "instructions": "Mention the return policy.", "outline": ["Why fit matters", "Our picks"], "brief": {"questions": ["Are trail shoes waterproof?"], "competitors": []}, - "bodyMarkdown": "# The Best Trail Running Shoes\n\nFit first.", - "bodyHtml": "

The Best Trail Running Shoes

Fit first.

", + "body_markdown": "# The Best Trail Running Shoes\n\nFit first.", + "body_html": "

The Best Trail Running Shoes

Fit first.

", "images": [ - {"mediaId": "med_2", "url": "https://cdn.test/1.jpg", "alt": "Mud", "position": 1}, + {"media_id": "med_2", "url": "https://cdn.test/1.jpg", "alt": "Mud", "position": 1}, ], } @@ -100,19 +100,22 @@ def _json(request: httpx.Request) -> dict[str, Any]: @respx.mock def test_settings_round_trip(client: Fopost) -> None: get_route = respx.get(f"{BASE_URL}/blog/settings").mock( - return_value=httpx.Response(200, json={"data": SETTINGS_FIXTURE}) + return_value=httpx.Response(200, json=SETTINGS_FIXTURE) ) patch_route = respx.patch(f"{BASE_URL}/blog/settings").mock( - return_value=httpx.Response( - 200, json={"data": SETTINGS_FIXTURE, "websiteSecret": "whsec_once"} - ) + return_value=httpx.Response(200, json={**SETTINGS_FIXTURE, "website_secret": "whsec_once"}) ) rotate_route = respx.post(f"{BASE_URL}/blog/settings/website-secret").mock( - return_value=httpx.Response(200, json={"data": {"websiteSecret": "whsec_new"}}) + return_value=httpx.Response(200, json={"website_secret": "whsec_new"}) ) test_route = respx.post(f"{BASE_URL}/blog/settings/website-test").mock( return_value=httpx.Response( - 502, json={"data": {"ok": False, "status": 500, "error": "upstream failed"}} + 502, + json={ + "error": "website_test_failed", + "message": "upstream failed", + "http_status": 500, + }, ) ) @@ -123,7 +126,7 @@ def test_settings_round_trip(client: Fopost) -> None: assert settings.goal == "sales" assert settings.focus_topics == "Trail and road shoes" assert settings.plan_run is not None and settings.plan_run.added == 12 - assert dict(get_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(get_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert get_route.calls.last.request.headers["X-API-Key"] == "osk_test_key" updated = client.blog.update_settings( @@ -159,10 +162,10 @@ def test_settings_round_trip(client: Fopost) -> None: @respx.mock def test_website_test_reports_the_outcome(client: Fopost) -> None: respx.post(f"{BASE_URL}/blog/settings/website-test").mock( - return_value=httpx.Response(200, json={"data": {"ok": True, "status": 200, "error": None}}) + return_value=httpx.Response(200, json={"http_status": 200}) ) result = client.blog.test_website("ws_1") - assert result.ok is True and result.status == 200 + assert result.http_status == 200 # ── Keywords ────────────────────────────────────────────────────── @@ -174,13 +177,13 @@ def test_keywords_round_trip(client: Fopost) -> None: return_value=httpx.Response(200, json={"data": [KEYWORD_FIXTURE]}) ) plan_route = respx.post(f"{BASE_URL}/blog/keywords/plan").mock( - return_value=httpx.Response(202, json={"data": {"status": "queued"}}) + return_value=httpx.Response(202, json={"status": "queued"}) ) add_route = respx.post(f"{BASE_URL}/blog/keywords").mock( - return_value=httpx.Response(201, json={"data": KEYWORD_FIXTURE}) + return_value=httpx.Response(201, json=KEYWORD_FIXTURE) ) patch_route = respx.patch(f"{BASE_URL}/blog/keywords/kw_1").mock( - return_value=httpx.Response(200, json={"data": {**KEYWORD_FIXTURE, "status": "dismissed"}}) + return_value=httpx.Response(200, json={**KEYWORD_FIXTURE, "status": "dismissed"}) ) delete_route = respx.delete(f"{BASE_URL}/blog/keywords/kw_1").mock( return_value=httpx.Response(204) @@ -191,7 +194,7 @@ def test_keywords_round_trip(client: Fopost) -> None: assert keywords[0].fit_reason == "Buyers compare shoes before they pick one." assert str(keywords[0].planned_for) == "2026-10-14" assert dict(list_route.calls.last.request.url.params) == { - "workspaceId": "ws_1", + "workspace_id": "ws_1", "status": "suggested,planned", "limit": "50", } @@ -209,11 +212,11 @@ def test_keywords_round_trip(client: Fopost) -> None: dismissed = client.blog.update_keyword("ws_1", "kw_1", status="dismissed", planned_for=None) assert dismissed.status == "dismissed" - assert dict(patch_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(patch_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert _json(patch_route.calls.last.request) == {"status": "dismissed", "plannedFor": None} assert client.blog.delete_keyword("ws_1", "kw_1") is None - assert dict(delete_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(delete_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} @respx.mock @@ -274,22 +277,22 @@ def test_articles_round_trip(client: Fopost) -> None: return_value=httpx.Response(200, json={"data": [ARTICLE_FIXTURE]}) ) create_route = respx.post(f"{BASE_URL}/blog/articles").mock( - return_value=httpx.Response(202, json={"data": {**ARTICLE_FIXTURE, "status": "queued"}}) + return_value=httpx.Response(202, json={**ARTICLE_FIXTURE, "status": "queued"}) ) get_route = respx.get(f"{BASE_URL}/blog/articles/art_1").mock( - return_value=httpx.Response(200, json={"data": ARTICLE_FULL_FIXTURE}) + return_value=httpx.Response(200, json=ARTICLE_FULL_FIXTURE) ) patch_route = respx.patch(f"{BASE_URL}/blog/articles/art_1").mock( - return_value=httpx.Response(200, json={"data": ARTICLE_FULL_FIXTURE}) + return_value=httpx.Response(200, json=ARTICLE_FULL_FIXTURE) ) regenerate_route = respx.post(f"{BASE_URL}/blog/articles/art_1/regenerate").mock( - return_value=httpx.Response(202, json={"data": {**ARTICLE_FIXTURE, "status": "writing"}}) + return_value=httpx.Response(202, json={**ARTICLE_FIXTURE, "status": "writing"}) ) publish_route = respx.post(f"{BASE_URL}/blog/articles/art_1/publish").mock( - return_value=httpx.Response(202, json={"data": {**ARTICLE_FIXTURE, "status": "publishing"}}) + return_value=httpx.Response(202, json={**ARTICLE_FIXTURE, "status": "publishing"}) ) reject_route = respx.post(f"{BASE_URL}/blog/articles/art_1/reject").mock( - return_value=httpx.Response(200, json={"data": {**ARTICLE_FIXTURE, "status": "rejected"}}) + return_value=httpx.Response(200, json={**ARTICLE_FIXTURE, "status": "rejected"}) ) delete_route = respx.delete(f"{BASE_URL}/blog/articles/art_1").mock( return_value=httpx.Response(204) @@ -300,7 +303,7 @@ def test_articles_round_trip(client: Fopost) -> None: assert articles[0].featured_image is not None assert articles[0].featured_image.media_id == "med_1" assert dict(list_route.calls.last.request.url.params) == { - "workspaceId": "ws_1", + "workspace_id": "ws_1", "status": "draft", "limit": "10", } @@ -319,12 +322,12 @@ def test_articles_round_trip(client: Fopost) -> None: assert full.body_markdown.startswith("# The Best") assert full.images[0].position == 1 assert full.brief is not None and full.brief["questions"] == ["Are trail shoes waterproof?"] - assert dict(get_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(get_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} client.blog.update_article( "ws_1", "art_1", title="New title", tags=["shoes"], publish_at="2026-10-15T09:00:00Z" ) - assert dict(patch_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(patch_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert _json(patch_route.calls.last.request) == { "title": "New title", "tags": ["shoes"], @@ -332,22 +335,22 @@ def test_articles_round_trip(client: Fopost) -> None: } assert client.blog.regenerate_article("ws_1", "art_1").status == "writing" - assert dict(regenerate_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(regenerate_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert client.blog.publish_article("ws_1", "art_1").status == "publishing" - assert dict(publish_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(publish_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert client.blog.reject_article("ws_1", "art_1").status == "rejected" - assert dict(reject_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(reject_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert client.blog.delete_article("ws_1", "art_1") is None - assert dict(delete_route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(delete_route.calls.last.request.url.params) == {"workspace_id": "ws_1"} @respx.mock def test_submit_article(client: Fopost) -> None: route = respx.post(f"{BASE_URL}/blog/articles/art_1/submit").mock( - return_value=httpx.Response(200, json={"data": {**ARTICLE_FIXTURE, "status": "in_review"}}) + return_value=httpx.Response(200, json={**ARTICLE_FIXTURE, "status": "in_review"}) ) assert client.blog.submit_article("ws_1", "art_1").status == "in_review" - assert dict(route.calls.last.request.url.params) == {"workspaceId": "ws_1"} + assert dict(route.calls.last.request.url.params) == {"workspace_id": "ws_1"} assert route.calls.last.request.content == b""