From 5864610731d32ec09ac7a235b62e37672e00c76b Mon Sep 17 00:00:00 2001 From: Tram-Nguyen87 Date: Tue, 23 Jun 2026 13:30:53 +1200 Subject: [PATCH 1/3] feat(netlify): upgrade to SDK 2.0.0 with unit + integration tests Co-Authored-By: Claude Sonnet 4.6 --- netlify/config.json | 34 +- netlify/netlify.py | 46 +- netlify/requirements.txt | 2 +- netlify/tests/__init__.py | 1 - netlify/tests/{context.py => conftest.py} | 2 - netlify/tests/test_netlify.py | 149 ------- netlify/tests/test_netlify_integration.py | 276 ++++++++++++ netlify/tests/test_netlify_unit.py | 491 ++++++++++++++++++++++ 8 files changed, 796 insertions(+), 205 deletions(-) rename netlify/tests/{context.py => conftest.py} (78%) delete mode 100644 netlify/tests/test_netlify.py create mode 100644 netlify/tests/test_netlify_integration.py create mode 100644 netlify/tests/test_netlify_unit.py diff --git a/netlify/config.json b/netlify/config.json index e928f185..b4676b15 100644 --- a/netlify/config.json +++ b/netlify/config.json @@ -1,7 +1,7 @@ { "name": "Netlify", "display_name": "Netlify", - "version": "1.0.0", + "version": "2.0.0", "description": "Integration with Netlify for managing sites, deployments, and hosting", "entry_point": "netlify.py", "auth": { @@ -24,10 +24,6 @@ "sites": { "type": "array", "description": "List of sites" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -55,10 +51,6 @@ "site": { "type": "object", "description": "Created site details" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -82,10 +74,6 @@ "site": { "type": "object", "description": "Site details" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -106,9 +94,9 @@ "output_schema": { "type": "object", "properties": { - "result": { + "deleted": { "type": "boolean", - "description": "Operation success status" + "description": "Whether the site was successfully deleted" } } } @@ -132,10 +120,6 @@ "deploys": { "type": "array", "description": "List of deploys" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -167,10 +151,6 @@ "deploy_url": { "type": "string", "description": "URL of the deployed site" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -194,10 +174,6 @@ "deploy": { "type": "object", "description": "Deploy details" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } @@ -229,10 +205,6 @@ "site": { "type": "object", "description": "Updated site details" - }, - "result": { - "type": "boolean", - "description": "Operation success status" } } } diff --git a/netlify/netlify.py b/netlify/netlify.py index 490c47ee..10bad388 100644 --- a/netlify/netlify.py +++ b/netlify/netlify.py @@ -1,4 +1,4 @@ -from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult +from autohive_integrations_sdk import Integration, ExecutionContext, ActionHandler, ActionResult, ActionError from typing import Dict, Any import hashlib @@ -23,12 +23,12 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): try: response = await context.fetch(f"{NETLIFY_API_BASE_URL}/sites", method="GET") - sites = response if isinstance(response, list) else [] + sites = response.data if isinstance(response.data, list) else [] - return ActionResult(data={"sites": sites, "result": True}, cost_usd=0.0) + return ActionResult(data={"sites": sites}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"sites": [], "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("create_site") @@ -46,10 +46,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await context.fetch(f"{NETLIFY_API_BASE_URL}/sites", method="POST", json=payload) - return ActionResult(data={"site": response, "result": True}, cost_usd=0.0) + return ActionResult(data={"site": response.data}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"site": {}, "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("get_site") @@ -62,10 +62,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await context.fetch(f"{NETLIFY_API_BASE_URL}/sites/{site_id}", method="GET") - return ActionResult(data={"site": response, "result": True}, cost_usd=0.0) + return ActionResult(data={"site": response.data}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"site": {}, "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("update_site") @@ -84,10 +84,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await context.fetch(f"{NETLIFY_API_BASE_URL}/sites/{site_id}", method="PATCH", json=payload) - return ActionResult(data={"site": response, "result": True}, cost_usd=0.0) + return ActionResult(data={"site": response.data}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"site": {}, "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("delete_site") @@ -100,10 +100,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): await context.fetch(f"{NETLIFY_API_BASE_URL}/sites/{site_id}", method="DELETE") - return ActionResult(data={"deleted": True, "result": True}, cost_usd=0.0) + return ActionResult(data={"deleted": True}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"deleted": False, "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) # ---- Deploy Handlers ---- @@ -119,12 +119,12 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await context.fetch(f"{NETLIFY_API_BASE_URL}/sites/{site_id}/deploys", method="GET") - deploys = response if isinstance(response, list) else [] + deploys = response.data if isinstance(response.data, list) else [] - return ActionResult(data={"deploys": deploys, "result": True}, cost_usd=0.0) + return ActionResult(data={"deploys": deploys}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"deploys": [], "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("create_deploy") @@ -146,13 +146,16 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): hash_to_content[sha1] = content # Create deploy with file digests - deploy = await context.fetch( + deploy_response = await context.fetch( f"{NETLIFY_API_BASE_URL}/sites/{site_id}/deploys", method="POST", json={"files": files_dict} ) + deploy = deploy_response.data # Upload required files required_hashes = deploy.get("required", []) deploy_id = deploy.get("id") + if not deploy_id: + raise ValueError("Netlify did not return a deploy ID — cannot upload files or retrieve deploy status") for sha1_hash in required_hashes: if sha1_hash in hash_to_content: @@ -166,16 +169,17 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): ) # Get final deploy info - final_deploy = await context.fetch(f"{NETLIFY_API_BASE_URL}/deploys/{deploy_id}", method="GET") + final_response = await context.fetch(f"{NETLIFY_API_BASE_URL}/deploys/{deploy_id}", method="GET") + final_deploy = final_response.data deploy_url = ( final_deploy.get("deploy_ssl_url") or final_deploy.get("ssl_url") or final_deploy.get("url", "") ) - return ActionResult(data={"deploy": final_deploy, "deploy_url": deploy_url, "result": True}, cost_usd=0.0) + return ActionResult(data={"deploy": final_deploy, "deploy_url": deploy_url}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"deploy": {}, "deploy_url": "", "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) @netlify.action("get_deploy") @@ -188,7 +192,7 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): response = await context.fetch(f"{NETLIFY_API_BASE_URL}/deploys/{deploy_id}", method="GET") - return ActionResult(data={"deploy": response, "result": True}, cost_usd=0.0) + return ActionResult(data={"deploy": response.data}, cost_usd=0.0) except Exception as e: - return ActionResult(data={"deploy": {}, "result": False, "error": str(e)}, cost_usd=0.0) + return ActionError(message=str(e)) diff --git a/netlify/requirements.txt b/netlify/requirements.txt index b56fee2e..1af9591f 100644 --- a/netlify/requirements.txt +++ b/netlify/requirements.txt @@ -1 +1 @@ -autohive-integrations-sdk~=1.0.2 +autohive-integrations-sdk~=2.0.0 diff --git a/netlify/tests/__init__.py b/netlify/tests/__init__.py index 6bd34bcf..e69de29b 100644 --- a/netlify/tests/__init__.py +++ b/netlify/tests/__init__.py @@ -1 +0,0 @@ -# Netlify Integration Tests diff --git a/netlify/tests/context.py b/netlify/tests/conftest.py similarity index 78% rename from netlify/tests/context.py rename to netlify/tests/conftest.py index 38455009..1406b775 100644 --- a/netlify/tests/context.py +++ b/netlify/tests/conftest.py @@ -2,5 +2,3 @@ import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -from netlify import netlify diff --git a/netlify/tests/test_netlify.py b/netlify/tests/test_netlify.py deleted file mode 100644 index e776de8e..00000000 --- a/netlify/tests/test_netlify.py +++ /dev/null @@ -1,149 +0,0 @@ -# Test suite for Netlify integration -import asyncio -from context import netlify -from autohive_integrations_sdk import ExecutionContext - - -async def test_list_sites(): - """Test listing all sites.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("list_sites", {}, context) - print(f"List Sites Result: {result}") - assert result.data.get("result") - assert "sites" in result.data - return result - except Exception as e: - print(f"Error testing list_sites: {e}") - return None - - -async def test_create_site(): - """Test creating a new site.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - inputs = {"name": "test-site-autohive"} - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("create_site", inputs, context) - print(f"Create Site Result: {result}") - assert result.data.get("result") - assert "site" in result.data - return result - except Exception as e: - print(f"Error testing create_site: {e}") - return None - - -async def test_get_site(): - """Test getting site details.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - inputs = {"site_id": "your_site_id_here"} - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("get_site", inputs, context) - print(f"Get Site Result: {result}") - assert result.data.get("result") - assert "site" in result.data - return result - except Exception as e: - print(f"Error testing get_site: {e}") - return None - - -async def test_list_deploys(): - """Test listing deploys for a site.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - inputs = {"site_id": "your_site_id_here"} - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("list_deploys", inputs, context) - print(f"List Deploys Result: {result}") - assert result.data.get("result") - assert "deploys" in result.data - return result - except Exception as e: - print(f"Error testing list_deploys: {e}") - return None - - -async def test_create_deploy(): - """Test creating a deploy with files.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - inputs = { - "site_id": "your_site_id_here", - "files": {"/index.html": "

Hello from Autohive!

"}, - } - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("create_deploy", inputs, context) - print(f"Create Deploy Result: {result}") - assert result.data.get("result") - assert "deploy" in result.data - assert "deploy_url" in result.data - return result - except Exception as e: - print(f"Error testing create_deploy: {e}") - return None - - -async def test_get_deploy(): - """Test getting deploy details.""" - auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": "your_access_token_here"}} # nosec B105 - inputs = {"deploy_id": "your_deploy_id_here"} - - async with ExecutionContext(auth=auth) as context: - try: - result = await netlify.execute_action("get_deploy", inputs, context) - print(f"Get Deploy Result: {result}") - assert result.data.get("result") - assert "deploy" in result.data - return result - except Exception as e: - print(f"Error testing get_deploy: {e}") - return None - - -# Main test runner -async def run_all_tests(): - """Run all test functions.""" - print("=" * 60) - print("Netlify Integration Test Suite") - print("=" * 60) - - test_functions = [ - ("List Sites", test_list_sites), - ("Create Site", test_create_site), - ("Get Site", test_get_site), - ("List Deploys", test_list_deploys), - ("Create Deploy", test_create_deploy), - ("Get Deploy", test_get_deploy), - ] - - results = [] - for test_name, test_func in test_functions: - print(f"\n{'-' * 60}") - print(f"Running: {test_name}") - print(f"{'-' * 60}") - result = await test_func() - results.append((test_name, result is not None)) - - print("\n" + "=" * 60) - print("Test Summary") - print("=" * 60) - for test_name, passed in results: - status = "PASS" if passed else "FAIL" - print(f"{status}: {test_name}") - - passed_count = sum(1 for _, passed in results if passed) - print(f"\nTotal: {passed_count}/{len(results)} tests passed") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(run_all_tests()) diff --git a/netlify/tests/test_netlify_integration.py b/netlify/tests/test_netlify_integration.py new file mode 100644 index 00000000..5f9f522c --- /dev/null +++ b/netlify/tests/test_netlify_integration.py @@ -0,0 +1,276 @@ +""" +End-to-end integration tests for the Netlify integration. + +These tests call the real Netlify API and require a valid personal access token +set in the NETLIFY_ACCESS_TOKEN environment variable. + +Run all read-only tests: + pytest netlify/tests/test_netlify_integration.py -m integration + +Run destructive tests (sites / deploys created on the real account): + pytest netlify/tests/test_netlify_integration.py -m "integration and destructive" + +Never runs in CI — the default pytest marker filter (-m unit) excludes these. +""" + +import os + +import aiohttp +import pytest +from autohive_integrations_sdk import FetchResponse, HTTPError +from unittest.mock import AsyncMock, MagicMock + +import netlify as netlify_mod # noqa: E402 + +netlify_integration = netlify_mod.netlify + +pytestmark = pytest.mark.integration + +ACCESS_TOKEN = os.environ.get("NETLIFY_ACCESS_TOKEN", "") + + +@pytest.fixture +def live_context(): + if not ACCESS_TOKEN: + pytest.skip("NETLIFY_ACCESS_TOKEN not set — skipping integration tests") + + async def real_fetch(url, *, method="GET", params=None, json=None, headers=None, data=None, **kwargs): + auth_headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} + merged_headers = {**auth_headers, **(headers or {})} + async with aiohttp.ClientSession() as session: + async with session.request( + method, + url, + params=params, + json=json, + headers=merged_headers, + data=data, + ) as resp: + try: + resp_data = await resp.json(content_type=None) + except Exception: + resp_data = await resp.text() + if not resp.ok: + raise HTTPError(resp.status, str(resp_data)) + return FetchResponse(status=resp.status, headers=dict(resp.headers), data=resp_data) + + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(side_effect=real_fetch) + ctx.auth = {"auth_type": "PlatformOauth2", "credentials": {"access_token": ACCESS_TOKEN}} + return ctx + + +# ============================================================================= +# LIST SITES +# ============================================================================= + + +class TestListSites: + async def test_returns_list(self, live_context): + result = await netlify_integration.execute_action("list_sites", {}, live_context) + data = result.result.data + assert "sites" in data + assert isinstance(data["sites"], list) + + async def test_site_items_have_id_and_name(self, live_context): + result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + site = sites[0] + assert "id" in site + assert "name" in site + + +# ============================================================================= +# GET SITE +# ============================================================================= + + +class TestGetSite: + async def test_returns_site_details(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + result = await netlify_integration.execute_action("get_site", {"site_id": site_id}, live_context) + data = result.result.data + assert "site" in data + assert data["site"]["id"] == site_id + + async def test_site_has_expected_fields(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + result = await netlify_integration.execute_action("get_site", {"site_id": site_id}, live_context) + site = result.result.data["site"] + assert "id" in site + assert "name" in site + assert "url" in site or "ssl_url" in site + + +# ============================================================================= +# LIST DEPLOYS +# ============================================================================= + + +class TestListDeploys: + async def test_returns_deploys_list(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + result = await netlify_integration.execute_action("list_deploys", {"site_id": site_id}, live_context) + data = result.result.data + assert "deploys" in data + assert isinstance(data["deploys"], list) + + async def test_deploy_items_have_id_and_state(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + result = await netlify_integration.execute_action("list_deploys", {"site_id": site_id}, live_context) + deploys = result.result.data["deploys"] + if not deploys: + pytest.skip("No deploys on this site") + deploy = deploys[0] + assert "id" in deploy + assert "state" in deploy + + +# ============================================================================= +# GET DEPLOY +# ============================================================================= + + +class TestGetDeploy: + async def test_returns_deploy_details(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + deploys_result = await netlify_integration.execute_action("list_deploys", {"site_id": site_id}, live_context) + deploys = deploys_result.result.data["deploys"] + if not deploys: + pytest.skip("No deploys on this site") + + deploy_id = deploys[0]["id"] + result = await netlify_integration.execute_action("get_deploy", {"deploy_id": deploy_id}, live_context) + data = result.result.data + assert "deploy" in data + assert data["deploy"]["id"] == deploy_id + + async def test_deploy_has_state_field(self, live_context): + list_result = await netlify_integration.execute_action("list_sites", {}, live_context) + sites = list_result.result.data["sites"] + if not sites: + pytest.skip("No sites on this account") + + site_id = sites[0]["id"] + deploys_result = await netlify_integration.execute_action("list_deploys", {"site_id": site_id}, live_context) + deploys = deploys_result.result.data["deploys"] + if not deploys: + pytest.skip("No deploys on this site") + + deploy_id = deploys[0]["id"] + result = await netlify_integration.execute_action("get_deploy", {"deploy_id": deploy_id}, live_context) + assert "state" in result.result.data["deploy"] + + +# ============================================================================= +# DESTRUCTIVE — create/update/delete site and deploy (writes to real account) +# Only run with: pytest -m "integration and destructive" +# ============================================================================= + + +@pytest.mark.destructive +class TestSiteLifecycle: + """Create site → update it → delete it. + + Uses a unique test name to avoid collisions. Cleans up even on failure + via the site_id tracking pattern. + """ + + async def test_full_lifecycle(self, live_context): + import time + + test_name = f"autohive-test-{int(time.time())}" + site_id = None + + try: + # Create + create_result = await netlify_integration.execute_action("create_site", {"name": test_name}, live_context) + assert "site" in create_result.result.data + site_id = create_result.result.data["site"]["id"] + assert site_id + + # Get — verify it exists + get_result = await netlify_integration.execute_action("get_site", {"site_id": site_id}, live_context) + assert get_result.result.data["site"]["id"] == site_id + + # Update name + new_name = f"{test_name}-updated" + update_result = await netlify_integration.execute_action( + "update_site", {"site_id": site_id, "name": new_name}, live_context + ) + assert "site" in update_result.result.data + + finally: + if site_id: + delete_result = await netlify_integration.execute_action( + "delete_site", {"site_id": site_id}, live_context + ) + assert delete_result.result.data["deleted"] is True + + +@pytest.mark.destructive +class TestDeployLifecycle: + """Create a site, deploy a file to it, then clean up. + + This tests the full create_deploy flow including file hashing and + (potentially) file upload when Netlify requests the content. + """ + + async def test_create_deploy_and_verify(self, live_context): + import time + + site_name = f"autohive-deploy-test-{int(time.time())}" + site_id = None + + try: + # Create a site to deploy to + create_site_result = await netlify_integration.execute_action( + "create_site", {"name": site_name}, live_context + ) + site_id = create_site_result.result.data["site"]["id"] + assert site_id + + # Deploy a simple HTML file + deploy_result = await netlify_integration.execute_action( + "create_deploy", + { + "site_id": site_id, + "files": {"/index.html": "

Autohive Integration Test

"}, + }, + live_context, + ) + data = deploy_result.result.data + assert "deploy" in data + assert "deploy_url" in data + assert data["deploy"]["id"] + + finally: + if site_id: + await netlify_integration.execute_action("delete_site", {"site_id": site_id}, live_context) diff --git a/netlify/tests/test_netlify_unit.py b/netlify/tests/test_netlify_unit.py new file mode 100644 index 00000000..acb07041 --- /dev/null +++ b/netlify/tests/test_netlify_unit.py @@ -0,0 +1,491 @@ +""" +Unit tests for the Netlify integration using mocked fetch (SDK 2.0.0). + +Covers every action: list_sites, create_site, get_site, update_site, +delete_site, list_deploys, create_deploy, get_deploy. + +Each action is tested for: happy path, key request details (URL + method), +error path (ActionError), and important edge cases (list coercion, multi-fetch +sequencing, deploy_url priority, missing required inputs). +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock +from autohive_integrations_sdk import FetchResponse +from autohive_integrations_sdk.integration import ResultType + +import netlify as netlify_mod # noqa: E402 + +netlify_integration = netlify_mod.netlify + +pytestmark = pytest.mark.unit + + +def ok(data, status=200): + """Wrap data in a successful FetchResponse.""" + return FetchResponse(status=status, headers={}, data=data) + + +def make_ctx(response_data): + """Mock context with a single fetch response.""" + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(return_value=ok(response_data)) + ctx.auth = {} + return ctx + + +def make_ctx_multi(responses: list): + """Mock context with sequential fetch responses.""" + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(side_effect=[ok(r) for r in responses]) + ctx.auth = {} + return ctx + + +def make_ctx_error(exc: Exception): + """Mock context whose fetch raises an exception.""" + ctx = MagicMock(name="ExecutionContext") + ctx.fetch = AsyncMock(side_effect=exc) + ctx.auth = {} + return ctx + + +# ============================================================================= +# LIST SITES +# ============================================================================= + + +@pytest.mark.asyncio +async def test_list_sites_returns_sites(): + sites = [{"id": "site1", "name": "my-site"}, {"id": "site2", "name": "other-site"}] + ctx = make_ctx(sites) + result = await netlify_integration.execute_action("list_sites", {}, ctx) + assert result.result.data["sites"] == sites + + +@pytest.mark.asyncio +async def test_list_sites_non_list_response_coerces_to_empty(): + ctx = make_ctx({"error": "unexpected"}) + result = await netlify_integration.execute_action("list_sites", {}, ctx) + assert result.result.data["sites"] == [] + + +@pytest.mark.asyncio +async def test_list_sites_empty_list(): + ctx = make_ctx([]) + result = await netlify_integration.execute_action("list_sites", {}, ctx) + assert result.result.data["sites"] == [] + + +@pytest.mark.asyncio +async def test_list_sites_calls_correct_url(): + ctx = make_ctx([]) + await netlify_integration.execute_action("list_sites", {}, ctx) + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert url.endswith("/sites") + assert ctx.fetch.call_args.kwargs.get("method") == "GET" + + +@pytest.mark.asyncio +async def test_list_sites_error_returns_action_error(): + ctx = make_ctx_error(Exception("Network failure")) + result = await netlify_integration.execute_action("list_sites", {}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Network failure" in result.result.message + + +# ============================================================================= +# CREATE SITE +# ============================================================================= + + +@pytest.mark.asyncio +async def test_create_site_returns_site(): + site = {"id": "new-site-id", "name": "test-site", "url": "https://test-site.netlify.app"} + ctx = make_ctx(site) + result = await netlify_integration.execute_action("create_site", {"name": "test-site"}, ctx) + assert result.result.data["site"] == site + + +@pytest.mark.asyncio +async def test_create_site_sends_name_in_payload(): + ctx = make_ctx({"id": "s1", "name": "my-site"}) + await netlify_integration.execute_action("create_site", {"name": "my-site"}, ctx) + assert ctx.fetch.call_args.kwargs.get("method") == "POST" + assert ctx.fetch.call_args.kwargs.get("json", {}).get("name") == "my-site" + + +@pytest.mark.asyncio +async def test_create_site_includes_custom_domain_when_provided(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("create_site", {"name": "my-site", "custom_domain": "example.com"}, ctx) + payload = ctx.fetch.call_args.kwargs.get("json", {}) + assert payload.get("custom_domain") == "example.com" + + +@pytest.mark.asyncio +async def test_create_site_omits_custom_domain_when_absent(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("create_site", {"name": "my-site"}, ctx) + payload = ctx.fetch.call_args.kwargs.get("json", {}) + assert "custom_domain" not in payload + + +@pytest.mark.asyncio +async def test_create_site_missing_name_returns_validation_error(): + ctx = make_ctx({}) + result = await netlify_integration.execute_action("create_site", {}, ctx) + assert result.type == ResultType.VALIDATION_ERROR + + +@pytest.mark.asyncio +async def test_create_site_error_returns_action_error(): + ctx = make_ctx_error(Exception("API error")) + result = await netlify_integration.execute_action("create_site", {"name": "my-site"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "API error" in result.result.message + + +# ============================================================================= +# GET SITE +# ============================================================================= + + +@pytest.mark.asyncio +async def test_get_site_returns_site_details(): + site = {"id": "site-abc", "name": "my-site", "url": "https://my-site.netlify.app"} + ctx = make_ctx(site) + result = await netlify_integration.execute_action("get_site", {"site_id": "site-abc"}, ctx) + assert result.result.data["site"] == site + + +@pytest.mark.asyncio +async def test_get_site_calls_site_id_url(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("get_site", {"site_id": "site-abc"}, ctx) + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert "site-abc" in url + assert ctx.fetch.call_args.kwargs.get("method") == "GET" + + +@pytest.mark.asyncio +async def test_get_site_missing_site_id_returns_validation_error(): + ctx = make_ctx({}) + result = await netlify_integration.execute_action("get_site", {}, ctx) + assert result.type == ResultType.VALIDATION_ERROR + + +@pytest.mark.asyncio +async def test_get_site_error_returns_action_error(): + ctx = make_ctx_error(Exception("Not found")) + result = await netlify_integration.execute_action("get_site", {"site_id": "bad-id"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Not found" in result.result.message + + +# ============================================================================= +# UPDATE SITE +# ============================================================================= + + +@pytest.mark.asyncio +async def test_update_site_returns_updated_site(): + site = {"id": "s1", "name": "new-name"} + ctx = make_ctx(site) + result = await netlify_integration.execute_action("update_site", {"site_id": "s1", "name": "new-name"}, ctx) + assert result.result.data["site"] == site + + +@pytest.mark.asyncio +async def test_update_site_sends_patch_to_site_url(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("update_site", {"site_id": "s1", "name": "renamed"}, ctx) + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert "s1" in url + assert ctx.fetch.call_args.kwargs.get("method") == "PATCH" + + +@pytest.mark.asyncio +async def test_update_site_sends_name_in_payload(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("update_site", {"site_id": "s1", "name": "renamed"}, ctx) + payload = ctx.fetch.call_args.kwargs.get("json", {}) + assert payload.get("name") == "renamed" + + +@pytest.mark.asyncio +async def test_update_site_sends_custom_domain_in_payload(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("update_site", {"site_id": "s1", "custom_domain": "new.example.com"}, ctx) + payload = ctx.fetch.call_args.kwargs.get("json", {}) + assert payload.get("custom_domain") == "new.example.com" + + +@pytest.mark.asyncio +async def test_update_site_omits_empty_optional_fields(): + ctx = make_ctx({"id": "s1"}) + await netlify_integration.execute_action("update_site", {"site_id": "s1"}, ctx) + payload = ctx.fetch.call_args.kwargs.get("json", {}) + assert "name" not in payload + assert "custom_domain" not in payload + + +@pytest.mark.asyncio +async def test_update_site_error_returns_action_error(): + ctx = make_ctx_error(Exception("Forbidden")) + result = await netlify_integration.execute_action("update_site", {"site_id": "s1"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Forbidden" in result.result.message + + +# ============================================================================= +# DELETE SITE +# ============================================================================= + + +@pytest.mark.asyncio +async def test_delete_site_returns_deleted_true(): + ctx = make_ctx(None) + result = await netlify_integration.execute_action("delete_site", {"site_id": "s1"}, ctx) + assert result.result.data["deleted"] is True + + +@pytest.mark.asyncio +async def test_delete_site_sends_delete_method(): + ctx = make_ctx(None) + await netlify_integration.execute_action("delete_site", {"site_id": "site-del"}, ctx) + assert ctx.fetch.call_args.kwargs.get("method") == "DELETE" + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert "site-del" in url + + +@pytest.mark.asyncio +async def test_delete_site_missing_site_id_returns_validation_error(): + ctx = make_ctx(None) + result = await netlify_integration.execute_action("delete_site", {}, ctx) + assert result.type == ResultType.VALIDATION_ERROR + + +@pytest.mark.asyncio +async def test_delete_site_error_returns_action_error(): + ctx = make_ctx_error(Exception("Not found")) + result = await netlify_integration.execute_action("delete_site", {"site_id": "gone"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Not found" in result.result.message + + +# ============================================================================= +# LIST DEPLOYS +# ============================================================================= + + +@pytest.mark.asyncio +async def test_list_deploys_returns_deploys(): + deploys = [{"id": "d1", "state": "ready"}, {"id": "d2", "state": "building"}] + ctx = make_ctx(deploys) + result = await netlify_integration.execute_action("list_deploys", {"site_id": "s1"}, ctx) + assert result.result.data["deploys"] == deploys + + +@pytest.mark.asyncio +async def test_list_deploys_non_list_response_coerces_to_empty(): + ctx = make_ctx({"unexpected": "dict"}) + result = await netlify_integration.execute_action("list_deploys", {"site_id": "s1"}, ctx) + assert result.result.data["deploys"] == [] + + +@pytest.mark.asyncio +async def test_list_deploys_calls_site_deploys_url(): + ctx = make_ctx([]) + await netlify_integration.execute_action("list_deploys", {"site_id": "site-xyz"}, ctx) + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert "site-xyz" in url + assert url.endswith("/deploys") + assert ctx.fetch.call_args.kwargs.get("method") == "GET" + + +@pytest.mark.asyncio +async def test_list_deploys_error_returns_action_error(): + ctx = make_ctx_error(Exception("Timeout")) + result = await netlify_integration.execute_action("list_deploys", {"site_id": "s1"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Timeout" in result.result.message + + +# ============================================================================= +# CREATE DEPLOY +# ============================================================================= + + +@pytest.mark.asyncio +async def test_create_deploy_no_required_files(): + """When Netlify returns no required hashes, no file uploads happen.""" + deploy_init = {"id": "deploy-1", "required": []} + final_deploy = {"id": "deploy-1", "state": "ready", "deploy_ssl_url": "https://deploy-1.netlify.app"} + ctx = make_ctx_multi([deploy_init, final_deploy]) + + result = await netlify_integration.execute_action( + "create_deploy", + {"site_id": "s1", "files": {"/index.html": "Hello"}}, + ctx, + ) + data = result.result.data + assert data["deploy"]["id"] == "deploy-1" + assert data["deploy_url"] == "https://deploy-1.netlify.app" + assert ctx.fetch.call_count == 2 + + +@pytest.mark.asyncio +async def test_create_deploy_uploads_required_files(): + """When required hashes are returned, the matching file content is uploaded.""" + content = "Hello" + import hashlib + + sha1 = hashlib.sha1(content.encode(), usedforsecurity=False).hexdigest() + + deploy_init = {"id": "deploy-2", "required": [sha1]} + final_deploy = {"id": "deploy-2", "state": "ready", "ssl_url": "https://deploy-2.netlify.app"} + # 3 calls: create deploy, upload file, get final deploy + ctx = make_ctx_multi([deploy_init, None, final_deploy]) + + await netlify_integration.execute_action( + "create_deploy", + {"site_id": "s1", "files": {"/index.html": content}}, + ctx, + ) + assert ctx.fetch.call_count == 3 + # second call is the file upload (PUT) + upload_call = ctx.fetch.call_args_list[1] + upload_url = upload_call.args[0] if upload_call.args else upload_call.kwargs.get("url", "") + assert upload_call.kwargs.get("method") == "PUT" + assert "deploy-2" in upload_url + assert sha1 in upload_url + assert upload_call.kwargs.get("headers", {}).get("Content-Type") == "application/octet-stream" + + +@pytest.mark.asyncio +async def test_create_deploy_url_priority_ssl_url_over_url(): + """deploy_ssl_url is preferred over ssl_url, which is preferred over url.""" + deploy_init = {"id": "d1", "required": []} + final_deploy = { + "id": "d1", + "deploy_ssl_url": "https://deploy-ssl.netlify.app", + "ssl_url": "https://ssl.netlify.app", + "url": "https://plain.netlify.app", + } + ctx = make_ctx_multi([deploy_init, final_deploy]) + result = await netlify_integration.execute_action( + "create_deploy", {"site_id": "s1", "files": {"/index.html": "hi"}}, ctx + ) + assert result.result.data["deploy_url"] == "https://deploy-ssl.netlify.app" + + +@pytest.mark.asyncio +async def test_create_deploy_url_fallback_to_ssl_url(): + deploy_init = {"id": "d1", "required": []} + final_deploy = {"id": "d1", "ssl_url": "https://ssl.netlify.app", "url": "https://plain.netlify.app"} + ctx = make_ctx_multi([deploy_init, final_deploy]) + result = await netlify_integration.execute_action( + "create_deploy", {"site_id": "s1", "files": {"/index.html": "hi"}}, ctx + ) + assert result.result.data["deploy_url"] == "https://ssl.netlify.app" + + +@pytest.mark.asyncio +async def test_create_deploy_url_fallback_to_url(): + deploy_init = {"id": "d1", "required": []} + final_deploy = {"id": "d1", "url": "https://plain.netlify.app"} + ctx = make_ctx_multi([deploy_init, final_deploy]) + result = await netlify_integration.execute_action( + "create_deploy", {"site_id": "s1", "files": {"/index.html": "hi"}}, ctx + ) + assert result.result.data["deploy_url"] == "https://plain.netlify.app" + + +@pytest.mark.asyncio +async def test_create_deploy_multiple_files_hashed_correctly(): + """Each file in the mapping gets its own SHA1 digest in the payload.""" + import hashlib + + files = {"/index.html": "A", "/style.css": "body { color: red; }"} + expected_hashes = { + path: hashlib.sha1(content.encode(), usedforsecurity=False).hexdigest() for path, content in files.items() + } + + deploy_init = {"id": "d1", "required": []} + final_deploy = {"id": "d1", "deploy_ssl_url": "https://d1.netlify.app"} + ctx = make_ctx_multi([deploy_init, final_deploy]) + + await netlify_integration.execute_action("create_deploy", {"site_id": "s1", "files": files}, ctx) + + sent_files = ctx.fetch.call_args_list[0].kwargs.get("json", {}).get("files", {}) + for path, sha1 in expected_hashes.items(): + assert sent_files[path] == sha1 + + +@pytest.mark.asyncio +async def test_create_deploy_missing_id_in_deploy_response_returns_action_error(): + """If Netlify's initial create-deploy response has no 'id', ActionError before any URL corruption.""" + deploy_init = {"required": ["abc123"]} # no "id" key + ctx = make_ctx(deploy_init) + result = await netlify_integration.execute_action( + "create_deploy", + {"site_id": "s1", "files": {"/index.html": "hi"}}, + ctx, + ) + assert result.type == ResultType.ACTION_ERROR + assert "deploy ID" in result.result.message + + +@pytest.mark.asyncio +async def test_create_deploy_error_returns_action_error(): + ctx = make_ctx_error(Exception("Deploy failed")) + result = await netlify_integration.execute_action( + "create_deploy", {"site_id": "s1", "files": {"/index.html": "hi"}}, ctx + ) + assert result.type == ResultType.ACTION_ERROR + assert "Deploy failed" in result.result.message + + +@pytest.mark.asyncio +async def test_create_deploy_missing_required_inputs_returns_validation_error(): + ctx = make_ctx({}) + result = await netlify_integration.execute_action("create_deploy", {"site_id": "s1"}, ctx) + assert result.type == ResultType.VALIDATION_ERROR + + +# ============================================================================= +# GET DEPLOY +# ============================================================================= + + +@pytest.mark.asyncio +async def test_get_deploy_returns_deploy_details(): + deploy = {"id": "d1", "state": "ready", "deploy_ssl_url": "https://d1.netlify.app"} + ctx = make_ctx(deploy) + result = await netlify_integration.execute_action("get_deploy", {"deploy_id": "d1"}, ctx) + assert result.result.data["deploy"] == deploy + + +@pytest.mark.asyncio +async def test_get_deploy_calls_deploy_url(): + ctx = make_ctx({"id": "d99"}) + await netlify_integration.execute_action("get_deploy", {"deploy_id": "d99"}, ctx) + url = ctx.fetch.call_args.args[0] if ctx.fetch.call_args.args else ctx.fetch.call_args.kwargs["url"] + assert "d99" in url + assert ctx.fetch.call_args.kwargs.get("method") == "GET" + + +@pytest.mark.asyncio +async def test_get_deploy_missing_deploy_id_returns_validation_error(): + ctx = make_ctx({}) + result = await netlify_integration.execute_action("get_deploy", {}, ctx) + assert result.type == ResultType.VALIDATION_ERROR + + +@pytest.mark.asyncio +async def test_get_deploy_error_returns_action_error(): + ctx = make_ctx_error(Exception("Deploy not found")) + result = await netlify_integration.execute_action("get_deploy", {"deploy_id": "bad-id"}, ctx) + assert result.type == ResultType.ACTION_ERROR + assert "Deploy not found" in result.result.message From 0cfd798c7ad2de92effdc57736e575f6634d0fe7 Mon Sep 17 00:00:00 2001 From: Tram-Nguyen87 Date: Tue, 23 Jun 2026 13:57:01 +1200 Subject: [PATCH 2/3] fix(netlify): use file path in deploy upload URL; exclude destructive tests from read-only command - Upload URL now correctly uses the file path (/deploys/{id}/files{/path}) instead of the SHA1 hash per the Netlify API spec; added hash_to_path mapping to track which path corresponds to each required SHA1 - Read-only integration test command corrected to -m "integration and not destructive" so destructive lifecycle tests are not accidentally run Co-Authored-By: Claude Sonnet 4.6 --- netlify/netlify.py | 8 ++++++-- netlify/tests/test_netlify_integration.py | 2 +- netlify/tests/test_netlify_unit.py | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/netlify/netlify.py b/netlify/netlify.py index 10bad388..83429098 100644 --- a/netlify/netlify.py +++ b/netlify/netlify.py @@ -139,11 +139,14 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): # Prepare files dictionary with SHA1 hashes files_dict = {} hash_to_content = {} + hash_to_path = {} for path, content in files.items(): sha1 = hashlib.sha1(content.encode(), usedforsecurity=False).hexdigest() # nosec B324 files_dict[path] = sha1 - hash_to_content[sha1] = content + if sha1 not in hash_to_content: + hash_to_content[sha1] = content + hash_to_path[sha1] = path # Create deploy with file digests deploy_response = await context.fetch( @@ -160,9 +163,10 @@ async def execute(self, inputs: Dict[str, Any], context: ExecutionContext): for sha1_hash in required_hashes: if sha1_hash in hash_to_content: file_content = hash_to_content[sha1_hash] + file_path = hash_to_path[sha1_hash] await context.fetch( - f"{NETLIFY_API_BASE_URL}/deploys/{deploy_id}/files/{sha1_hash}", + f"{NETLIFY_API_BASE_URL}/deploys/{deploy_id}/files{file_path}", method="PUT", headers={"Content-Type": "application/octet-stream"}, data=file_content.encode(), diff --git a/netlify/tests/test_netlify_integration.py b/netlify/tests/test_netlify_integration.py index 5f9f522c..cfef922d 100644 --- a/netlify/tests/test_netlify_integration.py +++ b/netlify/tests/test_netlify_integration.py @@ -5,7 +5,7 @@ set in the NETLIFY_ACCESS_TOKEN environment variable. Run all read-only tests: - pytest netlify/tests/test_netlify_integration.py -m integration + pytest netlify/tests/test_netlify_integration.py -m "integration and not destructive" Run destructive tests (sites / deploys created on the real account): pytest netlify/tests/test_netlify_integration.py -m "integration and destructive" diff --git a/netlify/tests/test_netlify_unit.py b/netlify/tests/test_netlify_unit.py index acb07041..77bfa4d3 100644 --- a/netlify/tests/test_netlify_unit.py +++ b/netlify/tests/test_netlify_unit.py @@ -359,7 +359,7 @@ async def test_create_deploy_uploads_required_files(): upload_url = upload_call.args[0] if upload_call.args else upload_call.kwargs.get("url", "") assert upload_call.kwargs.get("method") == "PUT" assert "deploy-2" in upload_url - assert sha1 in upload_url + assert "/index.html" in upload_url assert upload_call.kwargs.get("headers", {}).get("Content-Type") == "application/octet-stream" From ca068f1b3a03206b6aeab59f1a9476def1b27573 Mon Sep 17 00:00:00 2001 From: Tram-Nguyen87 Date: Wed, 24 Jun 2026 14:09:21 +1200 Subject: [PATCH 3/3] fix(netlify): add NETLIFY_ACCESS_TOKEN to root .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Kai's blocker — integration tests read this variable but it was missing from the canonical setup contract. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 0ead0748..e93f998b 100644 --- a/.env.example +++ b/.env.example @@ -290,3 +290,7 @@ # TYPEFORM_TEST_FORM_ID= # Optional: existing workspace ID for get_workspace (falls back to list_workspaces if unset). # TYPEFORM_TEST_WORKSPACE_ID= + +# -- Netlify -- +# Personal access token for the Netlify API. +# NETLIFY_ACCESS_TOKEN=