|
7 | 7 | import respx |
8 | 8 |
|
9 | 9 | from stackone_ai import StackOneTool |
10 | | -from stackone_ai.models import ExecuteConfig, ToolParameters |
| 10 | +from stackone_ai.models import ( |
| 11 | + ExecuteConfig, |
| 12 | + ToolParameters, |
| 13 | + _filename_from_content_disposition, |
| 14 | + _is_json_content_type, |
| 15 | +) |
11 | 16 | from stackone_ai.toolset import _StackOneRpcTool |
12 | 17 | from tests.conftest import TEST_BASE_URL |
13 | 18 |
|
@@ -332,3 +337,177 @@ def test_extract_record_with_non_dict(self, rpc_tool): |
332 | 337 | assert rpc_tool._extract_record("string") is None |
333 | 338 | assert rpc_tool._extract_record(123) is None |
334 | 339 | assert rpc_tool._extract_record(None) is None |
| 340 | + |
| 341 | + |
| 342 | +class TestBinaryDownloadResponse: |
| 343 | + """File-download actions return raw bytes + metadata instead of failing on JSON parsing. |
| 344 | +
|
| 345 | + The StackOne API serves file downloads as raw binary with the file's own MIME type |
| 346 | + (e.g. application/pdf) and a Content-Disposition header - never JSON. The returned |
| 347 | + shape mirrors the StackOne generated SDKs' download response (content + content_type + |
| 348 | + status_code + headers), with content as raw bytes (the Python analog of the Java |
| 349 | + client's byte[] body / the TypeScript client's response stream). |
| 350 | + """ |
| 351 | + |
| 352 | + @respx.mock |
| 353 | + def test_binary_response_returns_content_dict(self, mock_tool): |
| 354 | + """A non-JSON (binary) body is returned as bytes + metadata, not JSON-parsed.""" |
| 355 | + # Leading bytes of a real PDF; the 0xc4 byte is invalid UTF-8 and is exactly |
| 356 | + # what makes the unconditional response.json() raise UnicodeDecodeError. |
| 357 | + pdf_bytes = b"%PDF-1.4\n%\xc4\xe5\xf2\xe5\xeb\xa7\xf3\xa0\xd0\xc4\xc6\n1 0 obj\n" |
| 358 | + respx.post("https://api.example.com/test").mock( |
| 359 | + return_value=httpx.Response( |
| 360 | + 200, |
| 361 | + headers={ |
| 362 | + "content-type": "application/pdf", |
| 363 | + "content-disposition": 'attachment; filename="download.pdf"', |
| 364 | + }, |
| 365 | + content=pdf_bytes, |
| 366 | + ) |
| 367 | + ) |
| 368 | + |
| 369 | + result = mock_tool.execute({"name": "report", "value": 1}) |
| 370 | + |
| 371 | + assert result["content"] == pdf_bytes |
| 372 | + assert result["content_type"] == "application/pdf" |
| 373 | + assert result["status_code"] == 200 |
| 374 | + assert result["file_name"] == "download.pdf" |
| 375 | + assert result["headers"]["content-type"] == "application/pdf" |
| 376 | + |
| 377 | + @respx.mock |
| 378 | + def test_rpc_download_action_returns_content_dict(self): |
| 379 | + """The RPC download path (e.g. googledrive_unified_download_file) returns bytes. |
| 380 | +
|
| 381 | + Reproduces the reported failure: a download action invoked through /actions/rpc |
| 382 | + previously raised UnicodeDecodeError because the binary body was JSON-parsed. |
| 383 | + """ |
| 384 | + parameters = ToolParameters( |
| 385 | + type="object", |
| 386 | + properties={"id": {"type": "string", "description": "File ID"}}, |
| 387 | + ) |
| 388 | + tool = _StackOneRpcTool( |
| 389 | + name="googledrive_unified_download_file", |
| 390 | + description="Download a file", |
| 391 | + parameters=parameters, |
| 392 | + api_key="test_api_key", |
| 393 | + base_url=TEST_BASE_URL, |
| 394 | + account_id="test_account", |
| 395 | + ) |
| 396 | + |
| 397 | + rtf_bytes = b"{\\rtf1\\ansi\\ansicpg1252\\\xc4\xe5 hello}" |
| 398 | + respx.post(f"{TEST_BASE_URL}/actions/rpc").mock( |
| 399 | + return_value=httpx.Response( |
| 400 | + 200, |
| 401 | + headers={ |
| 402 | + "content-type": "application/rtf", |
| 403 | + "content-disposition": 'attachment; filename="download.rtf"', |
| 404 | + }, |
| 405 | + content=rtf_bytes, |
| 406 | + ) |
| 407 | + ) |
| 408 | + |
| 409 | + result = tool.execute({"path": {"id": "file-123"}}) |
| 410 | + |
| 411 | + assert result["content"] == rtf_bytes |
| 412 | + assert result["content_type"] == "application/rtf" |
| 413 | + assert result["file_name"] == "download.rtf" |
| 414 | + |
| 415 | + @respx.mock |
| 416 | + def test_octet_stream_without_filename(self, mock_tool): |
| 417 | + """A binary body with no Content-Disposition still returns content with file_name=None.""" |
| 418 | + blob = b"\x00\x01\x02\xc4\xff\xfe" |
| 419 | + respx.post("https://api.example.com/test").mock( |
| 420 | + return_value=httpx.Response( |
| 421 | + 200, |
| 422 | + headers={"content-type": "application/octet-stream"}, |
| 423 | + content=blob, |
| 424 | + ) |
| 425 | + ) |
| 426 | + |
| 427 | + result = mock_tool.execute({}) |
| 428 | + |
| 429 | + assert result["content"] == blob |
| 430 | + assert result["content_type"] == "application/octet-stream" |
| 431 | + assert result["file_name"] is None |
| 432 | + |
| 433 | + @respx.mock |
| 434 | + def test_json_response_still_parsed(self, mock_tool): |
| 435 | + """Regression guard: JSON responses are unchanged - parsed to a dict, not wrapped.""" |
| 436 | + respx.post("https://api.example.com/test").mock( |
| 437 | + return_value=httpx.Response(200, json={"id": "123", "ok": True}) |
| 438 | + ) |
| 439 | + |
| 440 | + result = mock_tool.execute({"name": "x", "value": 1}) |
| 441 | + |
| 442 | + assert result == {"id": "123", "ok": True} |
| 443 | + assert "content" not in result |
| 444 | + |
| 445 | + @respx.mock |
| 446 | + def test_json_with_charset_param_still_parsed(self, mock_tool): |
| 447 | + """A JSON Content-Type with parameters (charset) is still parsed as JSON.""" |
| 448 | + respx.post("https://api.example.com/test").mock( |
| 449 | + return_value=httpx.Response( |
| 450 | + 200, |
| 451 | + headers={"content-type": "application/json; charset=utf-8"}, |
| 452 | + content=b'{"ok": true}', |
| 453 | + ) |
| 454 | + ) |
| 455 | + |
| 456 | + result = mock_tool.execute({}) |
| 457 | + |
| 458 | + assert result == {"ok": True} |
| 459 | + |
| 460 | + @respx.mock |
| 461 | + def test_missing_content_type_returns_bytes(self, mock_tool): |
| 462 | + """A body with no Content-Type is treated as opaque content (bytes), not JSON. |
| 463 | +
|
| 464 | + Pins the deliberate contract: the SDK trusts Content-Type to decide JSON vs |
| 465 | + file, so an absent Content-Type is returned as raw bytes rather than risking |
| 466 | + a UTF-8/JSON decode of binary. (StackOne always labels JSON as application/json.) |
| 467 | + """ |
| 468 | + blob = b"\xff\xd8\xff\xe0\x00\x10JFIF" # JPEG magic bytes, no content-type |
| 469 | + respx.post("https://api.example.com/test").mock(return_value=httpx.Response(200, content=blob)) |
| 470 | + |
| 471 | + result = mock_tool.execute({}) |
| 472 | + |
| 473 | + assert result["content"] == blob |
| 474 | + assert result["content_type"] == "application/octet-stream" |
| 475 | + assert result["file_name"] is None |
| 476 | + |
| 477 | + |
| 478 | +class TestResponseHelpers: |
| 479 | + """Unit tests for the Content-Type and Content-Disposition helpers.""" |
| 480 | + |
| 481 | + @pytest.mark.parametrize( |
| 482 | + ("content_type", "expected"), |
| 483 | + [ |
| 484 | + ("application/json", True), |
| 485 | + ("application/json; charset=utf-8", True), |
| 486 | + ("APPLICATION/JSON", True), |
| 487 | + ("application/problem+json", True), |
| 488 | + ("application/vnd.api+json", True), |
| 489 | + ("", False), |
| 490 | + ("application/pdf", False), |
| 491 | + ("application/octet-stream", False), |
| 492 | + ("text/plain", False), |
| 493 | + ("text/json-but-not-really", False), |
| 494 | + ], |
| 495 | + ) |
| 496 | + def test_is_json_content_type(self, content_type, expected): |
| 497 | + assert _is_json_content_type(content_type) is expected |
| 498 | + |
| 499 | + @pytest.mark.parametrize( |
| 500 | + ("header", "expected"), |
| 501 | + [ |
| 502 | + ('attachment; filename="download.pdf"', "download.pdf"), |
| 503 | + ("attachment; filename=download.pdf", "download.pdf"), |
| 504 | + ('inline; filename="my report.docx"', "my report.docx"), |
| 505 | + # RFC 5987 extended form is percent-decoded and takes precedence. |
| 506 | + ("attachment; filename=\"fallback.txt\"; filename*=UTF-8''na%C3%AFve.txt", "naïve.txt"), |
| 507 | + ("attachment", None), |
| 508 | + (None, None), |
| 509 | + ("", None), |
| 510 | + ], |
| 511 | + ) |
| 512 | + def test_filename_from_content_disposition(self, header, expected): |
| 513 | + assert _filename_from_content_disposition(header) == expected |
0 commit comments