|
7 | 7 | from unittest.mock import Mock, patch, MagicMock
|
8 | 8 | import io
|
9 | 9 |
|
10 |
| -from quilt_mcp.tools.package_ops import package_create, _collect_objects_into_package |
| 10 | +from quilt_mcp.tools.package_ops import ( |
| 11 | + package_create, |
| 12 | + package_update, |
| 13 | + package_delete, |
| 14 | + _collect_objects_into_package, |
| 15 | + _normalize_registry, |
| 16 | + _build_selector_fn |
| 17 | +) |
11 | 18 |
|
12 | 19 |
|
13 | 20 | class TestPackageCreate:
|
@@ -341,3 +348,367 @@ def test_collect_objects_with_invalid_uris(self):
|
341 | 348 | assert any("Skipping non-S3 URI" in w for w in warnings)
|
342 | 349 | assert any("Skipping bucket-only URI" in w for w in warnings)
|
343 | 350 | assert any("Skipping URI that appears to be a 'directory'" in w for w in warnings)
|
| 351 | + |
| 352 | + |
| 353 | +class TestNormalizeRegistry: |
| 354 | + """Test cases for the _normalize_registry function.""" |
| 355 | + |
| 356 | + def test_normalize_registry_with_s3_prefix(self): |
| 357 | + """Test that s3:// URIs are returned as-is.""" |
| 358 | + result = _normalize_registry("s3://my-bucket") |
| 359 | + assert result == "s3://my-bucket" |
| 360 | + |
| 361 | + def test_normalize_registry_without_s3_prefix(self): |
| 362 | + """Test that bucket names get s3:// prefix added.""" |
| 363 | + result = _normalize_registry("my-bucket") |
| 364 | + assert result == "s3://my-bucket" |
| 365 | + |
| 366 | + def test_normalize_registry_with_path(self): |
| 367 | + """Test that bucket names with paths get s3:// prefix added.""" |
| 368 | + result = _normalize_registry("my-bucket/path/to/files") |
| 369 | + assert result == "s3://my-bucket/path/to/files" |
| 370 | + |
| 371 | + |
| 372 | +class TestBuildSelectorFn: |
| 373 | + """Test cases for the _build_selector_fn function.""" |
| 374 | + |
| 375 | + def test_build_selector_fn_all(self): |
| 376 | + """Test selector function with 'all' mode.""" |
| 377 | + selector = _build_selector_fn("all", "s3://target-bucket") |
| 378 | + |
| 379 | + # Should return True for any entry |
| 380 | + result = selector("test_key", Mock()) |
| 381 | + assert result is True |
| 382 | + |
| 383 | + def test_build_selector_fn_none(self): |
| 384 | + """Test selector function with 'none' mode.""" |
| 385 | + selector = _build_selector_fn("none", "s3://target-bucket") |
| 386 | + |
| 387 | + # Should return False for any entry |
| 388 | + result = selector("test_key", Mock()) |
| 389 | + assert result is False |
| 390 | + |
| 391 | + def test_build_selector_fn_same_bucket_matching(self): |
| 392 | + """Test selector function with 'same_bucket' mode - matching bucket.""" |
| 393 | + selector = _build_selector_fn("same_bucket", "s3://target-bucket") |
| 394 | + |
| 395 | + # Mock entry with matching bucket |
| 396 | + entry = Mock() |
| 397 | + entry.physical_key = "s3://target-bucket/path/to/file.txt" |
| 398 | + |
| 399 | + result = selector("test_key", entry) |
| 400 | + assert result is True |
| 401 | + |
| 402 | + def test_build_selector_fn_same_bucket_non_matching(self): |
| 403 | + """Test selector function with 'same_bucket' mode - non-matching bucket.""" |
| 404 | + selector = _build_selector_fn("same_bucket", "s3://target-bucket") |
| 405 | + |
| 406 | + # Mock entry with different bucket |
| 407 | + entry = Mock() |
| 408 | + entry.physical_key = "s3://other-bucket/path/to/file.txt" |
| 409 | + |
| 410 | + result = selector("test_key", entry) |
| 411 | + assert result is False |
| 412 | + |
| 413 | + def test_build_selector_fn_same_bucket_invalid_physical_key(self): |
| 414 | + """Test selector function with 'same_bucket' mode - invalid physical key.""" |
| 415 | + selector = _build_selector_fn("same_bucket", "s3://target-bucket") |
| 416 | + |
| 417 | + # Mock entry with invalid physical key |
| 418 | + entry = Mock() |
| 419 | + entry.physical_key = "invalid-key" |
| 420 | + |
| 421 | + result = selector("test_key", entry) |
| 422 | + assert result is False |
| 423 | + |
| 424 | + def test_build_selector_fn_same_bucket_exception_on_physical_key(self): |
| 425 | + """Test selector function with 'same_bucket' mode - exception getting physical key.""" |
| 426 | + selector = _build_selector_fn("same_bucket", "s3://target-bucket") |
| 427 | + |
| 428 | + # Mock entry that raises exception when accessing physical_key |
| 429 | + entry = Mock() |
| 430 | + entry.physical_key = Mock(side_effect=Exception("Access error")) |
| 431 | + |
| 432 | + result = selector("test_key", entry) |
| 433 | + assert result is False |
| 434 | + |
| 435 | + def test_build_selector_fn_same_bucket_malformed_s3_uri(self): |
| 436 | + """Test selector function with 'same_bucket' mode - malformed S3 URI.""" |
| 437 | + selector = _build_selector_fn("same_bucket", "s3://target-bucket") |
| 438 | + |
| 439 | + # Mock entry with malformed S3 URI |
| 440 | + entry = Mock() |
| 441 | + entry.physical_key = "s3://malformed" # No bucket separator |
| 442 | + |
| 443 | + result = selector("test_key", entry) |
| 444 | + assert result is False |
| 445 | + |
| 446 | + def test_build_selector_fn_default_mode(self): |
| 447 | + """Test selector function with unknown mode defaults to 'all'.""" |
| 448 | + selector = _build_selector_fn("unknown_mode", "s3://target-bucket") |
| 449 | + |
| 450 | + # Should behave like 'all' mode |
| 451 | + result = selector("test_key", Mock()) |
| 452 | + assert result is True |
| 453 | + |
| 454 | + |
| 455 | +class TestCollectObjectsIntoPackageAdvanced: |
| 456 | + """Advanced test cases for the _collect_objects_into_package function.""" |
| 457 | + |
| 458 | + def test_collect_objects_with_duplicate_logical_paths(self): |
| 459 | + """Test collecting objects with duplicate logical paths (filename collisions).""" |
| 460 | + mock_pkg = Mock() |
| 461 | + |
| 462 | + # Track what gets added as we go - initially package is empty |
| 463 | + added_keys = set() |
| 464 | + |
| 465 | + def contains_side_effect(key): |
| 466 | + # Return True if the key has already been added to the package |
| 467 | + return key in added_keys |
| 468 | + |
| 469 | + def set_side_effect(key, uri): |
| 470 | + # Track what gets added |
| 471 | + added_keys.add(key) |
| 472 | + |
| 473 | + mock_pkg.__contains__ = Mock(side_effect=contains_side_effect) |
| 474 | + mock_pkg.set = Mock(side_effect=set_side_effect) |
| 475 | + |
| 476 | + s3_uris = [ |
| 477 | + "s3://bucket/file.txt", |
| 478 | + "s3://bucket/path/file.txt", # Same filename, should get counter prefix |
| 479 | + ] |
| 480 | + warnings = [] |
| 481 | + |
| 482 | + result = _collect_objects_into_package(mock_pkg, s3_uris, flatten=True, warnings=warnings) |
| 483 | + |
| 484 | + # Verify objects were added with unique logical paths |
| 485 | + assert len(result) == 2 |
| 486 | + assert mock_pkg.set.call_count == 2 |
| 487 | + |
| 488 | + # Check the logical paths used |
| 489 | + logical_paths = [call.args[0] for call in mock_pkg.set.call_args_list] |
| 490 | + source_uris = [call.args[1] for call in mock_pkg.set.call_args_list] |
| 491 | + |
| 492 | + # First URI should use original filename (package is initially empty) |
| 493 | + assert logical_paths[0] == "file.txt" |
| 494 | + assert source_uris[0] == "s3://bucket/file.txt" |
| 495 | + |
| 496 | + # Second URI should get counter prefix since "file.txt" is now taken |
| 497 | + assert logical_paths[1] == "1_file.txt" # Counter starts at 1 |
| 498 | + assert source_uris[1] == "s3://bucket/path/file.txt" |
| 499 | + |
| 500 | + def test_collect_objects_with_package_set_exception(self): |
| 501 | + """Test collecting objects when package.set() raises an exception.""" |
| 502 | + mock_pkg = Mock() |
| 503 | + mock_pkg.__contains__ = Mock(return_value=False) |
| 504 | + mock_pkg.set = Mock(side_effect=Exception("Failed to set object")) |
| 505 | + |
| 506 | + s3_uris = ["s3://bucket/file.txt"] |
| 507 | + warnings = [] |
| 508 | + |
| 509 | + result = _collect_objects_into_package(mock_pkg, s3_uris, flatten=True, warnings=warnings) |
| 510 | + |
| 511 | + # Verify no objects were added due to exception |
| 512 | + assert len(result) == 0 |
| 513 | + assert mock_pkg.set.call_count == 1 |
| 514 | + |
| 515 | + # Verify warning was generated |
| 516 | + assert len(warnings) == 1 |
| 517 | + assert "Failed to add s3://bucket/file.txt:" in warnings[0] |
| 518 | + |
| 519 | + |
| 520 | +class TestPackageCreateErrorHandling: |
| 521 | + """Test error handling in package_create function.""" |
| 522 | + |
| 523 | + def test_package_create_with_empty_s3_uris(self): |
| 524 | + """Test package_create with empty S3 URIs list.""" |
| 525 | + result = package_create( |
| 526 | + package_name="test/package", |
| 527 | + s3_uris=[], |
| 528 | + registry="s3://test-bucket" |
| 529 | + ) |
| 530 | + |
| 531 | + assert result["error"] == "No S3 URIs provided" |
| 532 | + |
| 533 | + def test_package_create_with_empty_package_name(self): |
| 534 | + """Test package_create with empty package name.""" |
| 535 | + result = package_create( |
| 536 | + package_name="", |
| 537 | + s3_uris=["s3://bucket/file.txt"], |
| 538 | + registry="s3://test-bucket" |
| 539 | + ) |
| 540 | + |
| 541 | + assert result["error"] == "Package name is required" |
| 542 | + |
| 543 | + def test_package_create_with_invalid_json_metadata(self): |
| 544 | + """Test package_create with invalid JSON string metadata.""" |
| 545 | + result = package_create( |
| 546 | + package_name="test/package", |
| 547 | + s3_uris=["s3://bucket/file.txt"], |
| 548 | + metadata='{"invalid": json syntax}', # Invalid JSON |
| 549 | + registry="s3://test-bucket" |
| 550 | + ) |
| 551 | + |
| 552 | + assert result["success"] is False |
| 553 | + assert result["error"] == "Invalid metadata format" |
| 554 | + assert "json_error" in result |
| 555 | + assert "examples" in result |
| 556 | + |
| 557 | + def test_package_create_with_non_dict_non_string_metadata(self): |
| 558 | + """Test package_create with metadata that's not a dict or string.""" |
| 559 | + result = package_create( |
| 560 | + package_name="test/package", |
| 561 | + s3_uris=["s3://bucket/file.txt"], |
| 562 | + metadata=123, # Invalid type |
| 563 | + registry="s3://test-bucket" |
| 564 | + ) |
| 565 | + |
| 566 | + assert result["success"] is False |
| 567 | + assert result["error"] == "Invalid metadata type" |
| 568 | + assert result["provided_type"] == "int" |
| 569 | + assert "examples" in result |
| 570 | + |
| 571 | + @patch("quilt_mcp.tools.package_ops.quilt_service.create_package_revision") |
| 572 | + def test_package_create_with_service_error_response(self, mock_create_revision): |
| 573 | + """Test package_create when service returns error response.""" |
| 574 | + mock_create_revision.return_value = { |
| 575 | + "error": "Service failed to create package", |
| 576 | + "details": "Some internal error" |
| 577 | + } |
| 578 | + |
| 579 | + result = package_create( |
| 580 | + package_name="test/package", |
| 581 | + s3_uris=["s3://bucket/file.txt"], |
| 582 | + registry="s3://test-bucket" |
| 583 | + ) |
| 584 | + |
| 585 | + assert result["error"] == "Service failed to create package" |
| 586 | + assert result["package_name"] == "test/package" |
| 587 | + assert "warnings" in result |
| 588 | + |
| 589 | + @patch("quilt_mcp.tools.package_ops.quilt_service.create_package_revision") |
| 590 | + def test_package_create_with_service_exception(self, mock_create_revision): |
| 591 | + """Test package_create when service raises exception.""" |
| 592 | + mock_create_revision.side_effect = Exception("Network error") |
| 593 | + |
| 594 | + result = package_create( |
| 595 | + package_name="test/package", |
| 596 | + s3_uris=["s3://bucket/file.txt"], |
| 597 | + registry="s3://test-bucket" |
| 598 | + ) |
| 599 | + |
| 600 | + assert "Failed to create package: Network error" in result["error"] |
| 601 | + assert result["package_name"] == "test/package" |
| 602 | + assert "warnings" in result |
| 603 | + |
| 604 | + |
| 605 | +class TestPackageUpdate: |
| 606 | + """Test cases for the package_update function.""" |
| 607 | + |
| 608 | + def test_package_update_with_empty_s3_uris(self): |
| 609 | + """Test package_update with empty S3 URIs list.""" |
| 610 | + result = package_update( |
| 611 | + package_name="test/package", |
| 612 | + s3_uris=[], |
| 613 | + registry="s3://test-bucket" |
| 614 | + ) |
| 615 | + |
| 616 | + assert result["error"] == "No S3 URIs provided" |
| 617 | + |
| 618 | + def test_package_update_with_empty_package_name(self): |
| 619 | + """Test package_update with empty package name.""" |
| 620 | + result = package_update( |
| 621 | + package_name="", |
| 622 | + s3_uris=["s3://bucket/file.txt"], |
| 623 | + registry="s3://test-bucket" |
| 624 | + ) |
| 625 | + |
| 626 | + assert result["error"] == "package_name is required for package_update" |
| 627 | + |
| 628 | + def test_package_update_with_invalid_json_metadata(self): |
| 629 | + """Test package_update with invalid JSON string metadata.""" |
| 630 | + result = package_update( |
| 631 | + package_name="test/package", |
| 632 | + s3_uris=["s3://bucket/file.txt"], |
| 633 | + metadata='{"invalid": json}', # Invalid JSON |
| 634 | + registry="s3://test-bucket" |
| 635 | + ) |
| 636 | + |
| 637 | + assert result["success"] is False |
| 638 | + assert result["error"] == "Invalid metadata format" |
| 639 | + assert "json_error" in result |
| 640 | + |
| 641 | + def test_package_update_with_non_dict_metadata(self): |
| 642 | + """Test package_update with metadata that's not a dict or string.""" |
| 643 | + result = package_update( |
| 644 | + package_name="test/package", |
| 645 | + s3_uris=["s3://bucket/file.txt"], |
| 646 | + metadata=["invalid", "type"], # Invalid type |
| 647 | + registry="s3://test-bucket" |
| 648 | + ) |
| 649 | + |
| 650 | + assert result["success"] is False |
| 651 | + assert result["error"] == "Invalid metadata type" |
| 652 | + assert result["provided_type"] == "list" |
| 653 | + |
| 654 | + @patch("quilt_mcp.tools.package_ops.QuiltService") |
| 655 | + @patch("quilt_mcp.utils.suppress_stdout") |
| 656 | + def test_package_update_browse_package_failure(self, mock_suppress, mock_quilt_service_class): |
| 657 | + """Test package_update when browsing existing package fails.""" |
| 658 | + mock_service = Mock() |
| 659 | + mock_service.browse_package.side_effect = Exception("Package not found") |
| 660 | + mock_quilt_service_class.return_value = mock_service |
| 661 | + |
| 662 | + result = package_update( |
| 663 | + package_name="test/package", |
| 664 | + s3_uris=["s3://bucket/file.txt"], |
| 665 | + registry="s3://test-bucket" |
| 666 | + ) |
| 667 | + |
| 668 | + assert "Failed to browse existing package 'test/package':" in result["error"] |
| 669 | + assert result["package_name"] == "test/package" |
| 670 | + |
| 671 | + |
| 672 | +class TestPackageDelete: |
| 673 | + """Test cases for the package_delete function.""" |
| 674 | + |
| 675 | + def test_package_delete_with_empty_package_name(self): |
| 676 | + """Test package_delete with empty package name.""" |
| 677 | + result = package_delete( |
| 678 | + package_name="", |
| 679 | + registry="s3://test-bucket" |
| 680 | + ) |
| 681 | + |
| 682 | + assert result["error"] == "package_name is required for package deletion" |
| 683 | + |
| 684 | + @patch("quilt_mcp.tools.package_ops.quilt3.delete_package") |
| 685 | + @patch("quilt_mcp.utils.suppress_stdout") |
| 686 | + def test_package_delete_success(self, mock_suppress, mock_delete): |
| 687 | + """Test successful package deletion.""" |
| 688 | + mock_delete.return_value = None # Successful deletion |
| 689 | + |
| 690 | + result = package_delete( |
| 691 | + package_name="test/package", |
| 692 | + registry="s3://test-bucket" |
| 693 | + ) |
| 694 | + |
| 695 | + assert result["status"] == "success" |
| 696 | + assert result["action"] == "deleted" |
| 697 | + assert result["package_name"] == "test/package" |
| 698 | + assert result["registry"] == "s3://test-bucket" |
| 699 | + assert "deleted successfully" in result["message"] |
| 700 | + |
| 701 | + @patch("quilt_mcp.tools.package_ops.quilt3.delete_package") |
| 702 | + @patch("quilt_mcp.utils.suppress_stdout") |
| 703 | + def test_package_delete_failure(self, mock_suppress, mock_delete): |
| 704 | + """Test package deletion failure.""" |
| 705 | + mock_delete.side_effect = Exception("Deletion failed") |
| 706 | + |
| 707 | + result = package_delete( |
| 708 | + package_name="test/package", |
| 709 | + registry="s3://test-bucket" |
| 710 | + ) |
| 711 | + |
| 712 | + assert "Failed to delete package 'test/package':" in result["error"] |
| 713 | + assert result["package_name"] == "test/package" |
| 714 | + assert result["registry"] == "s3://test-bucket" |
0 commit comments