|
| 1 | +import hashlib |
| 2 | +import json |
1 | 3 | import uuid |
2 | 4 | from datetime import timedelta |
3 | 5 | from unittest.mock import AsyncMock, Mock |
4 | 6 |
|
5 | 7 | import pytest |
6 | 8 | import pytest_asyncio |
| 9 | +from aiohttp.web import HTTPOk, StreamResponse |
7 | 10 | from aiohttp.web_exceptions import HTTPMovedPermanently |
| 11 | +from asgiref.sync import async_to_sync |
8 | 12 | from django.db import IntegrityError |
9 | 13 | from django_guid import clear_guid, set_guid |
10 | 14 |
|
|
17 | 21 | ContentArtifact, |
18 | 22 | Distribution, |
19 | 23 | Publication, |
| 24 | + PublishedArtifact, |
20 | 25 | Remote, |
21 | 26 | RemoteArtifact, |
22 | 27 | Repository, |
@@ -607,3 +612,140 @@ async def test_async_pull_through_add(ca1, monkeypatch, app_status): |
607 | 612 | await repo.adelete() |
608 | 613 | if task: |
609 | 614 | await task.adelete() |
| 615 | + |
| 616 | + |
| 617 | +def test_content_handler_json_default_returns_none(): |
| 618 | + assert Distribution().content_handler_json("any/path") is None |
| 619 | + |
| 620 | + |
| 621 | +@pytest.mark.parametrize( |
| 622 | + "accept,expected", |
| 623 | + [ |
| 624 | + (None, False), |
| 625 | + ("text/html", False), |
| 626 | + ("*/*", False), |
| 627 | + ("application/json", True), |
| 628 | + ("application/vnd.pypi.simple.v1+json", True), |
| 629 | + ], |
| 630 | +) |
| 631 | +def test_negotiate_json(accept, expected): |
| 632 | + request = Mock(headers={} if accept is None else {"Accept": accept}) |
| 633 | + assert Handler.negotiate_json(request) is expected |
| 634 | + |
| 635 | + |
| 636 | +def test_pagination_params_defaults_and_bounds(): |
| 637 | + request = Mock(query={}) |
| 638 | + assert Handler._pagination_params(request) == (Handler.DEFAULT_JSON_LIST_LIMIT, 0) |
| 639 | + |
| 640 | + request = Mock(query={"limit": "nope", "offset": "also-nope"}) |
| 641 | + assert Handler._pagination_params(request) == (Handler.DEFAULT_JSON_LIST_LIMIT, 0) |
| 642 | + |
| 643 | + request = Mock(query={"limit": "0", "offset": "-5"}) |
| 644 | + assert Handler._pagination_params(request) == (1, 0) |
| 645 | + |
| 646 | + request = Mock(query={"limit": "99999", "offset": "10"}) |
| 647 | + assert Handler._pagination_params(request) == (Handler.MAX_JSON_LIST_LIMIT, 10) |
| 648 | + |
| 649 | + |
| 650 | +def test_json_listing_response_envelope_and_next_offset(): |
| 651 | + entries = [{"path": "a.iso", "size": 1, "date": "2026-01-01T00:00:00"}] |
| 652 | + response = Handler._json_listing_response("/pulp/content/foo/", entries, 3, 1, 0) |
| 653 | + assert isinstance(response, HTTPOk) |
| 654 | + assert "application/json" in response.headers["Content-Type"] |
| 655 | + assert response.headers["Vary"] == "Accept" |
| 656 | + body = json.loads(response.text) |
| 657 | + assert body["path"] == "/pulp/content/foo/" |
| 658 | + assert body["packages"] == entries |
| 659 | + assert body["count"] == 3 |
| 660 | + assert body["limit"] == 1 |
| 661 | + assert body["offset"] == 0 |
| 662 | + assert body["next_offset"] == 1 |
| 663 | + |
| 664 | + last_page = Handler._json_listing_response("/pulp/content/foo/", entries, 1, 1, 0) |
| 665 | + assert "next_offset" not in json.loads(last_page.text) |
| 666 | + |
| 667 | + |
| 668 | +def test_json_response_passes_through_stream_response(): |
| 669 | + original = StreamResponse() |
| 670 | + assert Handler._json_response(original) is original |
| 671 | + |
| 672 | + response = Handler._json_response({"hello": "world"}) |
| 673 | + assert "application/json" in response.headers["Content-Type"] |
| 674 | + assert json.loads(response.text) == {"hello": "world"} |
| 675 | + |
| 676 | + |
| 677 | +@pytest.mark.asyncio |
| 678 | +async def test_content_handler_json_hook_is_used_when_accept_prefers_json(): |
| 679 | + """A Distribution.content_handler_json override is returned as the JSON response.""" |
| 680 | + handler = Handler() |
| 681 | + distro = Mock() |
| 682 | + distro.base_path = "foo" |
| 683 | + distro.checkpoint = False |
| 684 | + distro.content_handler.return_value = None |
| 685 | + distro.content_handler_json.return_value = {"packages": ["from-plugin"]} |
| 686 | + distro.content_headers_for.return_value = {} |
| 687 | + handler._match_distribution = Mock(return_value=distro) |
| 688 | + handler._permit = Mock(return_value=False) |
| 689 | + |
| 690 | + request = Mock() |
| 691 | + request.headers = {"Accept": "application/json"} |
| 692 | + request.path = "/pulp/content/foo/" |
| 693 | + request.query = {} |
| 694 | + |
| 695 | + response = await handler._match_and_stream("foo/", request) |
| 696 | + |
| 697 | + distro.content_handler_json.assert_called_once_with("") |
| 698 | + assert "application/json" in response.headers["Content-Type"] |
| 699 | + assert json.loads(response.text) == {"packages": ["from-plugin"]} |
| 700 | + |
| 701 | + |
| 702 | +@pytest.mark.django_db |
| 703 | +def test_list_directory_flat_recursive_leaves_and_pagination(): |
| 704 | + from pulp_file.app.models import FileContent, FilePublication, FileRepository |
| 705 | + |
| 706 | + repo = FileRepository.objects.create(name=str(uuid.uuid4())) |
| 707 | + paths = ["a.iso", "subdir/b.iso"] |
| 708 | + with repo.new_version() as repo_version: |
| 709 | + for path in paths: |
| 710 | + digest = hashlib.sha256(path.encode()).hexdigest() |
| 711 | + content = FileContent.objects.create(relative_path=path, digest=digest) |
| 712 | + repo_version.add_content(Content.objects.filter(pk=content.pk)) |
| 713 | + ContentArtifact.objects.create(content=content, relative_path=path) |
| 714 | + |
| 715 | + publication = FilePublication.objects.create(repository_version=repo_version, complete=True) |
| 716 | + for ca in ContentArtifact.objects.filter(content__in=repo_version.content.all()): |
| 717 | + PublishedArtifact.objects.create( |
| 718 | + publication=publication, content_artifact=ca, relative_path=ca.relative_path |
| 719 | + ) |
| 720 | + |
| 721 | + handler = Handler() |
| 722 | + list_flat = async_to_sync(handler.list_directory_flat) |
| 723 | + entries, total = list_flat(None, publication, "", 1000, 0) |
| 724 | + listed_paths = [entry["path"] for entry in entries] |
| 725 | + assert total == 2 |
| 726 | + assert listed_paths == ["a.iso", "subdir/b.iso"] |
| 727 | + assert "subdir/" not in listed_paths |
| 728 | + assert all("size" in entry and "date" in entry for entry in entries) |
| 729 | + |
| 730 | + nested, nested_total = list_flat(None, publication, "subdir/", 1000, 0) |
| 731 | + assert nested_total == 1 |
| 732 | + assert nested[0]["path"] == "b.iso" |
| 733 | + |
| 734 | + page, page_total = list_flat(None, publication, "", 1, 0) |
| 735 | + assert page_total == 2 |
| 736 | + assert [entry["path"] for entry in page] == ["a.iso"] |
| 737 | + |
| 738 | + empty, empty_total = list_flat(None, publication, "missing/", 1000, 0) |
| 739 | + assert empty_total == 0 |
| 740 | + assert empty == [] |
| 741 | + |
| 742 | + rv_entries, rv_total = list_flat(repo_version, None, "", 1000, 0) |
| 743 | + assert rv_total == 2 |
| 744 | + assert [entry["path"] for entry in rv_entries] == ["a.iso", "subdir/b.iso"] |
| 745 | + |
| 746 | + from django.db import connection |
| 747 | + from django.test.utils import CaptureQueriesContext |
| 748 | + |
| 749 | + with CaptureQueriesContext(connection) as ctx: |
| 750 | + list_flat(None, publication, "", 1, 0) |
| 751 | + assert any("LIMIT" in q["sql"].upper() for q in ctx.captured_queries) |
0 commit comments