|
1 | 1 | from pathlib import Path |
| 2 | +from urllib.parse import parse_qs, urlparse |
2 | 3 |
|
3 | 4 | import pytest |
4 | 5 | import requests_mock |
|
22 | 23 | UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = ( |
23 | 24 | TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.xml" |
24 | 25 | ) |
| 26 | +GET_BY_NAME_TOP_LEVEL_XML = TEST_ASSET_DIR / "project_get_by_name_top_level.xml" |
| 27 | +GET_BY_NAME_CHILD_XML = TEST_ASSET_DIR / "project_get_by_name_child.xml" |
| 28 | +GET_EMPTY_XML = TEST_ASSET_DIR / "project_get_empty.xml" |
| 29 | +GET_BY_NAME_AMBIGUOUS_ROOT_XML = TEST_ASSET_DIR / "project_get_by_name_ambiguous_root.xml" |
| 30 | +GET_BY_NAME_DUPLICATE_SIBLINGS_XML = TEST_ASSET_DIR / "project_get_by_name_duplicate_siblings.xml" |
| 31 | +GET_BY_NAME_GRANDCHILD_XML = TEST_ASSET_DIR / "project_get_by_name_grandchild.xml" |
25 | 32 |
|
26 | 33 |
|
27 | 34 | @pytest.fixture(scope="function") |
@@ -464,3 +471,229 @@ def test_get_all_fields(server: TSC.Server) -> None: |
464 | 471 | assert project.content_permissions == "ManagedByOwner" |
465 | 472 | assert project.parent_id is None |
466 | 473 | assert project.writeable is True |
| 474 | + |
| 475 | + |
| 476 | +# --- get_by_path tests --- |
| 477 | + |
| 478 | + |
| 479 | +def test_get_by_path_top_level(server: TSC.Server) -> None: |
| 480 | + """A single-component path resolves to a top-level project.""" |
| 481 | + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 482 | + with requests_mock.mock() as m: |
| 483 | + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) |
| 484 | + project = server.projects.get_by_path("Tableau") |
| 485 | + |
| 486 | + assert project is not None |
| 487 | + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" |
| 488 | + assert project.name == "Tableau" |
| 489 | + assert project.parent_id is None |
| 490 | + |
| 491 | + |
| 492 | +def test_get_by_path_top_level_with_leading_slash(server: TSC.Server) -> None: |
| 493 | + """Leading slash is stripped; result is the same as without it.""" |
| 494 | + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 495 | + with requests_mock.mock() as m: |
| 496 | + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) |
| 497 | + project = server.projects.get_by_path("/Tableau") |
| 498 | + |
| 499 | + assert project is not None |
| 500 | + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" |
| 501 | + |
| 502 | + |
| 503 | +def _filter_params(request) -> dict: |
| 504 | + """Parse the comma-separated 'filter' query param into a dict of field:value pairs.""" |
| 505 | + qs = parse_qs(urlparse(request.url).query) |
| 506 | + filters = {} |
| 507 | + for token in qs.get("filter", [""])[0].split(","): |
| 508 | + if ":" in token: |
| 509 | + parts = token.split(":", 2) # field, operator, value |
| 510 | + if len(parts) == 3: |
| 511 | + filters[parts[0]] = parts[2] |
| 512 | + return filters |
| 513 | + |
| 514 | + |
| 515 | +def test_get_by_path_nested(server: TSC.Server) -> None: |
| 516 | + """A two-component path walks the hierarchy and returns the child project.""" |
| 517 | + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 518 | + child_xml = GET_BY_NAME_CHILD_XML.read_text() |
| 519 | + baseurl = server.projects.baseurl |
| 520 | + |
| 521 | + def respond(request, context): |
| 522 | + params = _filter_params(request) |
| 523 | + if params.get("name") == "Tableau" and "parentProjectId" not in params: |
| 524 | + return top_level_xml |
| 525 | + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": |
| 526 | + return child_xml |
| 527 | + context.status_code = 404 |
| 528 | + return "" |
| 529 | + |
| 530 | + with requests_mock.mock() as m: |
| 531 | + m.get(baseurl, text=respond) |
| 532 | + project = server.projects.get_by_path("Tableau/Child 1") |
| 533 | + |
| 534 | + assert project is not None |
| 535 | + assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" |
| 536 | + assert project.name == "Child 1" |
| 537 | + assert project.parent_id == "1d0304cd-3796-429f-b815-7258370b9b74" |
| 538 | + |
| 539 | + |
| 540 | +def test_get_by_path_not_found_root(server: TSC.Server) -> None: |
| 541 | + """Returns None when the root-level component does not exist.""" |
| 542 | + empty_xml = GET_EMPTY_XML.read_text() |
| 543 | + baseurl = server.projects.baseurl |
| 544 | + |
| 545 | + def respond(request, context): |
| 546 | + params = _filter_params(request) |
| 547 | + if params.get("name") == "NonExistent": |
| 548 | + return empty_xml |
| 549 | + context.status_code = 404 |
| 550 | + return "" |
| 551 | + |
| 552 | + with requests_mock.mock() as m: |
| 553 | + m.get(baseurl, text=respond) |
| 554 | + project = server.projects.get_by_path("NonExistent") |
| 555 | + |
| 556 | + assert project is None |
| 557 | + |
| 558 | + |
| 559 | +def test_get_by_path_not_found_child(server: TSC.Server) -> None: |
| 560 | + """Returns None when a child component does not exist under the parent.""" |
| 561 | + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 562 | + empty_xml = GET_EMPTY_XML.read_text() |
| 563 | + baseurl = server.projects.baseurl |
| 564 | + |
| 565 | + def respond(request, context): |
| 566 | + params = _filter_params(request) |
| 567 | + if params.get("name") == "Tableau" and "parentProjectId" not in params: |
| 568 | + return top_level_xml |
| 569 | + if params.get("name") == "NoSuchChild": |
| 570 | + return empty_xml |
| 571 | + context.status_code = 404 |
| 572 | + return "" |
| 573 | + |
| 574 | + with requests_mock.mock() as m: |
| 575 | + m.get(baseurl, text=respond) |
| 576 | + project = server.projects.get_by_path("Tableau/NoSuchChild") |
| 577 | + |
| 578 | + assert project is None |
| 579 | + |
| 580 | + |
| 581 | +def test_get_by_path_empty_raises(server: TSC.Server) -> None: |
| 582 | + """An empty or slash-only path raises ValueError.""" |
| 583 | + with pytest.raises(ValueError): |
| 584 | + server.projects.get_by_path("") |
| 585 | + |
| 586 | + with pytest.raises(ValueError): |
| 587 | + server.projects.get_by_path("/") |
| 588 | + |
| 589 | + |
| 590 | +def test_get_by_path_trailing_slash(server: TSC.Server) -> None: |
| 591 | + """Trailing slash is stripped; result is the same as the bare name.""" |
| 592 | + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 593 | + with requests_mock.mock() as m: |
| 594 | + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) |
| 595 | + project = server.projects.get_by_path("Tableau/") |
| 596 | + |
| 597 | + assert project is not None |
| 598 | + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" |
| 599 | + |
| 600 | + |
| 601 | +def test_get_by_path_root_filters_non_top_level(server: TSC.Server) -> None: |
| 602 | + """When the API returns projects with the same name at different levels, |
| 603 | + get_by_path keeps only the one with no parent when looking at the root.""" |
| 604 | + ambiguous_xml = GET_BY_NAME_AMBIGUOUS_ROOT_XML.read_text() |
| 605 | + baseurl = server.projects.baseurl |
| 606 | + |
| 607 | + def respond(request, context): |
| 608 | + params = _filter_params(request) |
| 609 | + if params.get("name") == "Shared" and "parentProjectId" not in params: |
| 610 | + return ambiguous_xml |
| 611 | + context.status_code = 404 |
| 612 | + return "" |
| 613 | + |
| 614 | + with requests_mock.mock() as m: |
| 615 | + m.get(baseurl, text=respond) |
| 616 | + project = server.projects.get_by_path("Shared") |
| 617 | + |
| 618 | + # The implementation filters to parent_id is None at the root level, so |
| 619 | + # only "bbbbbbbb-..." (the actual top-level project) should be returned. |
| 620 | + assert project is not None |
| 621 | + assert project.id == "bbbbbbbb-0000-0000-0000-000000000002" |
| 622 | + assert project.parent_id is None |
| 623 | + |
| 624 | + |
| 625 | +def test_get_by_path_duplicate_siblings_returns_first(server: TSC.Server) -> None: |
| 626 | + """When multiple sibling projects share the same name, the first one is returned.""" |
| 627 | + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 628 | + duplicate_xml = GET_BY_NAME_DUPLICATE_SIBLINGS_XML.read_text() |
| 629 | + baseurl = server.projects.baseurl |
| 630 | + |
| 631 | + def respond(request, context): |
| 632 | + params = _filter_params(request) |
| 633 | + if params.get("name") == "Tableau" and "parentProjectId" not in params: |
| 634 | + return top_level_xml |
| 635 | + if params.get("name") == "Reports" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": |
| 636 | + return duplicate_xml |
| 637 | + context.status_code = 404 |
| 638 | + return "" |
| 639 | + |
| 640 | + with requests_mock.mock() as m: |
| 641 | + m.get(baseurl, text=respond) |
| 642 | + project = server.projects.get_by_path("Tableau/Reports") |
| 643 | + |
| 644 | + # Duplicate siblings: implementation takes the first result from the API. |
| 645 | + assert project is not None |
| 646 | + assert project.id == "cccccccc-0000-0000-0000-000000000001" |
| 647 | + |
| 648 | + |
| 649 | +def test_get_by_path_deep_three_levels(server: TSC.Server) -> None: |
| 650 | + """A three-component path issues three requests and returns the deepest project.""" |
| 651 | + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 652 | + child_xml = GET_BY_NAME_CHILD_XML.read_text() |
| 653 | + grandchild_xml = GET_BY_NAME_GRANDCHILD_XML.read_text() |
| 654 | + baseurl = server.projects.baseurl |
| 655 | + |
| 656 | + def respond(request, context): |
| 657 | + params = _filter_params(request) |
| 658 | + if params.get("name") == "Tableau" and "parentProjectId" not in params: |
| 659 | + return top_level_xml |
| 660 | + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": |
| 661 | + return child_xml |
| 662 | + if params.get("name") == "Q1" and params.get("parentProjectId") == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf": |
| 663 | + return grandchild_xml |
| 664 | + context.status_code = 404 |
| 665 | + return "" |
| 666 | + |
| 667 | + with requests_mock.mock() as m: |
| 668 | + m.get(baseurl, text=respond) |
| 669 | + project = server.projects.get_by_path("Tableau/Child 1/Q1") |
| 670 | + |
| 671 | + assert project is not None |
| 672 | + assert project.id == "eeeeeeee-0000-0000-0000-000000000001" |
| 673 | + assert project.name == "Q1" |
| 674 | + assert project.parent_id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" |
| 675 | + |
| 676 | + |
| 677 | +def test_get_by_path_with_spaces_in_name(server: TSC.Server) -> None: |
| 678 | + """Project names containing spaces are handled correctly.""" |
| 679 | + # Reuses the child fixture whose name is 'Child 1' (contains a space). |
| 680 | + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() |
| 681 | + child_xml = GET_BY_NAME_CHILD_XML.read_text() |
| 682 | + baseurl = server.projects.baseurl |
| 683 | + |
| 684 | + def respond(request, context): |
| 685 | + params = _filter_params(request) |
| 686 | + if params.get("name") == "Tableau" and "parentProjectId" not in params: |
| 687 | + return top_level_xml |
| 688 | + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": |
| 689 | + return child_xml |
| 690 | + context.status_code = 404 |
| 691 | + return "" |
| 692 | + |
| 693 | + with requests_mock.mock() as m: |
| 694 | + m.get(baseurl, text=respond) |
| 695 | + project = server.projects.get_by_path("Tableau/Child 1") |
| 696 | + |
| 697 | + assert project is not None |
| 698 | + assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" |
| 699 | + assert project.name == "Child 1" |
0 commit comments