From bc0ddce251a96fa41c740ea99a9784befd4924ed Mon Sep 17 00:00:00 2001 From: Gowtham Baratam <172632046+Gowtham1-dot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:07:15 +0530 Subject: [PATCH 1/2] feat(xero): add status field to update_invoice tool (TSK-16147) - Add optional status field to update_invoice input schema (enum: DRAFT, SUBMITTED, AUTHORISED, DELETED). - Handler forwards status into the Xero POST payload when provided. - Add offline handler-level test covering schema, payload forwarding, status-omitted behavior, and the existing DRAFT-only guard. --- src/servers/xero/main.py | 7 ++ .../xero/test_update_invoice_status.py | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/servers/xero/test_update_invoice_status.py diff --git a/src/servers/xero/main.py b/src/servers/xero/main.py index 011a073..b541a25 100644 --- a/src/servers/xero/main.py +++ b/src/servers/xero/main.py @@ -1117,6 +1117,11 @@ async def handle_list_tools() -> list[Tool]: ], }, }, + "status": { + "type": "string", + "description": "Updated invoice status. Use AUTHORISED to push the draft into the invoices section in Xero. Allowed transitions from DRAFT: SUBMITTED, AUTHORISED, DELETED.", + "enum": ["DRAFT", "SUBMITTED", "AUTHORISED", "DELETED"], + }, }, "required": ["invoiceId"], }, @@ -2329,6 +2334,8 @@ async def handle_call_tool( line_item["ItemCode"] = item["itemCode"] line_items.append(line_item) invoice["LineItems"] = line_items + if arguments.get("status"): + invoice["Status"] = arguments["status"] result = await call_xero_api( f"{ACCOUNTING_API}/Invoices/{invoice_id}", diff --git a/tests/servers/xero/test_update_invoice_status.py b/tests/servers/xero/test_update_invoice_status.py new file mode 100644 index 0000000..d0a1260 --- /dev/null +++ b/tests/servers/xero/test_update_invoice_status.py @@ -0,0 +1,115 @@ +""" +Offline handler-level test for the update_invoice status field (TSK-16147). + +Mocks Xero HTTP calls and Nango credentials so the test runs with no network +access and no real tenant. Verifies: + 1. The status field is in the registered tool schema with the correct enum. + 2. status="AUTHORISED" is forwarded to Xero in the POST payload. + 3. Omitting status leaves the payload unchanged (no Status key). + 4. The DRAFT-only guard still rejects updates against non-DRAFT invoices. +""" + +import asyncio +import json +from unittest.mock import patch, AsyncMock + +from mcp.types import ListToolsRequest, CallToolRequest, CallToolRequestParams + +from src.servers.xero import main as xero_main + + +def _draft_invoice_response(): + return {"Invoices": [{"InvoiceID": "inv-1", "Status": "DRAFT"}]} + + +def _authorised_invoice_response(): + return {"Invoices": [{"InvoiceID": "inv-1", "Status": "AUTHORISED"}]} + + +async def _invoke(tool_name: str, arguments: dict, api_responses: list): + """Run the call_tool handler with mocked Xero + Nango.""" + srv = xero_main.create_server(user_id="test-user") + handler = srv.request_handlers[CallToolRequest] + + # Mock the Xero HTTP layer: each call returns the next item in api_responses. + api_mock = AsyncMock(side_effect=api_responses) + creds_mock = AsyncMock(return_value=("fake-token", "fake-tenant")) + + with patch.object(xero_main, "call_xero_api", api_mock), patch.object( + xero_main, "get_xero_credentials", creds_mock + ): + request = CallToolRequest( + method="tools/call", + params=CallToolRequestParams(name=tool_name, arguments=arguments), + ) + result = await handler(request) + return result, api_mock + + +async def test_schema_exposes_status(): + srv = xero_main.create_server(user_id="test-user") + list_handler = srv.request_handlers[ListToolsRequest] + result = await list_handler(ListToolsRequest(method="tools/list")) + tool = next(t for t in result.root.tools if t.name == "update_invoice") + status = tool.inputSchema["properties"]["status"] + assert status["type"] == "string" + assert status["enum"] == ["DRAFT", "SUBMITTED", "AUTHORISED", "DELETED"] + assert "status" not in tool.inputSchema["required"] + print("PASS schema exposes status with correct enum and is optional") + + +async def test_status_authorised_is_forwarded(): + _, api_mock = await _invoke( + "update_invoice", + {"invoiceId": "inv-1", "status": "AUTHORISED"}, + api_responses=[_draft_invoice_response(), _authorised_invoice_response()], + ) + # Two calls: GET for guard, POST for update + assert api_mock.call_count == 2 + post_call = api_mock.call_args_list[1] + payload = post_call.kwargs["data"] + invoice_payload = payload["Invoices"][0] + assert invoice_payload["InvoiceID"] == "inv-1" + assert invoice_payload["Status"] == "AUTHORISED" + print("PASS status=AUTHORISED forwarded in POST payload:", invoice_payload) + + +async def test_status_omitted_means_no_status_key(): + _, api_mock = await _invoke( + "update_invoice", + {"invoiceId": "inv-1", "reference": "PO-123"}, + api_responses=[_draft_invoice_response(), _draft_invoice_response()], + ) + post_call = api_mock.call_args_list[1] + invoice_payload = post_call.kwargs["data"]["Invoices"][0] + assert "Status" not in invoice_payload, ( + f"expected no Status key when omitted, got {invoice_payload}" + ) + assert invoice_payload["Reference"] == "PO-123" + print("PASS status omitted -> no Status key in payload:", invoice_payload) + + +async def test_draft_guard_still_blocks_non_draft(): + non_draft = {"Invoices": [{"InvoiceID": "inv-1", "Status": "AUTHORISED"}]} + result, api_mock = await _invoke( + "update_invoice", + {"invoiceId": "inv-1", "status": "DELETED"}, + api_responses=[non_draft], + ) + # Handler catches the ValueError and returns an Error TextContent + text = result.root.content[0].text + assert "Only DRAFT invoices can be updated" in text, text + assert api_mock.call_count == 1 # never reached the POST + print("PASS non-DRAFT invoice rejected before POST:", text.strip()) + + +async def main(): + await test_schema_exposes_status() + await test_status_authorised_is_forwarded() + await test_status_omitted_means_no_status_key() + await test_draft_guard_still_blocks_non_draft() + print("\nAll 4 tests passed.") + + +if __name__ == "__main__": + asyncio.run(main()) From 940d1f62ec9cde7eac3169e39052047ac2864aed Mon Sep 17 00:00:00 2001 From: Gowtham Baratam <172632046+Gowtham1-dot@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:35:21 +0530 Subject: [PATCH 2/2] chore: apply Black formatting to update_invoice status test --- tests/servers/xero/test_update_invoice_status.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/servers/xero/test_update_invoice_status.py b/tests/servers/xero/test_update_invoice_status.py index d0a1260..a629d2e 100644 --- a/tests/servers/xero/test_update_invoice_status.py +++ b/tests/servers/xero/test_update_invoice_status.py @@ -82,9 +82,9 @@ async def test_status_omitted_means_no_status_key(): ) post_call = api_mock.call_args_list[1] invoice_payload = post_call.kwargs["data"]["Invoices"][0] - assert "Status" not in invoice_payload, ( - f"expected no Status key when omitted, got {invoice_payload}" - ) + assert ( + "Status" not in invoice_payload + ), f"expected no Status key when omitted, got {invoice_payload}" assert invoice_payload["Reference"] == "PO-123" print("PASS status omitted -> no Status key in payload:", invoice_payload)