diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db5775..7ab9234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,10 @@ jobs: pytest-md pytest-emoji + - name: Install quetz + run: | + micromamba install -c conda-forge 'quetz>=0.6.1' + - name: Install quetz-client run: | pip install -e . diff --git a/environment.yml b/environment.yml index 7c6de1e..f38f529 100644 --- a/environment.yml +++ b/environment.yml @@ -13,11 +13,14 @@ dependencies: - sphinx - sphinxcontrib-apidoc - sphinx_rtd_theme + - tbump - quetz >=0.6.1 - pytest-mock - requests-mock - httpx + # Runtime dependencies - fire - - tbump + - requests + - dacite - pip: - git+https://github.com/jupyter-server/jupyter_releaser.git@v2 diff --git a/setup.cfg b/setup.cfg index c110c7b..25b75e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,6 +18,7 @@ include_package_data = true install_requires = fire requests + dacite python_requires = >=3.8 package_dir= =src diff --git a/src/quetz_client/client.py b/src/quetz_client/client.py index 9263008..0d83592 100644 --- a/src/quetz_client/client.py +++ b/src/quetz_client/client.py @@ -2,9 +2,10 @@ from dataclasses import dataclass from itertools import count from pathlib import Path -from typing import Dict, Iterator, List, Mapping, Optional, Union +from typing import Dict, Iterator, List, Optional, Union import requests +from dacite import from_dict @dataclass(frozen=True) @@ -21,16 +22,22 @@ class Channel: @dataclass(frozen=True) -class ChannelMember: - username: str - role: str +class Profile: + name: str + avatar_url: str @dataclass(frozen=True) class User: id: str username: str - profile: Mapping + profile: Profile + + +@dataclass(frozen=True) +class ChannelMember: + user: User + role: str @dataclass(frozen=True) @@ -91,7 +98,7 @@ def yield_channel_members(self, channel: str) -> Iterator[ChannelMember]: response = self.session.get(url=url) response.raise_for_status() for member_json in response.json(): - yield ChannelMember(**member_json) + yield from_dict(ChannelMember, member_json) def yield_users(self, query: str = "", limit: int = 20) -> Iterator[User]: url = f"{self.url}/api/paginated/users" diff --git a/tests/conftest.py b/tests/conftest.py index 6767684..19a6ccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,39 +1,135 @@ +import os import re import shutil +import socket +import time from contextlib import contextmanager +from dataclasses import asdict from pathlib import Path -from tempfile import NamedTemporaryFile from typing import Iterator import pytest import requests +from dacite import from_dict +from requests_mock import Mocker -from quetz_client.client import QuetzClient +from quetz_client.client import Channel, QuetzClient, User + +# Resources created here: +# Channels, channel memberships, packages +# +# Resources created by quetz on start: +# Users: alice, bob, carol, dave +# API key for one of the users +# See _fill_test_database in cli.py in quetz @contextmanager def temporary_package_file() -> Iterator[Path]: + path = Path.home() / "xtensor-0.16.1-0.tar.bz2" + + if path.exists(): + yield path + return + url = "https://conda.anaconda.org/conda-forge/linux-64/xtensor-0.16.1-0.tar.bz2" xtensor_download = requests.get(url, stream=True) - with NamedTemporaryFile() as file: - with open(file.name, "wb") as fp: - shutil.copyfileobj(xtensor_download.raw, fp) - yield Path(file.name) + with open(path, "wb") as file: + shutil.copyfileobj(xtensor_download.raw, file) + yield path -@pytest.fixture -def test_url(): +@pytest.fixture(scope="module") +def mock_server(): return "https://test.server" -@pytest.fixture -def quetz_client(test_url): - return QuetzClient(url=test_url, session=requests.Session()) +@pytest.fixture(scope="module") +def live_server(): + return "http://localhost:8000" + + +def wait_for_port(port: int, host: str = "localhost", timeout: float = 5.0): + """Wait until a port starts accepting TCP connections. + Args: + port: Port number. + host: Host address on which the port should exist. + timeout: In seconds. How long to wait before raising errors. + Raises: + TimeoutError: The port isn't accepting connection after time specified in `timeout`. + """ + start_time = time.perf_counter() + while True: + try: + with socket.create_connection((host, port), timeout=timeout): + break + except OSError as ex: + time.sleep(0.01) + if time.perf_counter() - start_time >= timeout: + raise TimeoutError( + "Waited too long for the port {} on host {} to start accepting " + "connections.".format(port, host) + ) from ex + + +@pytest.fixture(scope="module") +def start_server(): + """Start the server in a separate thread""" + path_to_quetz = "/home/runner/micromamba-root/envs/quetz-client/bin/quetz" + if not os.path.exists(path_to_quetz): + path_to_quetz = str(Path.home() / "mambaforge/envs/quetz-client/bin/quetz") + + import subprocess + + server_process = subprocess.Popen( + [ + path_to_quetz, + "run", + "quetz_test", + "--copy-conf", + "tests/dev_config.toml", + "--dev", + "--delete", + ] + ) + if server_process.poll() is not None: + raise RuntimeError("Server process failed to start") + wait_for_port(8000) + + yield + + server_process.terminate() + server_process.wait() + + +@pytest.fixture(scope="module") +def mock_client(mock_server): + return QuetzClient(url=mock_server, session=requests.Session()) + + +@pytest.fixture(scope="module") +def authed_session(live_server, start_server): + session = requests.Session() + response = session.get(f"{live_server}/api/dummylogin/alice") + assert response.status_code == 200 + return session + + +@pytest.fixture(scope="module") +def live_client(live_server, authed_session): + # Relay matching requests to the real server + with Mocker(session=authed_session, real_http=True): + yield QuetzClient(url=live_server, session=authed_session) + + +@pytest.fixture(params=[True, False]) +def client(request, live_client, mock_client): + return live_client if request.param else mock_client @pytest.fixture(autouse=True) -def mock_default_paginated_empty(requests_mock, test_url): - url = re.escape(f"{test_url}/api/paginated/") + r".*\?.*skip=20.*" +def mock_default_paginated_empty(requests_mock, mock_server): + url = re.escape(f"{mock_server}/api/paginated/") + r".*\?.*skip=20.*" requests_mock.get( re.compile(url), json={ @@ -43,106 +139,222 @@ def mock_default_paginated_empty(requests_mock, test_url): ) -@pytest.fixture(autouse=True) -def mock_yield_channels_0(requests_mock, test_url): - url = f"{test_url}/api/paginated/channels?skip=0" - mock_resp = { - "pagination": {"skip": 0, "limit": 2, "all_records_count": 3}, - "result": [ - { - "name": "a", - "description": "descr a", - "private": True, - "size_limit": None, - "ttl": 36000, - "mirror_channel_url": None, - "mirror_mode": None, - "members_count": 42, - "packages_count": 11, - }, - { - "name": "b", - "description": "descr b", - "private": True, - "size_limit": None, - "ttl": 36000, - "mirror_channel_url": None, - "mirror_mode": None, - "members_count": 42, - "packages_count": 11, - }, - ], - } - requests_mock.get(url, json=mock_resp) +def live_channels(authed_session, live_server): + response = authed_session.get(f"{live_server}/api/channels") + assert response.status_code == 200 + channels = [from_dict(Channel, c) for c in response.json()] + return channels -@pytest.fixture(autouse=True) -def mock_yield_channels_2(requests_mock, test_url): - url = f"{test_url}/api/paginated/channels?skip=2" - mock_resp = { - "pagination": {"skip": 2, "limit": 2, "all_records_count": 3}, + +def get_channel_json(channels, skip, limit): + return { + "pagination": { + "skip": skip, + "limit": limit, + "all_records_count": len(channels), + }, "result": [ - { - "name": "c", - "description": "descr c", - "private": False, - "size_limit": None, - "ttl": 36000, - "mirror_channel_url": None, - "mirror_mode": None, - "members_count": 42, - "packages_count": 11, - } + asdict(c) for c in channels[skip : min(skip + limit, len(channels))] ], } - requests_mock.get(url, json=mock_resp) -@pytest.fixture(autouse=True) -def mock_yield_channels_4(requests_mock, test_url): - url = f"{test_url}/api/paginated/channels?skip=4" - requests_mock.get( - url, +@pytest.fixture +def three_channels( + requests_mock, mock_server, live_post_3_channels, authed_session, live_server +): + # We don't use live_channels as a fixture here because + # we want to make sure that the channels are created first + channels = live_channels(authed_session, live_server) + # We only want the channels starting with c- + prefixed_channels = [c for c in channels if c.name.startswith("c-")] + + url = f"{mock_server}/api/paginated/channels?skip=0&q=c-" + requests_mock.get(url, json=get_channel_json(prefixed_channels, 0, 2)) + + url = f"{mock_server}/api/paginated/channels?skip=2&q=c-" + requests_mock.get(url, json=get_channel_json(prefixed_channels, 2, 2)) + + url = f"{mock_server}/api/paginated/channels?skip=4&q=c-" + requests_mock.get(url, json=get_channel_json([], 4, 2)) + + return get_channel_json(prefixed_channels, 0, len(prefixed_channels))["result"] + + +@pytest.fixture +def live_post_channel_a(authed_session, live_server): + post_channel(authed_session, live_server, "a", "descr a") + yield + delete_channel(authed_session, live_server, "a") + + +@pytest.fixture +def live_post_3_channels(authed_session, live_server): + post_channel(authed_session, live_server, "c-1", "descr c1") + post_channel(authed_session, live_server, "c-2", "descr c2") + post_channel(authed_session, live_server, "c-3", "descr c3") + yield + delete_channel(authed_session, live_server, "c-1") + delete_channel(authed_session, live_server, "c-2") + delete_channel(authed_session, live_server, "c-3") + + +def post_channel(authed_session, live_server, name, description): + response = authed_session.post( + f"{live_server}/api/channels", json={ - "pagination": {"skip": 4, "limit": 2, "all_records_count": 3}, - "result": [], + "name": name, + "description": description, + "private": True, + "size_limit": None, + "ttl": 36000, + "mirror_channel_url": None, + "mirror_mode": None, }, ) + assert response.status_code == 201 + return response + + +def delete_channel(authed_session, live_server, name): + response = authed_session.delete(f"{live_server}/api/channels/{name}") + assert response.status_code == 200 + + +@pytest.fixture(autouse=True, scope="module") +def live_users(authed_session, live_server): + # Get the live users alice, bob, carol, and dave + # These are created by passing the --dev flag to the live quetz + response = authed_session.get(f"{live_server}/api/users") + assert response.status_code == 200 + users = response.json() + assert len(users) == 4 + assert users[0]["username"] == "alice" + assert users[1]["username"] == "bob" + assert users[2]["username"] == "carol" + assert users[3]["username"] == "dave" + + # Turn json into users + return [from_dict(User, u) for u in users] + + +@pytest.fixture(scope="module") +def live_alice(live_users): + return get_user_with_username(live_users, "alice") @pytest.fixture -def expected_channel_members(): - return [{"username": "u1", "role": "owner"}, {"username": "u2", "role": "member"}] +def live_alice_role(authed_session, live_server): + response = authed_session.get(f"{live_server}/api/users/alice/role") + assert response.status_code == 200 + return response.json()["role"] + + +@pytest.fixture(scope="module") +def live_bob(live_users): + return get_user_with_username(live_users, "bob") + + +@pytest.fixture(scope="module") +def live_carol(live_users): + return get_user_with_username(live_users, "carol") + + +@pytest.fixture(scope="module") +def live_dave(live_users): + return get_user_with_username(live_users, "dave") + + +def get_user_with_username(users, username): + return {u.username: u for u in users}[username] + + +@pytest.fixture() +def live_post_channel_a_members(authed_session, live_server, live_post_channel_a): + # Add alice & bob to channel a + response = authed_session.post( + f"{live_server}/api/channels/a/members", + json={ + "username": "alice", + "role": "owner", + }, + ) + assert response.status_code == 201 + response = authed_session.post( + f"{live_server}/api/channels/a/members", + json={ + "username": "bob", + "role": "owner", + }, + ) + assert response.status_code == 201 + + # Channel will be deleted afterwards, so we don't need to remove the members + + +@pytest.fixture(scope="module") +def expected_channel_a_members(live_alice, live_bob): + return [ + { + "role": "owner", + "user": { + "id": live_alice.id, + "username": "alice", + "profile": {"name": "Alice", "avatar_url": "/avatar.jpg"}, + }, + }, + { + "role": "owner", + "user": { + "id": live_bob.id, + "username": "bob", + "profile": {"name": "Bob", "avatar_url": "/avatar.jpg"}, + }, + }, + ] @pytest.fixture(autouse=True) -def mock_yield_channel_members(requests_mock, test_url, expected_channel_members): - url = f"{test_url}/api/channels/a/members" - requests_mock.get(url, json=expected_channel_members) +def mock_yield_channel_a_members( + requests_mock, mock_server, expected_channel_a_members +): + url = f"{mock_server}/api/channels/a/members" + requests_mock.get(url, json=expected_channel_a_members) @pytest.fixture -def expected_users(): +def expected_users(live_alice, live_bob, live_carol, live_dave): return { "pagination": {"skip": 0, "limit": 20, "all_records_count": 2}, "result": [ { - "id": "015744e4-af4f-4bc4-a1a6-0c8ae8d14ddc", + "id": live_alice.id, "username": "alice", "profile": {"name": "Alice", "avatar_url": "/avatar.jpg"}, }, { - "id": "0a518bff-2e77-4ce9-b36e-ace9d50b1496", + "id": live_bob.id, "username": "bob", "profile": {"name": "Bob", "avatar_url": "/avatar.jpg"}, }, + { + "id": live_carol.id, + "username": "carol", + "profile": {"name": "Carol", "avatar_url": "/avatar.jpg"}, + }, + { + "id": live_dave.id, + "username": "dave", + "profile": {"name": "Dave", "avatar_url": "/avatar.jpg"}, + }, ], } @pytest.fixture(autouse=True) -def mock_yield_users(requests_mock, test_url, expected_users): - url = f"{test_url}/api/paginated/users?skip=0" +def mock_yield_users(requests_mock, mock_server, expected_users): + url = f"{mock_server}/api/paginated/users?skip=0" requests_mock.get(url, json=expected_users) @@ -174,6 +386,6 @@ def expected_packages(): @pytest.fixture(autouse=True) -def mock_yield_packages(requests_mock, test_url, expected_packages): - url = f"{test_url}/api/paginated/channels/channel1/packages?skip=0" +def mock_yield_packages(requests_mock, mock_server, expected_packages): + url = f"{mock_server}/api/paginated/channels/channel1/packages?skip=0" requests_mock.get(url, json=expected_packages) diff --git a/tests/test_both.py b/tests/test_both.py new file mode 100644 index 0000000..5d39c44 --- /dev/null +++ b/tests/test_both.py @@ -0,0 +1,33 @@ +from dacite import from_dict + +from quetz_client.client import Channel, ChannelMember, QuetzClient + + +def test_from_token(): + token = "abc" + quetz_client = QuetzClient.from_token("", token) + assert quetz_client.session.headers.get("X-API-Key") == token + + +def test_yield_channels(client: QuetzClient, three_channels): + channels = list(client.yield_channels(limit=2, query="c-")) + assert len(channels) == 3 + assert isinstance(channels[0], Channel) + assert {from_dict(Channel, c) for c in three_channels} == set(channels) + + +def test_yield_channel_members( + client: QuetzClient, expected_channel_a_members, live_post_channel_a_members +): + channel = "a" + channel_members = set(client.yield_channel_members(channel=channel)) + assert { + from_dict(ChannelMember, ecm) for ecm in expected_channel_a_members + } == channel_members + + +def test_yield_users(client: QuetzClient, expected_users): + users = list(client.yield_users()) + user_set = {(user.id, user.username) for user in users} + expected_set = {(user["id"], user["username"]) for user in expected_users["result"]} + assert user_set == expected_set diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index fa100a9..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,210 +0,0 @@ -import os -import re -import subprocess - -import pytest - -from quetz_client.client import Channel, ChannelMember, QuetzClient - -from .conftest import temporary_package_file - - -def test_readme_script(): - dir_path = os.path.dirname(os.path.realpath(__file__)) - result = subprocess.call(["bash", f"{dir_path}/test-readme.sh"]) - assert result == 0 - - -def test_yield_channels(quetz_client): - expected_channel_names = ("a", "b", "c") - channels = list(quetz_client.yield_channels(limit=2)) - assert len(channels) == 3 - assert isinstance(channels[0], Channel) - assert {channel.name for channel in channels} == set(expected_channel_names) - - -def test_yield_channel_members(quetz_client: QuetzClient, expected_channel_members): - channel = "a" - channel_members = set(quetz_client.yield_channel_members(channel=channel)) - assert {ChannelMember(**ecm) for ecm in expected_channel_members} == channel_members - - -def test_yield_users(quetz_client: QuetzClient, expected_users): - users = list(quetz_client.yield_users()) - user_set = {(user.id, user.username) for user in users} - expected_set = {(user["id"], user["username"]) for user in expected_users["result"]} - assert user_set == expected_set - - -@pytest.mark.parametrize( - "role", - [ - None, - "member", - "maintainer", - "owner", - ], -) -def test_get_role( - quetz_client: QuetzClient, - role, - requests_mock, - test_url: str, -): - username = "user" - url = f"{test_url}/api/users/{username}/role" - requests_mock.get(url, json={"role": role}) - actual_role = quetz_client.get_role(username) - assert next(actual_role).role == role - - -@pytest.mark.parametrize( - "role", - [ - None, - "member", - "maintainer", - "owner", - ], -) -def test_set_channel_member( - quetz_client: QuetzClient, - role, - requests_mock, - test_url: str, -): - channel = "a" - username = "user" - - url = f"{test_url}/api/channels/{channel}/members" - requests_mock.post(url, json=None) - - quetz_client.set_channel_member(username, role, channel) - - last_request = requests_mock.request_history[0] - assert last_request.method == "POST" - assert last_request.json()["username"] == username - assert last_request.json()["role"] == role - - -def test_delete_channel_member( - quetz_client: QuetzClient, - requests_mock, - test_url: str, -): - channel = "a" - username = "a" - - url = f"{test_url}/api/channels/{channel}/members" - requests_mock.delete(url, json=None) - - quetz_client.delete_channel_member(username, channel) - - last_request = requests_mock.request_history[0] - assert last_request.method == "DELETE" - assert last_request.qs["username"] == [username] - assert len(last_request.qs) == 1 - - -@pytest.mark.parametrize( - "role", - [ - None, - "member", - "maintainer", - "owner", - ], -) -def test_set_role( - quetz_client: QuetzClient, - role, - requests_mock, - test_url: str, -): - username = "user" - - url = f"{test_url}/api/users/{username}/role" - requests_mock.put(url, json=None) - - quetz_client.set_role(username, role) - - last_request = requests_mock.request_history[0] - assert last_request.method == "PUT" - assert last_request.json()["role"] == role - assert len(last_request.json()) == 1 - - -def test_from_token(): - token = "abc" - quetz_client = QuetzClient.from_token("", token) - assert quetz_client.session.headers.get("X-API-Key") == token - - -def test_set_channel( - quetz_client: QuetzClient, - requests_mock, - test_url: str, -): - channel = "a" - - url = f"{test_url}/api/channels" - requests_mock.post(url, json=None) - - quetz_client.set_channel(channel) - - last_request = requests_mock.request_history[0] - assert last_request.method == "POST" - assert last_request.json()["name"] == channel - - -def test_delete_channel( - quetz_client: QuetzClient, - requests_mock, - test_url: str, -): - channel = "a" - - url = f"{test_url}/api/channels/{channel}" - requests_mock.delete(url, json=None) - - quetz_client.delete_channel(channel) - - last_request = requests_mock.request_history[0] - assert last_request.method == "DELETE" - - -def test_yield_packages(quetz_client: QuetzClient, expected_packages): - channel = "channel1" - package_set = { - (p.name, p.url, p.current_version) for p in quetz_client.yield_packages(channel) - } - assert { - (ep["name"], ep["url"], ep["current_version"]) - for ep in expected_packages["result"] - } == package_set - - -def test_post_file_to_channel( - quetz_client: QuetzClient, - requests_mock, - test_url: str, -): - channel = "a" - - url_matcher = re.compile( - f"{test_url}/api/channels/{channel}/upload/\\w*\\?force=False&sha256=\\w*" - ) - requests_mock.register_uri("POST", url_matcher, json=None) - - requests_mock.register_uri( - "GET", - "https://conda.anaconda.org/conda-forge/linux-64/xtensor-0.16.1-0.tar.bz2", - real_http=True, - ) - - with temporary_package_file() as file: - quetz_client.post_file_to_channel(channel, file) - - # the last request here is the download of the test package file, thus we need to access the second-to-last request - last_request = requests_mock.request_history[1] - assert last_request.method == "POST" diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..03190de --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,82 @@ +import pytest + +from quetz_client.client import QuetzClient + +from .conftest import temporary_package_file + + +@pytest.mark.parametrize( + "role", + [ + "member", + "maintainer", + "owner", + ], +) +def test_live_set_channel_member( + live_client: QuetzClient, + live_post_channel_a, + role, +): + live_client.set_channel_member("alice", role, "a") + + members = live_client.yield_channel_members("a") + + assert any(m.user.username == "alice" and m.role == role for m in members) + + +def test_live_delete_channel_member( + authed_session, + live_client: QuetzClient, + live_post_channel_a_members, +): + # Check that alice is a member of channel a + channel = "a" + username = "alice" + + response = authed_session.get( + f"{live_client.url}/api/channels/{channel}/members", + ) + assert {u["user"]["username"] for u in response.json()} == {"alice", "bob"} + + live_client.delete_channel_member(username, channel) + + # Check that alice is no longer a member of channel a + response = authed_session.get( + f"{live_client.url}/api/channels/{channel}/members", + ) + assert {u["user"]["username"] for u in response.json()} == {"bob"} + + +def test_live_get_role( + live_client: QuetzClient, + live_alice_role, +): + actual_alice_role = live_client.get_role("alice") + assert next(actual_alice_role).role == live_alice_role + + +def test_live_post_file_to_channel( + live_client: QuetzClient, + live_post_channel_a, + requests_mock, +): + # For some reason, we still need to explicitly tell requests_mock to + # use the real http connection for this url. + # I thought this would be avoided by using real_http=True in + # live_client in conftest.py, but it's not. + requests_mock.register_uri( + "GET", + "https://conda.anaconda.org/conda-forge/linux-64/xtensor-0.16.1-0.tar.bz2", + real_http=True, + ) + + packages = live_client.yield_packages("a") + assert len(list(packages)) == 0 + + with temporary_package_file() as file: + live_client.post_file_to_channel("a", file) + + packages = live_client.yield_packages("a") + + assert any(p.name == "xtensor" for p in packages) diff --git a/tests/test_mock.py b/tests/test_mock.py new file mode 100644 index 0000000..a0a3321 --- /dev/null +++ b/tests/test_mock.py @@ -0,0 +1,172 @@ +import pytest + +from quetz_client.client import QuetzClient + +from .conftest import temporary_package_file + + +@pytest.mark.parametrize( + "role", + [ + None, + "member", + "maintainer", + "owner", + ], +) +def test_get_role( + mock_client: QuetzClient, + role, + requests_mock, + mock_server: str, +): + username = "user" + url = f"{mock_server}/api/users/{username}/role" + requests_mock.get(url, json={"role": role}) + actual_role = mock_client.get_role(username) + assert next(actual_role).role == role + + +@pytest.mark.parametrize( + "role", + [ + None, + "member", + "maintainer", + "owner", + ], +) +def test_mock_set_channel_member( + mock_client: QuetzClient, + role, + requests_mock, + mock_server: str, +): + channel = "a" + username = "user" + + url = f"{mock_server}/api/channels/{channel}/members" + requests_mock.post(url, json=None) + + mock_client.set_channel_member(username, role, channel) + + last_request = requests_mock.request_history[0] + assert last_request.method == "POST" + assert last_request.json()["username"] == username + assert last_request.json()["role"] == role + + +def test_mock_delete_channel_member( + mock_client: QuetzClient, + requests_mock, + mock_server: str, +): + channel = "a" + username = "a" + + url = f"{mock_server}/api/channels/{channel}/members" + requests_mock.delete(url, json=None) + + mock_client.delete_channel_member(username, channel) + + last_request = requests_mock.request_history[0] + assert last_request.method == "DELETE" + assert last_request.qs["username"] == [username] + assert len(last_request.qs) == 1 + + +@pytest.mark.parametrize( + "role", + [ + None, + "member", + "maintainer", + "owner", + ], +) +def test_mock_set_role( + mock_client: QuetzClient, + role, + requests_mock, + mock_server: str, +): + username = "user" + + url = f"{mock_server}/api/users/{username}/role" + requests_mock.put(url, json=None) + + mock_client.set_role(username, role) + + last_request = requests_mock.request_history[0] + assert last_request.method == "PUT" + assert last_request.json()["role"] == role + assert len(last_request.json()) == 1 + + +def test_mock_set_channel( + mock_client: QuetzClient, + requests_mock, + mock_server: str, +): + channel = "a" + + url = f"{mock_server}/api/channels" + requests_mock.post(url, json=None) + + mock_client.set_channel(channel) + + last_request = requests_mock.request_history[0] + assert last_request.method == "POST" + assert last_request.json()["name"] == channel + + +def test_mock_delete_channel( + mock_client: QuetzClient, + requests_mock, + mock_server: str, +): + channel = "a" + + url = f"{mock_server}/api/channels/{channel}" + requests_mock.delete(url, json=None) + + mock_client.delete_channel(channel) + + last_request = requests_mock.request_history[0] + assert last_request.method == "DELETE" + + +def test_mock_yield_packages(mock_client: QuetzClient, expected_packages): + channel = "channel1" + package_set = { + (p.name, p.url, p.current_version) for p in mock_client.yield_packages(channel) + } + assert { + (ep["name"], ep["url"], ep["current_version"]) + for ep in expected_packages["result"] + } == package_set + + +def test_mock_post_file_to_channel( + mock_client: QuetzClient, + requests_mock, + mock_server: str, +): + channel = "a" + + url = f"{mock_server}/api/channels/{channel}/upload/xtensor-0.16.1-0.tar.bz2" + requests_mock.post(url, json=None) + + requests_mock.register_uri( + "GET", + "https://conda.anaconda.org/conda-forge/linux-64/xtensor-0.16.1-0.tar.bz2", + real_http=True, + ) + + with temporary_package_file() as file: + mock_client.post_file_to_channel(channel, file) + + # the last request here might be the download of the test package file + # thus we need to access all the requests + assert len(requests_mock.request_history) <= 2 + assert any(r.method == "POST" for r in requests_mock.request_history)