from base64 import b64encode
from dataclasses import dataclass
import getpass
from pathlib import Path
import tempfile
from typing import Dict, Iterator, List, Optional, Tuple, Union

from tqdm import tqdm

from .session import RgdcSession
from .types import DATETIME_OR_STR_TUPLE, SEARCH_PREDICATE_CHOICE
from .utils import (
    DEFAULT_RGD_API,
    download_checksum_file_to_path,
    limit_offset_pager,
    spatial_search_params,
    spatial_subentry_id,
)


@dataclass
class RasterDownload:
    path: Path
    images: List[Path]
    ancillary: List[Path]


class Rgdc:
    def __init__(
        self,
        api_url: str = DEFAULT_RGD_API,
        username: Optional[str] = None,
        password: Optional[str] = None,
    ):
        """
        Initialize a RGD Client.

        Args:
            api_url: The base url of the RGD API instance.
            username: The username to authenticate to the instance with, if any.
            password: The password associated with the provided username. If None, a prompt will be provided.

        Returns:
            A new Rgdc instance.
        """
        auth_header = None

        # Prompt for password if not provided
        if username is not None and password is None:
            password = getpass.getpass()

        if username and password:
            encoded_credentials = b64encode(f'{username}:{password}'.encode('utf-8')).decode()
            auth_header = f'Basic {encoded_credentials}'

        self.session = RgdcSession(base_url=api_url, auth_header=auth_header)

    def list_image_tiles(self, image_id: Union[str, int]) -> Dict:
        """List geodata imagery tiles."""
        r = self.session.get(f'image_process/imagery/{image_id}/tiles')
        return r.json()

    def download_image_file(
        self, image_id: Union[str, int], chunk_size: int = 1024 * 1024
    ) -> Iterator[bytes]:
        """
        Download the associated ImageFile data for this ImageEntry directly from S3.

        Args:
            image_id: The ID of the ImageEntry to download.
            chunk_size: The size (in bytes) of each item in the returned iterator (defaults to 1MB).

        Returns:
            An iterator of byte chunks.
        """
        r = self.session.get(f'rgd_imagery/{image_id}/data', stream=True)
        return r.iter_content(chunk_size=chunk_size)

    def download_image_thumbnail(
        self,
        image_id: Union[str, int],
    ) -> bytes:
        """
        Download the generated thumbnail for this ImageEntry.

        Args:
            image_id: The ID of the ImageEntry to download.

        Returns:
            Thumbnail bytes.
        """
        r = self.session.get(f'image_process/imagery/{image_id}/thumbnail')
        return r.content

    def download_raster_thumbnail(
        self,
        raster_meta_id: Union[str, int, dict],
        band: int = 0,
    ) -> bytes:
        """
        Download the generated thumbnail for this ImageEntry.

        Args:
            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
            band: The index of the image in the raster's image set to produce thumbnail from.

        Returns:
            Thumbnail bytes.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        parent_raster = r.json().get('parent_raster', {})
        images = parent_raster.get('image_set', {}).get('images', [])
        try:
            return self.download_image_thumbnail(images[band]['id'])
        except IndexError:
            raise IndexError(f'Band index ({band}) out of range.')

    def get_raster(self, raster_meta_id: Union[str, int, dict], stac: bool = False) -> Dict:
        """Get raster entry detail.

        Args:
            stac: Optionally return as STAC Item dictionary/JSON.

        Returns:
            Serialized object representation.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        if stac:
            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}/stac')
        else:
            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        return r.json()

    def download_raster(
        self,
        raster_meta_id: Union[str, int, dict],
        pathname: Optional[str] = None,
        nest_with_name: bool = False,
        keep_existing: bool = True,
    ) -> RasterDownload:
        """
        Download the image set associated with a raster entry to disk.

        Args:
            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
            pathname: The directory to download the image set to. If not supplied, a temporary directory will be used.
            nest_with_name: If True, nests the download within an additional directory, using the raster entry name.
            keep_existing: If False, replace files existing on disk. Only valid if `pathname` is given.

        Returns:
            A dictionary of the paths to all files downloaded under the directory.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        parent_raster = r.json().get('parent_raster', {})

        # Create dirs after request to avoid empty dirs if failed
        if pathname is None:
            pathname = tempfile.mkdtemp()

        # Handle optional nesting with raster entry name
        path = Path(pathname)
        parent_raster_name: Optional[str] = parent_raster.get('name')

        if nest_with_name and parent_raster_name:
            path = path / parent_raster_name

        # Ensure base download directory exists
        if not path.exists():
            path.mkdir()

        # Initialize dataclass
        raster_download = RasterDownload(path, [], [])

        # Download images
        images = parent_raster.get('image_set', {}).get('images', [])
        for image in tqdm(images, desc='Downloading image files'):
            file = image.get('file', {})
            file_path = download_checksum_file_to_path(file, path, keep_existing=keep_existing)
            if file_path:
                raster_download.images.append(file_path)

        # Download ancillary files
        ancillary = parent_raster.get('ancillary_files', [])
        for file in tqdm(ancillary, desc='Downloading ancillary files'):
            file_path = download_checksum_file_to_path(file, path, keep_existing=keep_existing)
            if file_path:
                raster_download.ancillary.append(file_path)

        return raster_download

    def search(
        self,
        query: Optional[Union[Dict, str]] = None,
        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
        relates: Optional[str] = None,
        distance: Optional[Tuple[float, float]] = None,
        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
        instrumentation: Optional[str] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
    ) -> List[Dict]:
        """
        Search for geospatial entries based on various criteria.

        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.

        Args:
            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
                be used to filter data such that predicate(a, b) where b is the queried geometry.
            relates: Specify exactly how the queried geometry should relate to the data using a
                DE-9IM string code.
            distance: The min/max distance around the queried geometry in meters.
            acquired: The min/max date and time (ISO 8601) when data was acquired.
            instrumentation: The instrumentation used to acquire at least one of these data.
            limit: The maximum number of results to return.
            offset: The number of results to skip.

        Returns:
            A list of Spatial Entries.
        """
        params = spatial_search_params(
            query=query,
            predicate=predicate,
            relates=relates,
            distance=distance,
            acquired=acquired,
            instrumentation=instrumentation,
            limit=limit,
            offset=offset,
        )
        return list(limit_offset_pager(self.session, 'rgd/search', params=params))

    def create_raster_stac(self, raster: Dict) -> Dict:
        """Create a raster entry using STAC format."""
        r = self.session.post('rgd_imagery/raster/stac', json=raster)
        r.raise_for_status()

        return r.json()

    def search_raster_stac(
        self,
        query: Optional[Union[Dict, str]] = None,
        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
        relates: Optional[str] = None,
        distance: Optional[Tuple[float, float]] = None,
        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
        instrumentation: Optional[str] = None,
        num_bands: Optional[Tuple[int, int]] = None,
        resolution: Optional[Tuple[int, int]] = None,
        cloud_cover: Optional[Tuple[float, float]] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
    ) -> List[Dict]:
        """
        Search for raster entries based on various criteria.

        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.

        Args:
            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
                be used to filter data such that predicate(a, b) where b is the queried geometry.
            relates: Specify exactly how the queried geometry should relate to the data using a
                DE-9IM string code.
            distance: The min/max distance around the queried geometry in meters.
            acquired: The min/max date and time (ISO 8601) when data was acquired.
            instrumentation: The instrumentation used to acquire at least one of these data.
            num_bands: The min/max number of bands in the raster.
            resolution: The min/max resolution of the raster.
            cloud_cover: The min/max cloud coverage of the raster.
            limit: The maximum number of results to return.
            offset: The number of results to skip.

        Returns:
            A list of Spatial Entries in STAC Item format.
        """
        params = spatial_search_params(
            query=query,
            predicate=predicate,
            relates=relates,
            distance=distance,
            acquired=acquired,
            instrumentation=instrumentation,
            limit=limit,
            offset=offset,
        )

        if num_bands and len(num_bands) == 2:
            nbmin, nbmax = num_bands
            params['num_bands_min'] = nbmin
            params['num_bands_max'] = nbmax

        if resolution and len(resolution) == 2:
            rmin, rmax = resolution
            params['resolution_min'] = rmin
            params['resolution_max'] = rmax

        if cloud_cover and len(cloud_cover) == 2:
            ccmin, ccmax = cloud_cover
            params['cloud_cover_min'] = ccmin
            params['cloud_cover_max'] = ccmax

        return list(limit_offset_pager(self.session, 'rgd_imagery/raster/search', params=params))

rgd-client/rgd_client/rgdc.py
from base64 import b64encode
from dataclasses import dataclass
import getpass
from pathlib import Path
import tempfile
from typing import Dict, Iterator, List, Optional, Tuple, Union

from tqdm import tqdm

from .session import RgdcSession
from .types import DATETIME_OR_STR_TUPLE, SEARCH_PREDICATE_CHOICE
from .utils import (
    DEFAULT_RGD_API,
    download_checksum_file_to_path,
    limit_offset_pager,
    spatial_search_params,
    spatial_subentry_id,
)


@dataclass
class RasterDownload:
    path: Path
    images: List[Path]
    ancillary: List[Path]


class Rgdc:
    def __init__(
        self,
        api_url: str = DEFAULT_RGD_API,
        username: Optional[str] = None,
        password: Optional[str] = None,
    ):
        """
        Initialize a RGD Client.

        Args:
            api_url: The base url of the RGD API instance.
            username: The username to authenticate to the instance with, if any.
            password: The password associated with the provided username. If None, a prompt will be provided.

        Returns:
            A new Rgdc instance.
        """
        auth_header = None

        # Prompt for password if not provided
        if username is not None and password is None:
            password = getpass.getpass()

        if username and password:
            encoded_credentials = b64encode(f'{username}:{password}'.encode('utf-8')).decode()
            auth_header = f'Basic {encoded_credentials}'

        self.session = RgdcSession(base_url=api_url, auth_header=auth_header)

    def list_image_tiles(self, image_id: Union[str, int]) -> Dict:
        """List geodata imagery tiles."""
        r = self.session.get(f'image_process/imagery/{image_id}/tiles')
        return r.json()

    def download_image_file(
        self, image_id: Union[str, int], chunk_size: int = 1024 * 1024
    ) -> Iterator[bytes]:
        """
        Download the associated ImageFile data for this ImageEntry directly from S3.

        Args:
            image_id: The ID of the ImageEntry to download.
            chunk_size: The size (in bytes) of each item in the returned iterator (defaults to 1MB).

        Returns:
            An iterator of byte chunks.
        """
        r = self.session.get(f'rgd_imagery/{image_id}/data', stream=True)
        return r.iter_content(chunk_size=chunk_size)

    def download_image_thumbnail(
        self,
        image_id: Union[str, int],
    ) -> bytes:
        """
        Download the generated thumbnail for this ImageEntry.

        Args:
            image_id: The ID of the ImageEntry to download.

        Returns:
            Thumbnail bytes.
        """
        r = self.session.get(f'image_process/imagery/{image_id}/thumbnail')
        return r.content

    def download_raster_thumbnail(
        self,
        raster_meta_id: Union[str, int, dict],
        band: int = 0,
    ) -> bytes:
        """
        Download the generated thumbnail for this ImageEntry.

        Args:
            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
            band: The index of the image in the raster's image set to produce thumbnail from.

        Returns:
            Thumbnail bytes.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        parent_raster = r.json().get('parent_raster', {})
        images = parent_raster.get('image_set', {}).get('images', [])
        try:
            return self.download_image_thumbnail(images[band]['id'])
        except IndexError:
            raise IndexError(f'Band index ({band}) out of range.')

    def get_raster(self, raster_meta_id: Union[str, int, dict], stac: bool = False) -> Dict:
        """Get raster entry detail.

        Args:
            stac: Optionally return as STAC Item dictionary/JSON.

        Returns:
            Serialized object representation.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        if stac:
            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}/stac')
        else:
            r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        return r.json()

    def download_raster(
        self,
        raster_meta_id: Union[str, int, dict],
        pathname: Optional[str] = None,
        nest_with_name: bool = False,
        keep_existing: bool = True,
    ) -> RasterDownload:
        """
        Download the image set associated with a raster entry to disk.

        Args:
            raster_meta_id: The id of the RasterMeta, which is a child to the desired raster entry, or search result.
            pathname: The directory to download the image set to. If not supplied, a temporary directory will be used.
            nest_with_name: If True, nests the download within an additional directory, using the raster entry name.
            keep_existing: If False, replace files existing on disk. Only valid if `pathname` is given.

        Returns:
            A dictionary of the paths to all files downloaded under the directory.
        """
        if isinstance(raster_meta_id, dict):
            raster_meta_id = spatial_subentry_id(raster_meta_id)

        r = self.session.get(f'rgd_imagery/raster/{raster_meta_id}')
        parent_raster = r.json().get('parent_raster', {})

        # Create dirs after request to avoid empty dirs if failed
        if pathname is None:
            pathname = tempfile.mkdtemp()

        # Handle optional nesting with raster entry name
        path = Path(pathname)
        parent_raster_name: Optional[str] = parent_raster.get('name')

        if nest_with_name and parent_raster_name:
            path = path / parent_raster_name

        # Ensure base download directory exists
        if not path.exists():
            path.mkdir()

        # Initialize dataclass
        raster_download = RasterDownload(path, [], [])

        # Download images
        images = parent_raster.get('image_set', {}).get('images', [])
        for image in tqdm(images, desc='Downloading image files'):
            file = image.get('file', {})
            file_path = download_checksum_file_to_path(file, path, keep_existing=keep_existing)
            if file_path:
                raster_download.images.append(file_path)

        # Download ancillary files
        ancillary = parent_raster.get('ancillary_files', [])
        for file in tqdm(ancillary, desc='Downloading ancillary files'):
            file_path = download_checksum_file_to_path(file, path, keep_existing=keep_existing)
            if file_path:
                raster_download.ancillary.append(file_path)

        return raster_download

    def search(
        self,
        query: Optional[Union[Dict, str]] = None,
        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
        relates: Optional[str] = None,
        distance: Optional[Tuple[float, float]] = None,
        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
        instrumentation: Optional[str] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
    ) -> List[Dict]:
        """
        Search for geospatial entries based on various criteria.

        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.

        Args:
            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
                be used to filter data such that predicate(a, b) where b is the queried geometry.
            relates: Specify exactly how the queried geometry should relate to the data using a
                DE-9IM string code.
            distance: The min/max distance around the queried geometry in meters.
            acquired: The min/max date and time (ISO 8601) when data was acquired.
            instrumentation: The instrumentation used to acquire at least one of these data.
            limit: The maximum number of results to return.
            offset: The number of results to skip.

        Returns:
            A list of Spatial Entries.
        """
        params = spatial_search_params(
            query=query,
            predicate=predicate,
            relates=relates,
            distance=distance,
            acquired=acquired,
            instrumentation=instrumentation,
            limit=limit,
            offset=offset,
        )

        r = self.session.get('rgd/search', params=params)
        r.raise_for_status()

        return r.json()

    def create_raster_stac(self, raster: Dict) -> Dict:
        """Create a raster entry using STAC format."""
        r = self.session.post('rgd_imagery/raster/stac', json=raster)
        r.raise_for_status()

        return r.json()

    def search_raster_stac(
        self,
        query: Optional[Union[Dict, str]] = None,
        predicate: Optional[SEARCH_PREDICATE_CHOICE] = None,
        relates: Optional[str] = None,
        distance: Optional[Tuple[float, float]] = None,
        acquired: Optional[DATETIME_OR_STR_TUPLE] = None,
        instrumentation: Optional[str] = None,
        num_bands: Optional[Tuple[int, int]] = None,
        resolution: Optional[Tuple[int, int]] = None,
        cloud_cover: Optional[Tuple[float, float]] = None,
        limit: Optional[int] = None,
        offset: Optional[int] = None,
    ) -> List[Dict]:
        """
        Search for raster entries based on various criteria.

        For Ranges (Tuples), an entry of `None` means that side of the range is unbounded.
        E.g. a range of (2, None) is 2 or more, (None, 5) is at most 5, (2, 5) is between 2 and 5.

        Args:
            query: Either a WKT GeoJSON representation, a GeoJSON string, or a GeoJSON dict.
            predicate: A named spatial predicate based on the DE-9IM. This spatial predicate will
                be used to filter data such that predicate(a, b) where b is the queried geometry.
            relates: Specify exactly how the queried geometry should relate to the data using a
                DE-9IM string code.
            distance: The min/max distance around the queried geometry in meters.
            acquired: The min/max date and time (ISO 8601) when data was acquired.
            instrumentation: The instrumentation used to acquire at least one of these data.
            num_bands: The min/max number of bands in the raster.
            resolution: The min/max resolution of the raster.
            cloud_cover: The min/max cloud coverage of the raster.
            limit: The maximum number of results to return.
            offset: The number of results to skip.

        Returns:
            A list of Spatial Entries in STAC Item format.
        """
        params = spatial_search_params(
            query=query,
            predicate=predicate,
            relates=relates,
            distance=distance,
            acquired=acquired,
            instrumentation=instrumentation,
            limit=limit,
            offset=offset,
        )

        if num_bands and len(num_bands) == 2:
            nbmin, nbmax = num_bands
            params['num_bands_min'] = nbmin
            params['num_bands_max'] = nbmax

        if resolution and len(resolution) == 2:
            rmin, rmax = resolution
            params['resolution_min'] = rmin
            params['resolution_max'] = rmax

        if cloud_cover and len(cloud_cover) == 2:
            ccmin, ccmax = cloud_cover
            params['cloud_cover_min'] = ccmin
            params['cloud_cover_max'] = ccmax

        return list(limit_offset_pager(self.session, 'rgd_imagery/raster/search', params=params))

rgd-client/rgd_client/rgdc.py
diff --git a/.github/workflows/rgd-client.yml b/.github/workflows/rgd-client.yml
index adec6aa96..c9a35469b 100644
--- a/.github/workflows/rgd-client.yml
+++ b/.github/workflows/rgd-client.yml
@@ -5,25 +5,44 @@ on:
   pull_request:
     branches: "**"
 jobs:
-  deploy:
+  rgd-client:
     runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgis/postgis:latest
+        env:
+          POSTGRES_DB: django
+          POSTGRES_PASSWORD: postgres
+        ports:
+          - 5432:5432
+      rabbitmq:
+        image: rabbitmq:management
+        ports:
+          - 5672:5672
+      minio:
+        # This image does not require any command arguments (which GitHub Actions don't support)
+        image: bitnami/minio:latest
+        env:
+          MINIO_ACCESS_KEY: minioAccessKey
+          MINIO_SECRET_KEY: minioSecretKey
+        ports:
+          - 9000:9000
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python
         uses: actions/setup-python@v2
         with:
           python-version: "3.8"
-      - name: Install dependencies
+      - name: Install tox
         run: |
-          python -m pip install --upgrade pip
-          pip install setuptools pytest
-      - name: Install rgd-client
-        working-directory: ./rgd-client
-        run: pip install -e .
-      - name: Test import
-        working-directory: ./rgd-client
+          pip install --upgrade pip
+          pip install tox
+      - name: Test rgd-client
         run: |
-          python -c "from rgd_client import Rgdc; print('Import went okay!')"
-      # - name: Test
-      #   working-directory: ./rgd-client
-      #   run: pytest . -v
+          tox -e test-rgd-client
+        env:
+          MINIO_STORAGE_ENDPOINT: localhost:9000
+          MINIO_STORAGE_ACCESS_KEY: minioAccessKey
+          MINIO_STORAGE_SECRET_KEY: minioSecretKey
+          CELERY_BROKER_URL: amqp://localhost:5672/
+          DATABASE_HOST: localhost
diff --git a/rgd-client/rgd_client/rgdc.py b/rgd-client/rgd_client/rgdc.py
index 5f624ab98..f1574bcb8 100644
--- a/rgd-client/rgd_client/rgdc.py
+++ b/rgd-client/rgd_client/rgdc.py
@@ -238,7 +238,11 @@ def search(
             limit=limit,
             offset=offset,
         )
-        return list(limit_offset_pager(self.session, 'rgd/search', params=params))
+
+        r = self.session.get('rgd/search', params=params)
+        r.raise_for_status()
+
+        return r.json()
 
     def create_raster_stac(self, raster: Dict) -> Dict:
         """Create a raster entry using STAC format."""
diff --git a/rgd-client/tests/__init__.py b/rgd-client/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rgd-client/tests/conftest.py b/rgd-client/tests/conftest.py
new file mode 100644
index 000000000..6eaad55a1
--- /dev/null
+++ b/rgd-client/tests/conftest.py
@@ -0,0 +1,25 @@
+from django.contrib.auth.models import User
+import pytest
+
+from rgd_client import Rgdc
+
+from .data_fixtures import generate_fixtures
+
+# dynamic fixtures: populate commands
+for (name, fixture) in generate_fixtures():
+    globals()[name] = fixture
+
+
+@pytest.fixture
+def py_client(live_server):
+
+    params = {'username': 'test@kitware.com', 'email': 'test@kitware.com', 'password': 'password'}
+
+    user = User.objects.create_user(is_staff=True, is_superuser=True, **params)
+    user.save()
+
+    client = Rgdc(
+        username=params['username'], password=params['password'], api_url=f'{live_server.url}/api'
+    )
+
+    return client
diff --git a/rgd-client/tests/data_fixtures.py b/rgd-client/tests/data_fixtures.py
new file mode 100644
index 000000000..5a663f295
--- /dev/null
+++ b/rgd-client/tests/data_fixtures.py
@@ -0,0 +1,21 @@
+from django.core.management import call_command
+import pytest
+
+sources = [
+    'rgd_3d_demo',
+    'rgd_fmv_demo',
+    'rgd_geometry_demo',
+    'rgd_imagery_demo',
+]
+
+# dynamically creates fixtures for above management commands
+
+
+def generate_fixtures():
+    for s in sources:
+
+        @pytest.fixture
+        def data_fixture():
+            call_command(s)
+
+        yield (s, data_fixture)
diff --git a/rgd-client/tests/manage.py b/rgd-client/tests/manage.py
new file mode 100755
index 000000000..9e883ec7a
--- /dev/null
+++ b/rgd-client/tests/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+import os
+import sys
+
+from django.core.management import execute_from_command_line
+
+
+def main() -> None:
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings')
+    execute_from_command_line(sys.argv)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/rgd-client/tests/test_client.py b/rgd-client/tests/test_client.py
new file mode 100644
index 000000000..f1e89aa94
--- /dev/null
+++ b/rgd-client/tests/test_client.py
@@ -0,0 +1,80 @@
+import json
+
+import pytest
+
+bbox = {
+    'type': 'Polygon',
+    'coordinates': [
+        [
+            [-105.45091240368326, 39.626245373878696],
+            [-105.45091240368326, 39.929904289147274],
+            [-104.88775649170178, 39.929904289147274],
+            [-104.88775649170178, 39.626245373878696],
+            [-105.45091240368326, 39.626245373878696],
+        ]
+    ],
+}
+
+
+@pytest.mark.django_db(transaction=True)
+def test_basic_search(py_client, rgd_imagery_demo):
+
+    q = py_client.search(query=json.dumps(bbox), predicate='intersects')
+
+    assert any(x['subentry_name'] == 'afie_1.jpg' for x in q)
+
+    assert any(
+        x['subentry_name'] == 'LC08_L1TP_034032_20200429_20200509_01_T1_sr_band1.tif' for x in q
+    )
+
+
+@pytest.mark.django_db(transaction=True)
+def test_inspect_raster(py_client, rgd_imagery_demo):
+
+    q = py_client.search(query=json.dumps(bbox), predicate='intersects')
+
+    raster_meta = next(
+        (
+            x
+            for x in q
+            if x['subentry_name'] == 'LC08_L1TP_034032_20200429_20200509_01_T1_sr_band1.tif'
+        ),
+        None,
+    )
+
+    assert raster_meta is not None
+
+    try:
+        raster = py_client.get_raster(raster_meta)
+        count = len(raster['parent_raster']['image_set']['images'])
+    except Exception:
+        pytest.fail('Failed to get raster from meta')
+
+    for i in range(count):
+        try:
+            py_client.download_raster_thumbnail(raster_meta, band=i)
+        except Exception:
+            pytest.fail(f'Failed to download raster thumbnail {i}')
+
+
+@pytest.mark.django_db(transaction=True)
+def test_download_raster(py_client, rgd_imagery_demo):
+
+    q = py_client.search(query=json.dumps(bbox), predicate='intersects')
+
+    assert len(q) >= 1
+
+    try:
+        py_client.download_raster(q[0])
+    except Exception as e:
+        print(e)
+        pytest.fail('Failed to download raster image set')
+
+
+# TODO: figure out TemplateDoesNotExist error
+# def test_basic_stac_search(rgd_imagery_demo):
+
+#     try:
+#         py_client.search_raster_stac(query=json.dumps(bbox), predicate='intersects')
+#     except Exception:
+#         pytest.fail('Failed STAC search')
diff --git a/rgd-client/tests/test_project/__init__.py b/rgd-client/tests/test_project/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rgd-client/tests/test_project/celery.py b/rgd-client/tests/test_project/celery.py
new file mode 100644
index 000000000..aaf507cb7
--- /dev/null
+++ b/rgd-client/tests/test_project/celery.py
@@ -0,0 +1,17 @@
+import os
+
+from celery import Celery
+
+# import configurations.importer
+#
+os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings'
+# if not os.environ.get('DJANGO_CONFIGURATION'):
+#     raise ValueError('The environment variable "DJANGO_CONFIGURATION" must be set.')
+# configurations.importer.install()
+
+# Using a string config_source means the worker doesn't have to serialize
+# the configuration object to child processes.
+app = Celery('test_project', config_source='django.conf:settings', namespace='CELERY')
+
+# Load task modules from all registered Django app configs.
+app.autodiscover_tasks()
diff --git a/rgd-client/tests/test_project/settings.py b/rgd-client/tests/test_project/settings.py
new file mode 100644
index 000000000..fa2fa9b7e
--- /dev/null
+++ b/rgd-client/tests/test_project/settings.py
@@ -0,0 +1,22 @@
+from rgd_testing_utils.settings import *  # noqa
+
+INSTALLED_APPS += [  # noqa
+    'rgd_3d',
+    'rgd_fmv',
+    'rgd_geometry',
+    'rgd_imagery',
+    # Swagger
+    'drf_yasg',
+    'django_extensions',
+]
+
+ROOT_URLCONF = 'test_project.urls'
+WSGI_APPLICATION = 'test_project.wsgi.application'
+
+# Swagger
+REFETCH_SCHEMA_WITH_AUTH = True
+REFETCH_SCHEMA_ON_LOGOUT = True
+OPERATIONS_SORTER = 'alpha'
+DEEP_LINKING = True
+
+STATIC_URL = '/static/'
diff --git a/rgd-client/tests/test_project/urls.py b/rgd-client/tests/test_project/urls.py
new file mode 100644
index 000000000..610edd7e6
--- /dev/null
+++ b/rgd-client/tests/test_project/urls.py
@@ -0,0 +1,44 @@
+from django.contrib import admin
+from django.urls import include, path, re_path
+from django.views.generic.base import RedirectView
+from drf_yasg import openapi
+from drf_yasg.views import get_schema_view
+from rest_framework import permissions
+
+urlpatterns = [
+    path('admin/', admin.site.urls),
+    path('accounts/', include('allauth.urls')),
+    path('oauth/', include('oauth2_provider.urls', namespace='oauth2_provider')),
+    path('api/s3-upload/', include('s3_file_field.urls')),
+    path('', include('rgd.urls')),
+    path('', include('rgd_3d.urls')),
+    path('', include('rgd_fmv.urls')),
+    path('', include('rgd_geometry.urls')),
+    path('', include('rgd_imagery.urls')),
+    # Redirect homepage to RGD core app homepage
+    path(r'', RedirectView.as_view(url='rgd', permanent=False), name='index'),
+]
+
+schema_view = get_schema_view(
+    openapi.Info(
+        title='ResonantGeoData API',
+        default_version='v1',
+        description='ResonantGeoData',
+        # terms_of_service='https://www.google.com/policies/terms/',
+        contact=openapi.Contact(email='kitware@kitare.com'),
+        license=openapi.License(name='Apache 2.0'),
+    ),
+    public=False,
+    permission_classes=(permissions.AllowAny,),
+    patterns=urlpatterns,
+)
+
+urlpatterns += [
+    re_path(
+        r'^swagger(?P<format>\.json|\.yaml)$',
+        schema_view.without_ui(cache_timeout=0),
+        name='schema-json',
+    ),
+    path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
+    path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
+]
diff --git a/rgd-client/tests/test_project/wsgi.py b/rgd-client/tests/test_project/wsgi.py
new file mode 100644
index 000000000..1e6481c78
--- /dev/null
+++ b/rgd-client/tests/test_project/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings')
+
+application = get_wsgi_application()
diff --git a/rgd-client/tests/test_status.py b/rgd-client/tests/test_status.py
new file mode 100644
index 000000000..4c2d0b034
--- /dev/null
+++ b/rgd-client/tests/test_status.py
@@ -0,0 +1,11 @@
+import requests
+
+import rgd_client
+
+
+def test_rgd_version():
+    assert rgd_client.__version__  # Make sure not None
+
+
+def test_server_status(live_server):
+    assert requests.get(live_server.url).status_code == 200
diff --git a/rgd-client/tests/test_version.py b/rgd-client/tests/test_version.py
deleted file mode 100644
index ace72d6a4..000000000
--- a/rgd-client/tests/test_version.py
+++ /dev/null
@@ -1,5 +0,0 @@
-import rgd_client
-
-
-def test_rgd_version():
-    assert rgd_client.__version__  # Make sure not None
diff --git a/testing-utils/rgd_testing_utils/settings.py b/testing-utils/rgd_testing_utils/settings.py
index e8b39e28c..2609e18d1 100644
--- a/testing-utils/rgd_testing_utils/settings.py
+++ b/testing-utils/rgd_testing_utils/settings.py
@@ -96,7 +96,10 @@
 DEFAULT_FILE_STORAGE = 'minio_storage.storage.MinioMediaStorage'
 MINIO_STORAGE_ENDPOINT = os.environ.get('MINIO_STORAGE_ENDPOINT', 'minio:9000')
 MINIO_STORAGE_MEDIA_URL = os.environ.get(
-    'MINIO_STORAGE_MEDIA_URL', 'http://localhost:9000/django-storage'
+    'MINIO_STORAGE_MEDIA_URL',
+    'http://minio:9000/django-storage'
+    if MINIO_STORAGE_ENDPOINT == 'minio:9000'
+    else 'http://localhost:9000/django-storage',
 )
 MINIO_STORAGE_USE_HTTPS = False
 MINIO_STORAGE_ACCESS_KEY = os.environ.get('MINIO_STORAGE_ACCESS_KEY', 'minioAccessKey')
diff --git a/tox.ini b/tox.ini
index 99fffa93c..4b462de46 100644
--- a/tox.ini
+++ b/tox.ini
@@ -121,6 +121,21 @@ commands =
     coverage html -d {toxworkdir}/htmlcov/django-rgd-imagery
     coverage xml -o {toxworkdir}/coverage.xml
 
+[testenv:test-rgd-client]
+changedir = rgd-client/tests
+deps =
+    -r requirements.txt
+    -e ./django-rgd
+    -e ./django-rgd-3d[worker]
+    -e ./django-rgd-fmv[worker]
+    -e ./django-rgd-geometry[worker]
+    -e ./django-rgd-imagery[worker]
+    -e ./rgd-client[dev]
+commands =
+    pytest {posargs}
+    # coverage html -d {toxworkdir}/htmlcov/django-rgd-client
+    # coverage xml -o {toxworkdir}/coverage.xml
+
 [testenv:dev]
 deps =
     {[testenv:lint]deps}

Proper testing for rgd_client
We need to implement real tests for the Python client

The tests for this would require running RGD with data prepopulated in the background then executing the client tests
