# -*- coding: utf-8 -*-
# Copyright 2018, CS GROUP - France, https://www.csgroup.eu/
#
# This file is part of EODAG project
#     https://www.github.com/CS-SI/EODAG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ast
import datetime as dt
import json
import os
import re
import ssl
import unittest
from copy import deepcopy as copy_deepcopy
from pathlib import Path
from typing import Literal, Union, get_origin
from unittest import mock
from unittest.mock import call

import boto3
import botocore
import pytest
import requests
import responses
from botocore.stub import Stubber
from jsonpath_ng import JSONPath, parse
from pydantic_core import PydanticUndefined
from requests import RequestException
from shapely.geometry.base import BaseGeometry
from typing_extensions import get_args

from eodag.api.product import AssetsDict
from eodag.api.product.metadata_mapping import get_queryable_from_provider
from eodag.api.provider import Provider, ProvidersDict
from eodag.api.search_result import RawSearchResult
from eodag.plugins.search.cop_ghsl import (
    _convert_bbox_to_lonlat_EPSG3035,
    _convert_bbox_to_lonlat_mollweide,
    _get_available_values_from_constraints,
    _replace_datetimes,
)
from eodag.utils import deepcopy
from eodag.utils.exceptions import (
    PluginImplementationError,
    QuotaExceededError,
    UnsupportedCollection,
    ValidationError,
)
from tests.context import (
    DEFAULT_SEARCH_TIMEOUT,
    HTTP_REQ_TIMEOUT,
    NOT_AVAILABLE,
    TEST_RESOURCES_PATH,
    USER_AGENT,
    AuthenticationError,
    EOProduct,
    MisconfiguredError,
    NotAvailableError,
    PluginManager,
    PreparedSearch,
    QueryablesDict,
    QueryStringSearch,
    RequestError,
    TimeOutError,
    cached_parse,
    cached_yaml_load_all,
    ecmwf_temporal_to_eodag,
    get_geometry_from_various,
    load_default_config,
)


class BaseSearchPluginTest(unittest.TestCase):
    def setUp(self):
        super(BaseSearchPluginTest, self).setUp()
        providers = ProvidersDict.from_configs(load_default_config())
        self.plugins_manager = PluginManager(providers)
        self.collection = "S2_MSI_L1C"
        geom = [137.772897, 13.134202, 153.749135, 23.885986]
        geometry = get_geometry_from_various([], geometry=geom)
        self.search_criteria_s2_msi_l1c = {
            "collection": self.collection,
            "start_datetime": "2020-08-08",
            "end_datetime": "2020-08-16",
            "geometry": geometry,
        }
        self.provider_resp_dir = Path(TEST_RESOURCES_PATH) / "provider_responses"

    def get_search_plugin(self, collection=None, provider=None):
        return next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider=provider
            )
        )

    def get_auth_plugin(self, search_plugin):
        return self.plugins_manager.get_auth_plugin(search_plugin)

    def test_get_assets_from_mapping(self):
        search_plugin = self.get_search_plugin(provider="geodes")
        search_plugin.config.assets_mapping = {
            "one": {"href": "$.properties.href", "roles": ["a_role"], "title": "One"},
            "two": {
                "href": "https://a.static_url.com",
                "roles": ["a_role"],
                "title": "Two",
            },
        }
        provider_item = {"id": "ID123456", "properties": {"href": "a.product.com/ONE"}}
        asset_mappings = search_plugin.get_assets_from_mapping(provider_item)
        self.assertEqual(2, len(asset_mappings))
        self.assertEqual("a.product.com/ONE", asset_mappings["one"]["href"])
        self.assertEqual("One", asset_mappings["one"]["title"])
        self.assertListEqual(["a_role"], asset_mappings["one"]["roles"])
        self.assertEqual("https://a.static_url.com", asset_mappings["two"]["href"])
        self.assertEqual("Two", asset_mappings["two"]["title"])
        self.assertListEqual(["a_role"], asset_mappings["two"]["roles"])


class TestSearchPluginQueryStringSearchXml(BaseSearchPluginTest):
    def setUp(self):
        super().setUp()

        provider = "mundi"

        # manually add conf as this provider is not supported any more
        mundi_config = cached_yaml_load_all(
            Path(TEST_RESOURCES_PATH) / "mundi_conf.yml"
        )[0]
        self.plugins_manager.providers[provider] = Provider(mundi_config)
        self.plugins_manager.rebuild()

        # One of the providers that has a QueryStringSearch Search plugin and result_type=xml
        self.mundi_search_plugin = self.get_search_plugin(self.collection, provider)
        self.mundi_auth_plugin = self.get_auth_plugin(self.mundi_search_plugin)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_count_and_search_mundi(
        self, mock__request
    ):
        """A query with a QueryStringSearch (mundi here) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        products = self.mundi_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.mundi_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        mundi_url_search = (
            "https://Sentinel2.browse.catalog.mundiwebservices.com/opensearch?timeStart=2020-08-08T00:00:00.000Z&"
            "timeEnd=2020-08-16T00:00:00.000Z&geometry="
            "POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, 137.7729 13.1342))&"
            "productType=IMAGE&processingLevel=L1C&format=atom&relation=intersects&maxRecords=2&startIndex=1"
        )
        mundi_products_count = 47
        number_of_products = 2

        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, mundi_url_search)

        self.assertEqual(products.number_matched, mundi_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_no_count_and_search_mundi(
        self, mock__request, mock_count_hits
    ):
        """A query with a QueryStringSearch (here mundi) without a count"""
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        products = self.mundi_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.mundi_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        mundi_url_search = (
            "https://Sentinel2.browse.catalog.mundiwebservices.com/opensearch?timeStart=2020-08-08T00:00:00.000Z&"
            "timeEnd=2020-08-16T00:00:00.000Z&geometry="
            "POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, 137.7729 13.1342))&"
            "productType=IMAGE&processingLevel=L1C&format=atom&relation=intersects&maxRecords=2&startIndex=1"
        )
        number_of_products = 2

        mock_count_hits.assert_not_called()
        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, mundi_url_search)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_xml_distinct_collection_mtd_mapping(
        self, mock__request, mock_count_hits
    ):
        """The metadata mapping for XML QueryStringSearch should not mix specific collections metadata-mapping"""
        with open(self.provider_resp_dir / "mundi_search.xml", "rb") as f:
            mundi_resp_search = f.read()
        mock__request.return_value = mock.Mock()
        mock__request.return_value.content = mundi_resp_search

        search_plugin = self.get_search_plugin(self.collection, "mundi")

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "dc:creator/text()",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "dhus")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
        )
        self.assertNotIn("bar", products.data[0].properties)


class TestSearchPluginQueryStringSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginQueryStringSearch, self).setUp()
        # One of the providers that has a QueryStringSearch Search plugin
        provider = "sara"
        self.sara_search_plugin = self.get_search_plugin(self.collection, provider)
        self.sara_auth_plugin = self.get_auth_plugin(self.sara_search_plugin)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_count_and_search_cop_dataspace(
        self, mock__request
    ):
        self.maxDiff = None
        """A query with a QueryStringSearch (sara here) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "sara_search.json") as f:
            sara_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            sara_resp_search,
        ]
        products = self.sara_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.sara_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        sara_url_search = (
            "https://copernicus.nci.org.au/sara.server/1.0/api/collections/S2/search.json?startDate=2020-08-08&"
            "completionDate=2020-08-16&geometry=POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, "
            "153.7491 13.1342, 137.7729 13.1342))&productType=S2MSIL1C&instrument=MSI&processingLevel=L1C&"
            "sortParam=startDate&sortOrder=asc&maxRecords=2&page=1"
        )
        sara_products_count = 47
        number_of_products = 2

        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, sara_url_search)

        self.assertEqual(products.number_matched, sara_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_no_count_and_search_cop_dataspace(
        self, mock__request, mock_count_hits
    ):
        """A query with a QueryStringSearch (here sara) without a count"""
        with open(self.provider_resp_dir / "sara_search.json") as f:
            sara_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = sara_resp_search
        products = self.sara_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.sara_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        sara_url_search = (
            "https://copernicus.nci.org.au/sara.server/1.0/api/collections/S2/search.json?startDate=2020-08-08&"
            "completionDate=2020-08-16&geometry=POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, "
            "153.7491 13.1342, 137.7729 13.1342))&productType=S2MSIL1C&instrument=MSI&processingLevel=L1C&"
            "sortParam=startDate&sortOrder=asc&maxRecords=2&page=1"
        )
        number_of_products = 2

        mock_count_hits.assert_not_called()
        mock__request.assert_called_once()
        self.assertEqual(mock__request.call_args_list[-1][0][1].url, sara_url_search)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_search_cloudcover_cop_dataspace(
        self, mock__request, mock_normalize_results
    ):
        """A query with a QueryStringSearch (here sara) must use cloudCover filtering when configured"""

        self.sara_search_plugin.query(collection="S2_MSI_L1C", **{"eo:cloud_cover": 50})
        mock__request.assert_called()
        self.assertIn("cloudCover", mock__request.call_args_list[-1][0][1].url)

    def test_plugins_search_querystringsearch_search_cop_dataspace_ko(self):
        """A query with a parameter which is not queryable must
        raise an error if the provider does not allow it"""  # noqa
        # with raised error parameter set to True in the global config of the provider
        provider_search_plugin_config = copy_deepcopy(self.sara_search_plugin.config)
        self.sara_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = True

        with self.assertRaises(ValidationError) as context:
            self.sara_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.sara_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.sara_search_plugin.provider}",
            context.exception.message,
        )

        # with raised error parameter set to True in the config of the collection of the provider

        # first, update this parameter to False in the global config
        # to show that it is going to be taken over by this new config
        self.sara_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = False

        self.sara_search_plugin.config.products[
            self.search_criteria_s2_msi_l1c["collection"]
        ]["discover_metadata"] = {"raise_mtd_discovery_error": True}

        with self.assertRaises(ValidationError) as context:
            self.sara_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.sara_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.sara_search_plugin.provider}",
            context.exception.message,
        )

        # restore the original config
        self.sara_search_plugin.config = provider_search_plugin_config

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_search_quota_exceeded(
        self, mock__request
    ):
        """A query with a QueryStringSearch must handle a 429 response returned by the provider"""
        response = MockResponse({}, status_code=429)
        mock__request.side_effect = requests.exceptions.HTTPError(response=response)
        prep = PreparedSearch(collection="S2_MSI_L1C", count=False)
        with self.assertRaises(QuotaExceededError):
            self.sara_search_plugin.query(
                prep=prep, collection="S2_MSI_L1C", **{"eo:cloud_cover": 50}
            )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections(self, mock__request):
        """QueryStringSearch.discover_collections must return a well formatted dict"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change onfiguration for this test to filter out some collections
        results_entry = search_plugin.config.discover_collections["results_entry"]
        search_plugin.config.discover_collections["results_entry"] = cached_parse(
            'collections[?billing=="free"]'
        )

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                    "billing": "free",
                },
                {
                    "id": "bar_collection",
                    "title": "The BAR non-free collection",
                    "billing": "non-free",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertNotIn("bar_collection", conf_update_dict["providers_config"])
        self.assertNotIn("bar_collection", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["_collection"],
            "foo_collection",
        )
        self.assertEqual(
            conf_update_dict["collections_config"]["foo_collection"]["title"],
            "The FOO collection",
        )
        # restore configuration
        search_plugin.config.discover_collections["results_entry"] = results_entry

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections_unparsable_metadata_mapping(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must merge unparsable metadata_mapping with default"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # backup and set unparsable_properties with a metadata_mapping override
        discover_collections_conf = copy_deepcopy(
            search_plugin.config.discover_collections
        )
        from eodag.api.product.metadata_mapping import (
            mtd_cfg_as_conversion_and_querypath,
        )

        search_plugin.config.discover_collections[
            "generic_collection_unparsable_properties"
        ] = {
            "metadata_mapping": mtd_cfg_as_conversion_and_querypath(
                {"eo:cloud_cover": "$.null"}
            )
        }

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        providers_config = conf_update_dict["providers_config"]["foo_collection"]

        # unparsable metadata_mapping should be merged with default metadata_mapping
        self.assertIn("metadata_mapping", providers_config)
        merged_mapping = providers_config["metadata_mapping"]

        # should contain the override from unparsable_properties
        self.assertIn("eo:cloud_cover", merged_mapping)

        # should also contain keys from the default metadata_mapping
        self.assertIn("id", merged_mapping)
        self.assertIn("geometry", merged_mapping)
        self.assertIn("start_datetime", merged_mapping)

        # restore configuration
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_paginated(self):
        """QueryStringSearch.discover_collections must handle pagination"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change configuration for this test to filter out some collections
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections[
            "fetch_url"
        ] = "https://foo.bar/collections"
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0

        with responses.RequestsMock(
            assert_all_requests_are_fired=True
        ) as mock_requests_post:
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=0",
                json={
                    "collections": [
                        {
                            "id": "foo_collection",
                            "title": "The FOO collection",
                            "billing": "free",
                        }
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=1",
                json={
                    "collections": [
                        {
                            "id": "bar_collection",
                            "title": "The BAR non-free collection",
                            "billing": "non-free",
                        },
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=2",
                json={"collections": []},
            )
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn("foo_collection", conf_update_dict["providers_config"])
            self.assertIn("foo_collection", conf_update_dict["collections_config"])
            self.assertIn("bar_collection", conf_update_dict["providers_config"])
            self.assertIn("bar_collection", conf_update_dict["collections_config"])
            self.assertEqual(
                conf_update_dict["providers_config"]["foo_collection"]["_collection"],
                "foo_collection",
            )
            self.assertEqual(
                conf_update_dict["collections_config"]["foo_collection"]["title"],
                "The FOO collection",
            )

        # restore configuration
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_without_fetch_url(
        self,
    ):
        """QueryStringSearch.discover_collections must handle missing fetch_url"""
        # One of the providers that has a QueryStringSearch Search plugin and discover_collections configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections.pop("fetch_url", None)

        response = search_plugin.discover_collections()
        self.assertIsNone(response)
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_paginated_qs_dict(
        self,
    ):
        """
        QueryStringSearch.discover_collections must handle paginated responses with query string parameters
        """
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        # change configuration for this test to filter out some collections
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections[
            "fetch_url"
        ] = "https://foo.bar/collections"
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0
        search_plugin.config.discover_collections[
            "single_collection_fetch_qs"
        ] = "foo=bar"

        with responses.RequestsMock(
            assert_all_requests_are_fired=True
        ) as mock_requests_post:
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=0&foo=bar",
                json={
                    "collections": [
                        {
                            "id": "foo_collection",
                            "title": "The FOO collection",
                            "billing": "free",
                        }
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=1&foo=bar",
                json={
                    "collections": [
                        {
                            "id": "bar_collection",
                            "title": "The BAR non-free collection",
                            "billing": "non-free",
                        },
                    ]
                },
            )
            mock_requests_post.add(
                responses.GET,
                "https://foo.bar/collections?page=2&foo=bar",
                json={"collections": []},
            )
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn("foo_collection", conf_update_dict["providers_config"])
            self.assertIn("foo_collection", conf_update_dict["collections_config"])
            self.assertIn("bar_collection", conf_update_dict["providers_config"])
            self.assertIn("bar_collection", conf_update_dict["collections_config"])
            self.assertEqual(
                conf_update_dict["providers_config"]["foo_collection"]["_collection"],
                "foo_collection",
            )
            self.assertEqual(
                conf_update_dict["collections_config"]["foo_collection"]["title"],
                "The FOO collection",
            )

        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_no_fetch_url(
        self,
    ):
        """QueryStringSearch.discover_collections must handle paginated responses with query string parameters"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections.pop("fetch_url")
        search_plugin.config.discover_collections[
            "next_page_url_tpl"
        ] = "{url}?page={page}"
        search_plugin.config.discover_collections["start_page"] = 0
        result = search_plugin.discover_collections_per_page()
        assert result is None

        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_keyerror(
        self,
    ):
        """QueryStringSearch.discover_collections must handle missing keys in the response"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections = {
            "result_type": "json",
            "results_entry": parse("$.collections"),
        }

        class DummyResponse:
            def json(self):
                return {"collections": [{}]}

        with mock.patch.object(
            QueryStringSearch, "_request", return_value=DummyResponse()
        ):
            with self.assertLogs(level="WARNING") as log:
                result = search_plugin.discover_collections_per_page(
                    fetch_url="https://foo.bar/collections"
                )
                assert result is None
                assert any("Incomplete" in m for m in log.output)
        search_plugin.config.discover_collections = discover_collections_conf

    def test_plugins_search_querystringsearch_discover_collections_per_page_request_exception(
        self,
    ):
        """QueryStringSearch.discover_collections must handle request exceptions"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        discover_collections_conf = search_plugin.config.discover_collections
        search_plugin.config.discover_collections = {
            "result_type": "json",
            "results_entry": JSONPath(),
        }

        class DummyResponse:
            def json(self):
                raise requests.RequestException("boom")

        with mock.patch.object(
            QueryStringSearch, "_request", return_value=DummyResponse()
        ):
            with self.assertLogs(level="DEBUG") as log:
                result = search_plugin.discover_collections_per_page(
                    fetch_url="https://foo.bar/collections"
                )
                assert result is None
                assert any(
                    "Could not parse discovered collections response" in m
                    for m in log.output
                )

        search_plugin.config.discover_collections = discover_collections_conf

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_post(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must be able to query using POST requests"""
        # One of the providers that has a QueryStringSearch.discover_collections configured with POST requests
        provider = "geodes"
        search_plugin = self.get_search_plugin(self.collection, provider)

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "title": "The FOO collection",
                },
                {
                    "id": "bar_collection",
                    "title": "The BAR collection",
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertIn("bar_collection", conf_update_dict["providers_config"])
        self.assertIn("bar_collection", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["_collection"],
            "foo_collection",
        )
        self.assertEqual(
            conf_update_dict["collections_config"]["foo_collection"]["title"],
            "The FOO collection",
        )

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_with_query_param(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must return a well formatted dict"""
        # One of the providers that has discover_collections() configured with QueryStringSearch
        provider = "wekeo_cmems"
        search_plugin = self.get_search_plugin(provider=provider)
        self.assertEqual("PostJsonSearch", search_plugin.__class__.__name__)
        self.assertEqual(
            "QueryStringSearch",
            search_plugin.discover_collections.__func__.__qualname__.split(".")[0],
        )

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "foo_collection",
                        "metadata": {"title": "The FOO collection"},
                    }
                ]
            },
            {
                "dataset_id": "foo_collection",
                "metadata": {"title": "The FOO collection"},
            },
        ]
        search_plugin.discover_collections()
        mock__request.assert_called_with(
            mock.ANY,
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/datasets/foo_collection",
            timeout=60,
            headers=USER_AGENT,
            verify=True,
        )

    @mock.patch("eodag.plugins.search.qssearch.requests.Session.get", autospec=True)
    def test_plugins_search_querystringsearch_discover_collections_with_id_to_rename(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must handle collections that have to be renamed"""
        # One of the providers that has discover_collections() configured with QueryStringSearch
        provider = "wekeo_cmems"
        search_plugin = self.get_search_plugin(provider=provider)
        # change configuration for this test to rename the collection id
        search_plugin.config.discover_collections[
            "single_collection_parsable_metadata"
        ]["id"] = (None, cached_parse("$.metadata.understandable_id"))

        # case where the name to replace the current id exists in the metadata
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "1a2b3c4d",
                        "metadata": {"title": "The FOO collection"},
                    }
                ]
            },
            {
                "dataset_id": "1a2b3c4d",
                "metadata": {
                    "title": "The FOO collection",
                    "understandable_id": "foo_collection",
                },
            },
        ]

        with self.assertLogs(level="DEBUG") as cm:
            conf_update_dict = search_plugin.discover_collections()
            self.assertIn(
                "Rename 1a2b3c4d collection to foo_collection", str(cm.output)
            )

        self.assertIn("foo_collection", conf_update_dict["providers_config"])
        self.assertIn("foo_collection", conf_update_dict["collections_config"])
        self.assertNotIn("1a2b3c4d", conf_update_dict["providers_config"])
        self.assertNotIn("1a2b3c4d", conf_update_dict["collections_config"])
        self.assertEqual(
            conf_update_dict["providers_config"]["foo_collection"]["collection"],
            "1a2b3c4d",
        )
        self.assertNotIn("id", conf_update_dict["collections_config"]["foo_collection"])

        # case where the name to replace the current id does not exist in the metadata
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "dataset_id": "5e6f7g8h",
                        "metadata": {"title": "The BAR collection"},
                    }
                ]
            },
            {
                "dataset_id": "5e6f7g8h",
                "metadata": {"title": "The BAR collection"},
            },
        ]

        conf_update_dict = search_plugin.discover_collections()

        self.assertNotIn("5e6f7g8h", conf_update_dict["providers_config"])
        self.assertNotIn("5e6f7g8h", conf_update_dict["collections_config"])

        # restore configuration
        del search_plugin.config.discover_collections[
            "single_collection_parsable_metadata"
        ]["id"]

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_discover_collections_keywords(
        self, mock__request
    ):
        """QueryStringSearch.discover_collections must return a dict with well formatted keywords"""
        # One of the providers that has a QueryStringSearch Search plugin and keywords configured
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = {
            "collections": [
                {
                    "id": "foo_collection",
                    "keywords": ["foo", "bar"],
                    "summaries": {
                        "instruments": ["baZ"],
                        "constellation": "qux,foo",
                        "platform": ["quux", "corge", "bar"],
                        "processing:level": "GRAULT",
                    },
                },
            ]
        }
        conf_update_dict = search_plugin.discover_collections()
        keywords_list = conf_update_dict["collections_config"]["foo_collection"][
            "keywords"
        ]

        self.assertEqual(
            [
                "bar",
                "baz",
                "corge",
                "foo",
                "foo-collection",
                "grault",
                "quux",
                "qux",
            ],
            keywords_list,
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_querystringsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for QueryStringSearch should not mix specific collections metadata-mapping"""
        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__
        mock__request.return_value = mock.Mock()
        result = {
            "totalResults": 1,
            "features": [
                {
                    "id": "foo",
                    "geometry": geojson_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]
        search_plugin = self.get_search_plugin(self.collection, "sara")

        # ensure metadata_mapping exists for S1_SAR_GRD and S1_SAR_SLC
        search_plugin.config.products["S1_SAR_GRD"].setdefault("metadata_mapping", {})
        search_plugin.config.products["S1_SAR_SLC"].setdefault("metadata_mapping", {})

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch(
        "eodag.plugins.search.qssearch.requests.Session.get",
        autospec=True,
        side_effect=requests.exceptions.Timeout(),
    )
    def test_plugins_search_querystringseach_timeout(self, mock__request):
        search_plugin = self.get_search_plugin(self.collection, "sara")
        with self.assertRaises(TimeOutError):
            search_plugin.query(
                collection="S1_SAR_SLC",
                auth=None,
            )

    def test_plugins_search_querystringsearch_count_hits_xml(self):
        """Test QueryStringSearch.count_hits() with XML response and XPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {
            "total_items_nb_key_path": "string(//ns:TotalResults)"
        }
        xml = (
            """<root xmlns="http://example.com"><TotalResults>4</TotalResults></root>"""
        )

        mock_response = mock.Mock()
        mock_response.content = xml.encode()

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            result = search_plugin.count_hits("http://fake.url", result_type="xml")
            assert result == 4

    def test_plugins_search_querystringsearch_count_hits_json_dict_ok(self):
        """Test QueryStringSearch.count_hits() with JSON response and JSONPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {"total_items_nb_key_path": parse("$.total")}

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            result = search_plugin.count_hits("http://fake.url")
            assert result == 99

    def test_plugins_search_querystringsearch_count_hits_json_dict_not_jsonpath(self):
        """Test QueryStringSearch.count_hits() with JSON response and non-JSONPath key path"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {"total_items_nb_key_path": "$.total"}

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            with pytest.raises(PluginImplementationError):
                search_plugin.count_hits("http://fake.url")

    def test_plugins_search_querystringsearch_count_hits_json_dict_jsonpath_not_found(
        self,
    ):
        """Test QueryStringSearch.count_hits() with JSON response and JSONPath key path not found in the response"""
        provider = "earth_search"
        search_plugin = self.get_search_plugin(self.collection, provider)
        search_plugin.config.pagination = {
            "total_items_nb_key_path": parse("$.missing")
        }

        mock_response = mock.Mock()
        mock_response.json.return_value = {"total": 99}

        with mock.patch.object(search_plugin, "_request", return_value=mock_response):
            with pytest.raises(MisconfiguredError):
                search_plugin.count_hits("http://fake.url")


class TestSearchPluginPostJsonSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginPostJsonSearch, self).setUp()
        # One of the providers that has a PostJsonSearch Search plugin
        provider = "aws_eos"
        self.awseos_search_plugin = self.get_search_plugin(self.collection, provider)
        self.awseos_auth_plugin = self.get_auth_plugin(self.awseos_search_plugin)
        self.awseos_auth_plugin.config.credentials = dict(apikey="dummyapikey")
        self.awseos_url = "https://gate.eos.com/api/lms/search/v2/sentinel2"

    def test_plugins_search_postjsonsearch_request_error(self):
        """A query with a PostJsonSearch must handle requests errors"""

        @responses.activate(registry=responses.registries.FirstMatchRegistry)
        def run():
            responses.add(
                responses.POST, self.awseos_url, status=500, body=b"test error message"
            )

            with self.assertLogs("eodag.search.qssearch", level="DEBUG") as cm:
                with self.assertRaises(RequestError):
                    products, estimate = self.awseos_search_plugin.query(
                        prep=PreparedSearch(
                            page=1,
                            limit=2,
                            auth_plugin=self.awseos_auth_plugin,
                        ),
                        raise_errors=True,
                        **self.search_criteria_s2_msi_l1c,
                    )
                self.assertIn("test error message", str(cm.output))

        run()

    def test_plugins_search_postjsonsearch_request_auth_error(self):
        """A query with a PostJsonSearch must handle auth errors"""

        @responses.activate(registry=responses.registries.FirstMatchRegistry)
        def run():
            responses.add(
                responses.POST, self.awseos_url, status=403, body=b"test error message"
            )

            with self.assertRaisesRegex(AuthenticationError, "test error message"):
                products, estimate = self.awseos_search_plugin.query(
                    prep=PreparedSearch(
                        page=1,
                        limit=2,
                        auth_plugin=self.awseos_auth_plugin,
                    ),
                    **self.search_criteria_s2_msi_l1c,
                )

        run()

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_postjsonsearch_search_quota_exceeded(self, mock__request):
        """A query with a PostJsonSearch must handle a 429 response returned by the provider"""

        class MockResponseRequestsException(MockResponse):
            def raise_for_status(self):
                if self.status_code != 200:
                    raise requests.RequestException()

        response = MockResponseRequestsException({}, status_code=429)
        mock__request.return_value = response
        with self.assertRaises(QuotaExceededError):
            self.awseos_search_plugin.query(collection="S2_MSI_L2A")

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_count_and_search_awseos(self, mock__request):
        """A query with a PostJsonSearch (here aws_eos) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        with open(self.provider_resp_dir / "awseos_search.json") as f:
            awseos_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            awseos_resp_search,
        ]
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.awseos_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        awseos_products_count = 44
        number_of_products = 2

        self.assertEqual(mock__request.call_args_list[-1][0][1].url, self.awseos_url)

        self.assertEqual(products.number_matched, awseos_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_count_and_search_awseos_s2l2a(
        self, mock__request
    ):
        """A query with a PostJsonSearch (here aws_eos) must return a single EOProduct when search by id using specific_qssearch"""  # noqa

        mock_values = []
        with open(self.provider_resp_dir / "s2l2a_tileInfo.json") as f:
            mock_values.append(json.load(f))
        with open(self.provider_resp_dir / "s2l2a_productInfo.json") as f:
            mock_values.append(json.load(f))

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = mock_values

        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin, count=True),
            **{
                "collection": "S2_MSI_L2A",
                "id": "S2B_MSIL2A_20220101T000459_N0301_R130_T53DMB_20220101T012649",
            },
        )

        self.assertEqual(mock__request.call_count, 2)
        self.assertEqual(
            mock__request.call_args_list[0][0][1].url,
            "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/53/D/MB/2022/1/1/0/tileInfo.json",
        )
        self.assertEqual(
            mock__request.call_args_list[1][0][1].url,
            "https://roda.sentinel-hub.com/sentinel-s2-l2a/tiles/53/D/MB/2022/1/1/0/productInfo.json",
        )

        self.assertEqual(len(products.data), 1)
        self.assertIsInstance(products.data[0], EOProduct)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.count_hits", autospec=True
    )
    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_no_count_and_search_awseos(
        self, mock__request, mock_count_hits
    ):
        """A query with a PostJsonSearch (here aws_eos) without a count"""
        with open(self.provider_resp_dir / "awseos_search.json") as f:
            awseos_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.return_value = awseos_resp_search
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(
                count=False,
                page=1,
                limit=2,
                auth_plugin=self.awseos_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        # Specific expected results
        number_of_products = 2

        mock_count_hits.assert_not_called()
        self.assertEqual(mock__request.call_args_list[0][0][1].url, self.awseos_url)

        self.assertIsNone(products.number_matched)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count should not have been extracted from search results
        self.assertIsNone(
            getattr(mock__request.call_args_list[0][0][1], "total_items_nb", None)
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_postjsonsearch_search_cloudcover_awseos(
        self, mock_requests_post, mock_normalize_results
    ):
        """A query with a PostJsonSearch (here aws_eos) must only use cloudCover filtering for non-radar collections"""  # noqa

        self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S2_MSI_L1C",
            **{"eo:cloud_cover": 50},
        )
        mock_requests_post.assert_called()
        self.assertIn("cloudCoverage", str(mock_requests_post.call_args_list[-1][1]))
        mock_requests_post.reset_mock()

        self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S1_SAR_GRD",
            **{"eo:cloud_cover": 50},
        )
        mock_requests_post.assert_called()
        self.assertNotIn(
            "cloudCoverPercentage", str(mock_requests_post.call_args_list[-1][1])
        )

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for PostJsonSearch should not mix specific collections metadata-mapping"""
        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__
        mock__request.return_value = mock.Mock()
        result = {
            "meta": {"page": 1, "found": 1, "limit": 2},
            "results": [
                {
                    "id": "foo",
                    "dataGeometry": geojson_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]

        # update metadata_mapping only for S1_SAR_GRD
        self.awseos_search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"][
            "bar"
        ] = (
            None,
            "baz",
        )
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S1_SAR_GRD",
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar",
            self.awseos_search_plugin.config.products["S2_MSI_L1C"]["metadata_mapping"],
        )
        products = self.awseos_search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.awseos_auth_plugin),
            collection="S2_MSI_L1C",
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.normalize_results", autospec=True
    )
    def test_plugins_search_postjsonsearch_default_dates(
        self, mock_normalize, mock_request
    ):
        provider = "wekeo_ecmwf"
        search_plugins = self.plugins_manager.get_search_plugins(provider=provider)
        search_plugin = next(search_plugins)
        mock_request.return_value = MockResponse({"features": []}, 200)
        # year, month, day, time given -> don't use default dates
        search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["02"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "year": "2020",
                "month": ["02"],
                "day": ["20", "21"],
                "time": ["01:00"],
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # start date given and converted to year, month, day, time
        search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            start_datetime="2021-02-01T03:00:00Z",
        )
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "year": ["2021"],
                "month": ["02"],
                "day": ["01"],
                "time": ["03:00"],
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # no date info given -> default dates (extent.temporal.interval.0.0) which are
        # then converted to year, month, day, time
        col_conf = {
            "id": "ERA5_SL",
            "description": "ERA5 abstract",
            "instruments": [],
            "constellation": "ERA5",
            "platform": "ERA5",
            "processing:level": None,
            "keywords": [
                "ECMWF",
                "Reanalysis",
                "ERA5",
                "CDS",
                "Atmospheric",
                "land",
                "sea",
                "hourly",
                "single",
                "levels",
            ],
            "eodag:sensor_type": "ATMOSPHERIC",
            "license": "other",
            "title": "ERA5 hourly data on single levels from 1940 to present",
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["1940-01-01T00:00:00Z", None]]},
            },
            "_id": "ERA5_SL",
        }
        search_plugin.config.collection_config = dict(
            col_conf,
            **{"_collection": "ERA5_SL"},
        )
        search_plugin.query(collection="ERA5_SL", prep=PreparedSearch())
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "dataset_id": "EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )
        # collection with dates are query params -> use extent.temporal.interval.0.0 and today
        col_conf = {
            "id": "CAMS_EAC4",
            "description": "CAMS_EAC4 abstract",
            "instruments": [],
            "constellation": "CAMS",
            "platform": "CAMS",
            "processing:level": None,
            "keywords": [
                "Copernicus",
                "ADS",
                "CAMS",
                "Atmosphere",
                "Atmospheric",
                "EWMCF",
                "EAC4",
            ],
            "eodag:sensor_type": "ATMOSPHERIC",
            "license": "other",
            "title": "CAMS global reanalysis (EAC4)",
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["2003-01-01T00:00:00Z", None]]},
            },
            "_id": "CAMS_EAC4",
        }
        search_plugin.config.collection_config = dict(
            col_conf,
            **{"_collection": "CAMS_EAC4"},
        )
        search_plugin.query(collection="CAMS_EAC4", prep=PreparedSearch())
        mock_request.assert_called_with(
            "https://gateway.prod.wekeo2.eu/hda-broker/api/v1/dataaccess/search",
            json={
                "dataset_id": "EO:ECMWF:DAT:CAMS_GLOBAL_REANALYSIS_EAC4",
                "itemsPerPage": 20,
                "startIndex": 0,
            },
            headers=USER_AGENT,
            timeout=60,
            verify=True,
        )

    @mock.patch("eodag.plugins.search.qssearch.PostJsonSearch._request", autospec=True)
    def test_plugins_search_postjsonsearch_query_params_wekeo(self, mock__request):
        """A query with PostJsonSearch (here wekeo) must generate query params corresponding to the
        search criteria"""
        provider = "wekeo_ecmwf"
        collection = "GRIDDED_GLACIERS_MASS_CHANGE"
        search_plugin = self.get_search_plugin(collection, provider)
        auth_plugin = self.get_auth_plugin(search_plugin)

        mock__request.return_value = mock.Mock()

        def _test_query_params(search_criteria, raw_result, expected_query_params):
            mock__request.reset_mock()
            mock__request.return_value.json.side_effect = [raw_result]
            search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=10,
                    auth_plugin=auth_plugin,
                ),
                **search_criteria,
            )
            self.assertDictEqual(
                mock__request.call_args_list[0].args[1].query_params,
                expected_query_params,
            )

        raw_result = {
            "properties": {"itemsPerPage": 1, "startIndex": 0, "totalResults": 1},
            "features": [
                {
                    "type": "Feature",
                    "properties": {
                        "startdate": "1975-01-01T00:00:00Z",
                        "enddate": "2024-09-30T00:00:00Z",
                    },
                    "id": "derived-gridded-glacier-mass-change-576f8a153a25a83d9d3a5cfb03c4a759",
                }
            ],
        }

        # Test #1: using the datetime
        search_criteria = {
            "collection": collection,
            "start_datetime": "1980-01-01",
            "end_datetime": "1981-12-31",
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["1980_81"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)

        # Test #2: using parameter hydrological_year (single value)
        search_criteria = {
            "collection": collection,
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
            "ecmwf:hydrological_year": ["2020_21"],
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["2020_21"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)

        # Test #3: using parameter hydrological_year (multiple values)
        search_criteria = {
            "collection": collection,
            "ecmwf:variable": "glacier_mass_change",
            "ecmwf:data_format": "zip",
            "ecmwf:product_version": "wgms_fog_2022_09",
            "ecmwf:hydrological_year": ["1990_91", "2020_21"],
        }
        expected_query_params = {
            "dataset_id": "EO:ECMWF:DAT:DERIVED_GRIDDED_GLACIER_MASS_CHANGE",
            "hydrological_year": ["1990_91", "2020_21"],
            "variable": "glacier_mass_change",
            "data_format": "zip",
            "product_version": "wgms_fog_2022_09",
            "itemsPerPage": 10,
            "startIndex": 0,
        }
        _test_query_params(search_criteria, raw_result, expected_query_params)


class TestSearchPluginODataV4Search(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginODataV4Search, self).setUp()

        # manually add conf as this provider is not supported any more
        onda_config = cached_yaml_load_all(Path(TEST_RESOURCES_PATH) / "onda_conf.yml")[
            0
        ]
        self.plugins_manager.providers["onda"] = Provider(onda_config)
        self.plugins_manager.rebuild()

        # One of the providers that has a ODataV4Search Search plugin
        provider = "onda"
        self.onda_search_plugin = self.get_search_plugin(self.collection, provider)
        self.onda_auth_plugin = self.get_auth_plugin(self.onda_search_plugin)
        # Some expected results
        with open(self.provider_resp_dir / "onda_count.json") as f:
            self.onda_resp_count = json.load(f)
        self.onda_url_count = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products/$count?$search="footprint:"'
            "Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, "
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            'AND endPosition:[* TO 2020-08-16T00:00:00.000Z] AND foo:bar"'
        )
        self.onda_products_count = 47

    def test_plugins_search_odatav4search_normalize_results_onda(self):
        """ODataV4Search.normalize_results must use metada pre-mapping if configured"""

        self.assertTrue(hasattr(self.onda_search_plugin.config, "metadata_pre_mapping"))
        self.assertDictEqual(
            self.onda_search_plugin.config.metadata_pre_mapping,
            {
                "metadata_path": cached_parse("$.Metadata"),
                "metadata_path_id": "id",
                "metadata_path_value": "value",
            },
        )
        self.assertEqual(
            self.onda_search_plugin.config.discover_metadata["metadata_path"],
            "$.Metadata.*",
        )

        raw_results = [
            {"Metadata": [{"id": "foo", "value": "bar"}], "footprint": "POINT (0 0)"}
        ]
        products = self.onda_search_plugin.normalize_results(raw_results)

        self.assertEqual(products[0].properties["onda:foo"], "bar")

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        # per_product_metadata_query parameter is updated to False if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = False

        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        products = self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        # Specific expected results
        number_of_products = 2
        onda_url_search = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products?$format=json&$search="'
            'footprint:"Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, '
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            "AND endPosition:[* TO 2020-08-16T00:00:00.000Z] "
            'AND foo:bar"&$orderby=beginPosition asc&$top=2&$skip=0&$expand=Metadata'
        )

        self.assertEqual(
            mock__request.call_args_list[0].args[1].url, self.onda_url_count
        )
        self.assertEqual(mock__request.call_args_list[1].args[1].url, onda_url_search)

        self.assertEqual(products.number_matched, self.onda_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count non extracted from search results as count endpoint is specified
        self.assertFalse(hasattr(self.onda_search_plugin, "total_items_nb"))

    @mock.patch("eodag.plugins.search.qssearch.get_ssl_context", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.Request", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.urlopen", autospec=True)
    @mock.patch("eodag.plugins.search.qssearch.cast", autospec=True)
    def test_plugins_search_odatav4search_with_ssl_context(
        self, mock_cast, mock_urlopen, mock_request, mock_get_ssl_context
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products"""  # noqa
        self.onda_search_plugin.config.ssl_verify = False
        mock_cast.return_value.json.return_value = 2

        ssl_ctx = ssl.create_default_context()
        ssl_ctx.check_hostname = False
        ssl_ctx.verify_mode = ssl.CERT_NONE

        mock_request.return_value = mock.Mock()

        # Mocking return value of get_ssl_context
        mock_get_ssl_context.return_value = ssl_ctx

        self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )

        mock_get_ssl_context.assert_called_with(False)

        # # Asserting that get_ssl_context has been called
        self.assertEqual(mock_get_ssl_context.call_count, 2)

        # Asserting that urlopen has been called with the correct arguments
        mock_urlopen.assert_called_with(
            mock_request.return_value, timeout=60, context=ssl_ctx
        )

        del self.onda_search_plugin.config.ssl_verify

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda_per_product_metadata_query(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must return tuple with a list of EOProduct and a number of available products for query per product metadata"""  # noqa
        # per_product_metadata_query parameter is updated to True if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if not per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = True

        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        products = self.onda_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth=self.onda_auth_plugin,
            ),
            # custom query argument that must be mapped using discovery_metata.search_param
            foo="bar",
            **self.search_criteria_s2_msi_l1c,
        )
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        # we check request arguments of the last call
        metadata_url = "{}({})/Metadata".format(
            self.onda_search_plugin.config.api_endpoint.rstrip("/"),
            products.data[1].properties["uid"],
        )
        mock_requests_get.assert_called_with(
            metadata_url, headers=USER_AGENT, timeout=HTTP_REQ_TIMEOUT, verify=True
        )
        # we check that two requests have been called, one per product
        self.assertEqual(mock_requests_get.call_count, 2)

        # Specific expected results
        number_of_products = 2
        onda_url_search = (
            'https://catalogue.onda-dias.eu/dias-catalogue/Products?$format=json&$search="'
            'footprint:"Intersects(POLYGON ((137.7729 13.1342, 137.7729 23.8860, 153.7491 23.8860, 153.7491 13.1342, '
            '137.7729 13.1342)))" AND productType:S2MSI1C AND beginPosition:[2020-08-08T00:00:00.000Z TO *] '
            "AND endPosition:[* TO 2020-08-16T00:00:00.000Z] "
            'AND foo:bar"&$orderby=beginPosition asc&$top=2&$skip=0&$expand=Metadata'
        )

        self.assertEqual(
            mock__request.call_args_list[0].args[1].url, self.onda_url_count
        )
        self.assertEqual(mock__request.call_args_list[1].args[1].url, onda_url_search)

        self.assertEqual(products.number_matched, self.onda_products_count)
        self.assertEqual(len(products.data), number_of_products)
        self.assertIsInstance(products.data[0], EOProduct)
        # products count non extracted from search results as count endpoint is specified
        self.assertFalse(hasattr(self.onda_search_plugin, "total_items_nb"))

    @mock.patch("eodag.plugins.search.qssearch.requests.get", autospec=True)
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_count_and_search_onda_per_product_metadata_query_request_error(
        self, mock__request, mock_requests_get
    ):
        """A query with a ODataV4Search (here onda) must handle requests errors for query per product metadata"""  # noqa
        # per_product_metadata_query parameter is updated to True if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if not per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = True
        with open(self.provider_resp_dir / "onda_search.json") as f:
            onda_resp_search = json.load(f)
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            self.onda_resp_count,
            onda_resp_search,
        ]
        mock_requests_get.return_value = mock.Mock()
        # Mock requests.get in ODataV4Search.do_search that sends a request per product
        # obtained by QueryStringSearch.do_search to retrieve its metadata.
        # Just use dummy metadata here for the test.
        mock_requests_get.return_value.json.return_value = dict(
            value=[dict(id="dummy_metadata", value="dummy_metadata_val")]
        )
        mock_requests_get.side_effect = RequestException()

        with self.assertLogs(level="ERROR") as cm:
            self.onda_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth=self.onda_auth_plugin,
                ),
                # custom query argument that must be mapped using discovery_metata.search_param
                foo="bar",
                **self.search_criteria_s2_msi_l1c,
            )
            # restore per_product_metadata_query initial value if it is necessary
            if (
                self.onda_search_plugin.config.per_product_metadata_query
                != per_product_metadata_query
            ):
                self.onda_search_plugin.config.per_product_metadata_query = (
                    per_product_metadata_query
                )
            search_provider = self.onda_auth_plugin.provider
            search_type = self.onda_search_plugin.__class__.__name__
            error_message = f"Skipping error while searching for {search_provider} {search_type} instance"
            error_message_indexes_list = [
                i.start() for i in re.finditer(error_message, str(cm.output))
            ]
            # we check that two errors have been logged, one per product
            self.assertEqual(len(error_message_indexes_list), 2)

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_search_cloudcover_onda(
        self, mock__request, mock_normalize_results
    ):
        """A query with a ODataV4Search (here onda) must only use cloudCover filtering for non-radar collections"""
        # per_product_metadata_query parameter is updated to False if it is necessary
        per_product_metadata_query = (
            self.onda_search_plugin.config.per_product_metadata_query
        )
        if per_product_metadata_query:
            self.onda_search_plugin.config.per_product_metadata_query = False

        self.onda_search_plugin.query(collection="S2_MSI_L1C", cloudCover=50)
        # restore per_product_metadata_query initial value if it is necessary
        if (
            self.onda_search_plugin.config.per_product_metadata_query
            != per_product_metadata_query
        ):
            self.onda_search_plugin.config.per_product_metadata_query = (
                per_product_metadata_query
            )

        mock__request.assert_called()
        self.assertIn(
            "cloudCoverPercentage", mock__request.call_args_list[-1][0][1].url
        )
        mock__request.reset_mock()

        self.onda_search_plugin.query(collection="S1_SAR_GRD", cloudCover=50)
        mock__request.assert_called()
        self.assertNotIn(
            "cloudCoverPercentage", mock__request.call_args_list[-1][0][1].url
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_odatav4search_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for ODataV4Search should not mix specific collections metadata-mapping"""
        wkt_geometry = self.search_criteria_s2_msi_l1c["geometry"].wkt
        mock__request.return_value = mock.Mock()
        result = {
            "value": [
                {
                    "id": "foo",
                    "footprint": wkt_geometry,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [5, result, 5, result]
        search_plugin = self.get_search_plugin(provider="onda")

        # update metadata_mapping only for S1_SAR_GRD
        search_plugin.config.products["S1_SAR_GRD"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "bar", search_plugin.config.products["S1_SAR_SLC"]["metadata_mapping"]
        )
        products = search_plugin.query(
            collection="S1_SAR_SLC",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)


class TestSearchPluginStacSearch(BaseSearchPluginTest):
    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_mapping_earthsearch(self, mock__request):
        """The metadata mapping for earth_search should return well formatted results"""  # noqa

        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20201009T012345_N0209_R008_T31TCJ_20201009T123456.SAFE",
                        },
                    },
                    {
                        "id": "bar",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20200910T012345_N0209_R008_T31TCJ_20200910T123456.SAFE",
                        },
                    },
                    {
                        "id": "bar",
                        "geometry": geojson_geometry,
                        "properties": {
                            "s2:product_uri": "S2B_MSIL1C_20201010T012345_N0209_R008_T31TCJ_20201010T123456.SAFE",
                        },
                    },
                ],
            },
        ]

        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        products = search_plugin.query(
            prep=PreparedSearch(page=1, limit=2),
            **self.search_criteria_s2_msi_l1c,
        )
        self.assertEqual(
            products[0].properties["eodag:product_path"],
            "products/2020/10/9/S2B_MSIL1C_20201009T012345_N0209_R008_T31TCJ_20201009T123456",
        )
        self.assertEqual(
            products[1].properties["eodag:product_path"],
            "products/2020/9/10/S2B_MSIL1C_20200910T012345_N0209_R008_T31TCJ_20200910T123456",
        )
        self.assertEqual(
            products[2].properties["eodag:product_path"],
            "products/2020/10/10/S2B_MSIL1C_20201010T012345_N0209_R008_T31TCJ_20201010T123456",
        )

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_default_geometry(self, mock__request):
        """The metadata mapping for a stac provider should return a default geometry"""

        geojson_geometry = self.search_criteria_s2_msi_l1c["geometry"].__geo_interface__

        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": geojson_geometry,
                    },
                    {
                        "id": "bar",
                        "geometry": None,
                    },
                ],
            },
        ]

        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        products = search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=3,
            )
        )
        self.assertEqual(
            products.data[0].geometry, self.search_criteria_s2_msi_l1c["geometry"]
        )
        self.assertEqual(products[1].geometry.bounds, (-180.0, -90.0, 180.0, 90.0))

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    @mock.patch(
        "eodag.api.product.drivers.base.DatasetDriver.guess_asset_key_and_roles",
        autospec=True,
    )
    @mock.patch.dict(QueryStringSearch.extract_properties, {"json": mock.MagicMock()})
    def test_plugins_search_stacsearch_normalize_asset_key_from_href(
        self, mock_guess_asset_key_and_roles, mock_geodes_search_request
    ):
        """normalize_results must guess asset key from href if asset_key_from_href is set to True"""

        mock_properties_from_json = QueryStringSearch.extract_properties["json"]
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "foo": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                },
            },
        }
        mock_guess_asset_key_and_roles.return_value = ("normalized_key", ["some_role"])

        # guess asset key from href
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        self.assertFalse(hasattr(search_plugin.config, "asset_key_from_href"))
        products = search_plugin.normalize_results([{}])
        mock_guess_asset_key_and_roles.assert_called_once_with(
            products[0].driver, "https://example.com/foo", products[0]
        )
        self.assertEqual(len(products[0].assets), 1)
        self.assertEqual(products[0].assets["normalized_key"]["roles"], ["some_role"])

        mock_guess_asset_key_and_roles.reset_mock()
        # guess asset key from origin key
        search_plugin = self.get_search_plugin(self.collection, "geodes")
        self.assertEqual(search_plugin.config.asset_key_from_href, False)
        products = search_plugin.normalize_results([{}])
        mock_guess_asset_key_and_roles.assert_called_once_with(
            products[0].driver, "foo", products[0]
        )
        self.assertEqual(len(products[0].assets), 1)
        self.assertEqual(products[0].assets["normalized_key"]["roles"], ["some_role"])
        # title is also set using normlized key
        self.assertEqual(
            products[0].assets["normalized_key"]["title"], "normalized_key"
        )

        mock_guess_asset_key_and_roles.reset_mock()
        # no-extension href: driver returns (None, None), original key and roles are kept
        mock_guess_asset_key_and_roles.return_value = (None, None)
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "some-asset": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                },
            },
        }
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        products = search_plugin.normalize_results([{}])
        self.assertEqual(len(products[0].assets), 1)
        self.assertIn("some-asset", products[0].assets)
        self.assertEqual(products[0].assets["some-asset"]["roles"], ["bar"])
        self.assertEqual(products[0].assets["some-asset"]["title"], "some-asset")

        mock_guess_asset_key_and_roles.reset_mock()
        # no-extension href with existing title: original title is preserved
        mock_guess_asset_key_and_roles.return_value = (None, None)
        mock_properties_from_json.return_value = {
            "geometry": "POINT (0 0)",
            "assets": {
                "some-asset": {
                    "href": "https://example.com/foo",
                    "roles": ["bar"],
                    "title": "My custom title",
                },
            },
        }
        search_plugin = self.get_search_plugin(self.collection, "earth_search")
        products = search_plugin.normalize_results([{}])
        self.assertEqual(len(products[0].assets), 1)
        self.assertIn("some-asset", products[0].assets)
        self.assertEqual(products[0].assets["some-asset"]["title"], "My custom title")

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_stacsearch_opened_time_intervals(self, mock_requests_post):
        """Opened time intervals must be handled by StacSearch plugin"""
        mock_requests_post.return_value = mock.Mock()
        mock_requests_post.return_value.json.side_effect = [
            {
                "features": [
                    {
                        "id": "foo",
                        "geometry": None,
                    },
                ],
            },
        ] * 4
        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        search_plugin.query(
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "2020-01-01T00:00:00.000Z/2020-01-02T00:00:00.000Z",
        )

        search_plugin.query(start_datetime="2020-01-01")
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "2020-01-01T00:00:00.000Z/..",
        )

        search_plugin.query(end_datetime="2020-01-02")
        self.assertEqual(
            mock_requests_post.call_args.kwargs["json"]["datetime"],
            "../2020-01-02T00:00:00.000Z",
        )

        search_plugin.query()
        self.assertNotIn("datetime", mock_requests_post.call_args.kwargs["json"])

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_distinct_collection_mtd_mapping(
        self, mock__request
    ):
        """The metadata mapping for a stac provider should not mix specific collections metadata-mapping"""
        mock__request.return_value = mock.Mock()
        result = {
            "features": [
                {
                    "id": "foo",
                    "geometry": None,
                },
            ],
        }
        mock__request.return_value.json.side_effect = [result, result]
        search_plugin = self.get_search_plugin(self.collection, "earth_search")

        # update metadata_mapping only for S2_MSI_L1C
        search_plugin.config.products["S2_MSI_L1C"]["metadata_mapping"]["bar"] = (
            None,
            "baz",
        )
        products = search_plugin.query(
            collection="S2_MSI_L1C",
            auth=None,
        )
        self.assertIn("bar", products.data[0].properties)
        self.assertEqual(products.data[0].properties["bar"], "baz")

        # search with another collection
        self.assertNotIn(
            "metadata_mapping", search_plugin.config.products["S1_SAR_GRD"]
        )
        products = search_plugin.query(
            collection="S1_SAR_GRD",
            auth=None,
        )
        self.assertNotIn("bar", products.data[0].properties)

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_distinct_collection_mtd_mapping_earth_search(
        self, mock__request
    ):
        """The metadata mapping for a earth_search should correctly build tileIdentifier"""
        mock__request.return_value = mock.Mock()
        result = {
            "features": [
                {
                    "id": "foo",
                    "geometry": None,
                    "properties": {
                        "mgrs:utm_zone": "31",
                        "mgrs:latitude_band": "T",
                        "mgrs:grid_square": "CJ",
                    },
                },
            ],
        }
        collection = "S2_MSI_L1C"
        mock__request.return_value.json.side_effect = [result]
        search_plugin = self.get_search_plugin(collection, "earth_search")

        products = search_plugin.query(
            collection=collection,
            auth=None,
        )
        self.assertIn("grid:code", products.data[0].properties)
        self.assertEqual(
            products.data[0].properties["grid:code"],
            "MGRS-31TCJ",
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables(self, mock_request):
        provider_queryables = {
            "type": "object",
            "title": "Querable",
            "properties": {
                "dataset_id": {
                    "title": "dataset_id",
                    "type": "string",
                    "oneOf": [
                        {
                            "const": "EO:ESA:DAT:COP-DEM",
                            "title": "EO:ESA:DAT:COP-DEM",
                            "group": None,
                        }
                    ],
                },
                "bbox": {
                    "title": "Bbox",
                    "type": "array",
                    "minItems": 4,
                    "maxItems": 4,
                    "items": [
                        {"type": "number", "maximum": 180, "minimum": -180},
                        {"type": "number", "maximum": 90, "minimum": -90},
                        {"type": "number", "maximum": 180, "minimum": -180},
                        {"type": "number", "maximum": 90, "minimum": -90},
                    ],
                },
                "productIdentifier": {
                    "title": "Product Identifier",
                    "type": "string",
                    "pattern": "^[a-zA-Z0-9]+$",
                },
                "collection": {
                    "title": "Collection",
                    "type": "string",
                    "oneOf": [
                        {"const": "DGE_30", "title": "DGE_30", "group": None},
                        {"const": "DGE_90", "title": "DGE_90", "group": None},
                        {"const": "DTE_30", "title": "DTE_30", "group": None},
                        {"const": "DTE_90", "title": "DTE_90", "group": None},
                    ],
                },
                "startdate": {
                    "title": "Start Date",
                    "type": "string",
                    "format": "date-time",
                    "minimum": "",
                    "maximum": "",
                    "default": "",
                },
                "enddate": {
                    "title": "End Date",
                    "type": "string",
                    "format": "date-time",
                    "minimum": "",
                    "maximum": "",
                    "default": "",
                },
            },
            "required": ["dataset_id"],
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="wekeo_main")
        queryables = plugin.discover_queryables(
            collection="COP_DEM_GLO90_DGED", provider="wekeo_main"
        )
        self.assertIn("collection", queryables)
        self.assertIn("geom", queryables)
        self.assertIn("start", queryables)
        self.assertIn("end", queryables)

    @mock.patch("eodag.plugins.search.qssearch.StacSearch._request", autospec=True)
    def test_plugins_search_stacsearch_unparsed_query_parameters(self, mock__request):
        """search_param_unparsed should pass query params as-is to the provider"""
        result = {"features": []}
        mock__request.return_value = mock.Mock()
        mock__request.return_value.json.side_effect = [result]
        search_plugin = self.get_search_plugin(provider="earth_search")
        search_plugin.query(query={"foo": "bar", "baz": "qux"})

        self.assertTrue(mock__request.called)
        called_args, _ = mock__request.call_args
        prepared_search = called_args[1]
        self.assertEqual(
            prepared_search.query_params["query"], {"foo": "bar", "baz": "qux"}
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_dedl_discover_queryables(self, mock_request):
        """
        Test the discovery and parsing of queryables from the DEDL provider.

        This test verifies that:
        - The "ecmwf:time" field is correctly interpreted as an annotated list with a
        single literal value "00:00".
        - The "start" field is interpreted as an annotated union of datetime and date types.
        - The "geom" field is interpreted as an annotated union that includes a string,
        a dictionary with string keys and float values, and subclasses of BaseGeometry.

        The test mocks the _request method of the plugin to simulate a response with
        predefined queryables, then verifies the correctness of the resulting type annotations.
        """
        provider_queryables = {
            "$schema": "https://json-schema.org/draft/2019-09/schema",
            "type": "object",
            "title": "Queryables for EODAG STAC API",
            "description": "Queryable names for the EODAG STAC API Item Search filter. ",
            "properties": {
                "ecmwf:variable": {
                    "default": "10m_u_component_of_wind",
                    "items": {
                        "enum": [
                            "10m_u_component_of_wind",
                            "10m_v_component_of_wind",
                            "2m_dewpoint_temperature",
                            "2m_temperature",
                        ],
                        "type": "string",
                    },
                    "title": "variable",
                    "type": "array",
                },
                "ecmwf:pressure_level": {
                    "items": {},
                    "title": "pressure_level",
                    "type": "array",
                },
                "ecmwf:time": {
                    "default": "00:00",
                    "description": "Model base time as HH:MM (UTC)",
                    "items": {"const": "00:00", "type": "string"},
                    "title": "time",
                    "type": "array",
                },
                "start_datetime": {
                    "anyOf": [
                        {"format": "date-time", "type": "string"},
                        {"format": "date", "type": "string"},
                    ],
                    "default": "2015-01-01T00:00:00Z",
                    "title": "start_datetime",
                },
                "end_datetime": {
                    "anyOf": [
                        {"format": "date-time", "type": "string"},
                        {"format": "date", "type": "string"},
                    ],
                    "default": "2015-01-01T00:00:00Z",
                    "title": "end_datetime",
                },
                "geometry": {
                    "description": "Geometry",
                    "ref": "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/geometry",
                },
                "bbox": {
                    "description": "BBox",
                    "type": "array",
                    "oneOf": [
                        {"minItems": 4, "maxItems": 4},
                        {"minItems": 6, "maxItems": 6},
                    ],
                    "items": {"type": "number"},
                },
            },
            "required": [
                "ecmwf:variable",
                "ecmwf:time",
                "start_datetime",
                "geometry",
                "bbox",
            ],
            "additionalProperties": False,
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables_dedl = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # Check that "ecmwf:time" has type Annotated[list[Literal['00:00']], ...]
        self.assertIn("ecmwf_time", queryables_dedl)
        annotated_type = queryables_dedl["ecmwf_time"]
        args = get_args(annotated_type)
        base_type = args[0]
        self.assertEqual(get_origin(base_type), list)
        literal_args = get_args(base_type)
        self.assertEqual(literal_args, (Literal["00:00"],))

        # Check that "start" has type Annotated[str, ...]
        self.assertIn("start", queryables_dedl)
        annotated_type = queryables_dedl["start"]
        args = get_args(annotated_type)
        self.assertEqual(args[0], str)

        # Check that "geom" has type Annotated[Union[str, dict[str, float], BaseGeometry], ...]
        self.assertIn("geom", queryables_dedl)
        annotated_type = queryables_dedl["geom"]
        args = get_args(annotated_type)
        base_type = args[0]
        self.assertEqual(get_origin(base_type), Union)
        union_args = get_args(base_type)
        self.assertIn(str, union_args)
        self.assertTrue(any(get_origin(arg) is dict for arg in union_args))
        self.assertTrue(
            any(
                issubclass(arg, BaseGeometry)
                for arg in union_args
                if isinstance(arg, type)
            )
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_merge(self, mock_request):
        """discover_queryables must merge provider and eodag queryable types, aliases, and attributes"""
        from pydantic import AliasChoices

        provider_queryables = {
            "type": "object",
            "properties": {
                # no constrained type: eodag's str type expected
                "start_datetime": {
                    "title": "Start datetime",
                    "type": "string",
                    "format": "date-time",
                    "description": "provider start description",
                },
                # simple object: should get eodag's Union type (no Literal constraint)
                "geometry": {
                    "title": "Geometry",
                    "description": "provider geometry description",
                },
                # not in eodag queryables: kept as-is from provider
                "some_provider_param": {
                    "title": "Some param",
                    "type": "integer",
                    "description": "provider-only param",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # 1. "start" should be present (mapped from "start_datetime" via alias)
        self.assertIn("start", queryables)
        start_args = get_args(queryables["start"])
        start_type, start_fi = start_args[0], start_args[1]
        # type should be eodag's str (no Literal from provider)
        self.assertEqual(start_type, str)
        # alias from eodag should be preserved (AliasChoices with "start_datetime")
        self.assertIsInstance(start_fi.alias, AliasChoices)
        self.assertIn("start_datetime", start_fi.alias.choices)
        # non-empty attributes from provider
        self.assertEqual(start_fi.description, "provider start description")
        self.assertEqual(start_fi.title, "Start datetime")

        # 2. "geom" should be present (mapped from "geometry" via alias)
        self.assertIn("geom", queryables)
        geom_args = get_args(queryables["geom"])
        geom_type, geom_fi = geom_args[0], geom_args[1]
        # type should be eodag's Union (provider has no constraints)
        self.assertEqual(get_origin(geom_type), Union)
        # alias from eodag should be preserved
        self.assertIsInstance(geom_fi.alias, AliasChoices)
        self.assertIn("geometry", geom_fi.alias.choices)
        # non-empty attributes from provider
        self.assertEqual(geom_fi.description, "provider geometry description")
        self.assertEqual(geom_fi.title, "Geometry")

        # 3. "end" should be present (added from "datetime" split)
        #    "datetime" isn't in provider_queryables, so "end" won't be auto-added
        self.assertNotIn("end", queryables)
        self.assertNotIn("datetime", queryables)

        # 4. check "some_provider_param" is kept as-is
        self.assertIn("some_provider_param", queryables)
        sp_args = get_args(queryables["some_provider_param"])
        sp_type, sp_fi = sp_args[0], sp_args[1]
        self.assertEqual(sp_type, int)
        # non-empty attributes from provider
        self.assertEqual(sp_fi.description, "provider-only param")
        self.assertEqual(sp_fi.title, "Some param")

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_no_pattern_propagation(
        self, mock_request
    ):
        """discover_queryables must not propagate provider `pattern` or other
        constraints onto eodag queryables, which are parsed/formatted later.
        """
        provider_queryables = {
            "type": "object",
            "properties": {
                # mimics planetary-computer strict ISO UTC pattern
                "start_datetime": {
                    "title": "Start datetime",
                    "type": "string",
                    "format": "date-time",
                    "pattern": "(\\+00:00|Z)$",
                },
                "end_datetime": {
                    "title": "End datetime",
                    "type": "string",
                    "format": "date-time",
                    "pattern": "(\\+00:00|Z)$",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # "start" / "end" must be present and carry no `pattern` metadata
        for key in ("start", "end"):
            self.assertIn(key, queryables)
            field_type, field_info = get_args(queryables[key])[:2]
            self.assertEqual(field_type, str)
            patterns = [
                getattr(m, "pattern", None)
                for m in field_info.metadata
                if getattr(m, "pattern", None)
                and getattr(m, "pattern", None) is not PydanticUndefined
            ]
            self.assertEqual(
                patterns,
                [],
                f"unexpected pattern metadata propagated to `{key}`: {patterns}",
            )

        # build the pydantic model from these queryables and validate
        model = QueryablesDict(**queryables).get_model()
        model.model_validate(
            {
                "collection": "CAMS_GAC_FORECAST",
                "start_datetime": "2024-01-01",
                "end_datetime": "2024-01-31",
            }
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_stacsearch_discover_queryables_datetime_split(
        self, mock_request
    ):
        """discover_queryables must split provider 'datetime' into 'start' and 'end'"""
        from pydantic import AliasChoices

        provider_queryables = {
            "type": "object",
            "properties": {
                "datetime": {
                    "title": "Datetime",
                    "type": "string",
                    "format": "date-time",
                },
                "some_param": {
                    "title": "Some param",
                    "type": "string",
                },
            },
        }
        mock_request.return_value = mock.Mock()
        mock_request.return_value.json.side_effect = [provider_queryables]
        plugin = self.get_search_plugin(provider="dedl")
        queryables = plugin.discover_queryables(
            collection="CAMS_GAC_FORECAST", provider="dedl"
        )

        # "datetime" itself must not appear in queryables
        self.assertNotIn("datetime", queryables)

        # "start" and "end" must be added from eodag definitions
        self.assertIn("start", queryables)
        self.assertIn("end", queryables)

        # check "start" has the expected eodag alias
        start_args = get_args(queryables["start"])
        start_fi = start_args[1]
        self.assertIsInstance(start_fi.alias, AliasChoices)
        self.assertIn("start_datetime", start_fi.alias.choices)

        # check "end" has the expected eodag alias
        end_args = get_args(queryables["end"])
        end_fi = end_args[1]
        self.assertEqual(end_fi.alias, "end_datetime")

        # provider-only param still present
        self.assertIn("some_param", queryables)

    def test_plugins_search_stacsearch_missing_results_entry_misconfigured(self):
        """StacSearch must raise MisconfiguredError when results_entry is missing
        for a subclass not referenced in STAC_SEARCH_PLUGINS"""
        from eodag.config import PluginConfig
        from eodag.plugins.search.qssearch import StacSearch

        class _UnregisteredStacSearch(StacSearch):
            pass

        config = PluginConfig()
        # do not set results_entry on purpose
        with self.assertRaises(MisconfiguredError) as ctx:
            _UnregisteredStacSearch("some_provider", config)
        self.assertIn("results_entry", str(ctx.exception))
        self.assertIn("_UnregisteredStacSearch", str(ctx.exception))
        self.assertIn("STAC_SEARCH_PLUGINS", str(ctx.exception))

    def test_plugins_search_stacsearch_missing_results_entry_reraises_for_known_plugin(
        self,
    ):
        """StacSearch must re-raise AttributeError when results_entry is missing
        for a subclass referenced in STAC_SEARCH_PLUGINS"""
        from eodag.config import PluginConfig
        from eodag.plugins.search.geodes import GeodesSearch

        config = PluginConfig()
        # GeodesSearch is in STAC_SEARCH_PLUGINS so AttributeError must be re-raised
        with self.assertRaises(AttributeError):
            GeodesSearch("geodes", config)


class TestSearchPluginGeodesSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginGeodesSearch, self).setUp()
        self.provider = "geodes"
        self.search_plugin = self.get_search_plugin(provider=self.provider)

    def test_plugins_search_geodes_plugin_class(self):
        """The geodes provider must use the GeodesSearch plugin"""
        from eodag.plugins.search.geodes import GeodesSearch

        self.assertIsInstance(self.search_plugin, GeodesSearch)

    def _build_product(
        self, identifier, checksum, endpoint_url="https://geodes.example/data"
    ):
        download_link = f"https://geodes.example/data/{identifier}/file_{checksum}.tif"
        return EOProduct(
            self.provider,
            {
                "id": identifier,
                "geometry": "POINT (0 0)",
                "eodag:download_link": download_link,
                "geodes:endpoint_url": endpoint_url,
            },
        )

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    def test_plugins_search_geodes_get_availability(self, mock__request):
        """_get_availability must POST to the fastavailability endpoint with the
        download_link and endpoint_url for each product"""
        mock_response = mock.Mock()
        mock_response.json.return_value = {"products": []}
        mock__request.return_value = mock_response

        p1 = self._build_product("PROD1", "abc")
        p2 = self._build_product("PROD2", "def")

        result = self.search_plugin._get_availability([p1, p2])

        self.assertEqual(result, {"products": []})
        self.assertEqual(mock__request.call_count, 1)
        prep = mock__request.call_args.args[1]
        # url must be derived from api_endpoint (replacing api/stac/search by fastavailability)
        self.assertEqual(
            prep.url,
            self.search_plugin.config.api_endpoint.replace(
                "api/stac/search", "fastavailability"
            ),
        )
        self.assertEqual(
            prep.query_params,
            {
                "availability": [
                    {
                        "href": p1.properties["eodag:download_link"],
                        "endpointURL": "https://geodes.example/data",
                    },
                    {
                        "href": p2.properties["eodag:download_link"],
                        "endpointURL": "https://geodes.example/data",
                    },
                ]
            },
        )

    @mock.patch("eodag.plugins.search.geodes.GeodesSearch._request", autospec=True)
    def test_plugins_search_geodes_get_availability_skips_missing_fields(
        self, mock__request
    ):
        """Products without eodag:download_link or geodes:endpoint_url must be skipped
        in the availability request body"""
        mock_response = mock.Mock()
        mock_response.json.return_value = {"products": []}
        mock__request.return_value = mock_response

        p_ok = self._build_product("PROD1", "abc")
        p_no_url = EOProduct(
            self.provider,
            {
                "id": "PROD2",
                "geometry": "POINT (0 0)",
                "eodag:download_link": "https://geodes.example/data/PROD2/file.tif",
            },
        )
        p_no_link = EOProduct(
            self.provider,
            {
                "id": "PROD3",
                "geometry": "POINT (0 0)",
                "geodes:endpoint_url": "https://geodes.example/data",
            },
        )

        self.search_plugin._get_availability([p_ok, p_no_url, p_no_link])

        prep = mock__request.call_args.args[1]
        self.assertEqual(len(prep.query_params["availability"]), 1)
        self.assertEqual(
            prep.query_params["availability"][0]["href"],
            p_ok.properties["eodag:download_link"],
        )

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_online(self, mock_get_availability):
        """_set_availability must set order:status to ONLINE when asset is available"""
        from eodag.api.product.metadata_mapping import ONLINE_STATUS

        product = self._build_product("PROD1", "abc")
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [{"checksum": "abc", "available": True}],
                }
            ]
        }

        self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], ONLINE_STATUS)

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_offline(
        self, mock_get_availability
    ):
        """_set_availability must set order:status to OFFLINE when asset is not available"""
        from eodag.api.product.metadata_mapping import OFFLINE_STATUS

        product = self._build_product("PROD1", "abc")
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [{"checksum": "abc", "available": False}],
                }
            ]
        }

        self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], OFFLINE_STATUS)

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_no_match(
        self, mock_get_availability
    ):
        """When no matching product/asset is found in the availability response,
        order:status must be left unchanged and a warning must be logged"""
        product = self._build_product("PROD1", "abc")
        product.properties["order:status"] = "untouched"
        # no matching id in response
        mock_get_availability.return_value = {"products": []}

        with self.assertLogs("eodag.search.geodes", level="WARNING") as log_ctx:
            self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], "untouched")
        self.assertTrue(
            any("Could not update availability" in m for m in log_ctx.output)
        )

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._get_availability", autospec=True
    )
    def test_plugins_search_geodes_set_availability_ambiguous_asset(
        self, mock_get_availability
    ):
        """Products whose checksum matches more than one file must be skipped"""
        product = self._build_product("PROD1", "abc")
        product.properties["order:status"] = "untouched"
        mock_get_availability.return_value = {
            "products": [
                {
                    "id": "PROD1",
                    "files": [
                        {"checksum": "abc", "available": True},
                        {"checksum": "abc", "available": False},
                    ],
                }
            ]
        }

        with self.assertLogs("eodag.search.geodes", level="WARNING"):
            self.search_plugin._set_availability([product])

        self.assertEqual(product.properties["order:status"], "untouched")

    @mock.patch(
        "eodag.plugins.search.geodes.GeodesSearch._set_availability", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.normalize_results", autospec=True
    )
    def test_plugins_search_geodes_normalize_results_calls_set_availability(
        self, mock_super_normalize, mock_set_availability
    ):
        """normalize_results must delegate to StacSearch.normalize_results and
        then call _set_availability with the resulting products"""
        product = self._build_product("PROD1", "abc")
        mock_super_normalize.return_value = [product]

        results = self.search_plugin.normalize_results([{}])

        self.assertEqual(results, [product])
        mock_set_availability.assert_called_once_with(self.search_plugin, [product])

    @responses.activate
    def test_plugins_search_geodes_normalize(self):
        """normalize_results must shorten asset keys and parse the asset
        ``description`` field into structured ``geodes:*`` / ``file:*`` properties."""

        # Mock availability (content irrelevant for this test)
        responses.add(
            responses.POST,
            "https://geodes-portal.cnes.fr/fastavailability",
            json={"products": []},
            status=200,
        )

        with open(self.provider_resp_dir / "geodes_search.json", encoding="utf-8") as f:
            raw_features = json.load(f)["features"]
        raw_assets = raw_features[0]["assets"]
        quicklook_asset = raw_assets[
            "2025/04/02/S2A/S2A_MSIL1C_20250402T175741_N0511_R141_T14ULD_20250403T022035_quicklook.jpg"
        ]
        zip_asset = raw_assets[
            "S2A_MSIL1C_20250402T175741_N0511_R141_T14ULD_20250403T022035.zip"
        ]

        search_plugin = self.get_search_plugin(provider="geodes")
        normalized = search_plugin.normalize_results(RawSearchResult(raw_features))

        self.assertEqual(len(normalized), 1)
        self.assertDictEqual(
            dict(normalized[0].assets),
            {
                "quicklook.jpg": {
                    "href": quicklook_asset["href"],
                    "title": "quicklook.jpg",
                    "description": quicklook_asset["description"],
                    "type": "image/jpeg",
                    "roles": ["overview"],
                    "file:size": 18684,
                    "geodes:reference": False,
                    "geodes:online": True,
                    "geodes:datatype": "QUICKLOOK_SD",
                    "file:checksum": "1003ae6b1edf05adf7c46cb759ffeaec",
                },
                "zip": {
                    "href": zip_asset["href"],
                    "title": "zip",
                    "description": zip_asset["description"],
                    "type": "application/zip",
                    "roles": ["auxiliary"],
                    "file:size": 764210185,
                    "geodes:reference": False,
                    "geodes:online": False,
                    "geodes:datatype": "RAWDATA",
                    "file:checksum": "86f828c4c7e921615d1cd0476604f780",
                },
            },
        )


class TestSearchPluginMeteoblueSearch(BaseSearchPluginTest):
    @mock.patch("eodag.plugins.authentication.qsauth.requests.get", autospec=True)
    def setUp(self, mock_requests_get):
        super(TestSearchPluginMeteoblueSearch, self).setUp()
        # enable long diffs in test reports
        self.maxDiff = None
        # One of the providers that has a MeteoblueSearch Search plugin
        provider = "meteoblue"
        self.search_plugin = self.get_search_plugin(provider=provider)
        self.auth_plugin = self.get_auth_plugin(self.search_plugin)
        self.auth_plugin.config.credentials = {"cred": "entials"}
        self.auth = self.auth_plugin.authenticate()

    @mock.patch("eodag.plugins.search.qssearch.requests.post", autospec=True)
    def test_plugins_search_buildpostsearchresult_count_and_search(
        self, mock_requests_post
    ):
        """A query with a MeteoblueSearch must return a single result"""

        # custom query for meteoblue
        custom_query = {"queries": {"foo": "bar"}}
        collection_config = {"platform": "NEMSGLOBAL", "alias": "THE.ALIAS"}
        setattr(self.search_plugin.config, "collection_config", collection_config)
        products = self.search_plugin.query(
            prep=PreparedSearch(auth_plugin=self.auth_plugin, auth=self.auth),
            **custom_query,
        )

        mock_requests_post.assert_called_with(
            self.search_plugin.config.api_endpoint,
            json=mock.ANY,
            headers=USER_AGENT,
            timeout=DEFAULT_SEARCH_TIMEOUT,
            auth=self.auth,
            verify=True,
        )
        self.assertEqual(products.number_matched, 1)
        self.assertIsInstance(products.data[0], EOProduct)
        endpoint = "https://my.meteoblue.com/dataset/query"
        default_geom = {
            "coordinates": [
                [[180, -90], [180, 90], [-180, 90], [-180, -90], [180, -90]]
            ],
            "type": "Polygon",
        }
        # check eodag:download_link
        self.assertEqual(
            products.data[0].properties["eodag:download_link"],
            f"{endpoint}?" + json.dumps({"geometry": default_geom, **custom_query}),
        )
        # check eodag:order_link
        self.assertEqual(
            products.data[0].properties["eodag:order_link"],
            f"{endpoint}?"
            + json.dumps(
                {
                    "geometry": default_geom,
                    "runOnJobQueue": True,
                    **custom_query,
                }
            ),
        )


class MockResponse:
    def __init__(self, json_data, status_code):
        self.json_data = json_data
        self.status_code = status_code

    def json(self):
        return self.json_data

    def raise_for_status(self):
        if self.status_code != 200:
            raise RequestError


class TestSearchPluginCreodiasS3Search(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCreodiasS3Search, self).setUp()
        self.provider = "creodias_s3"

    @mock.patch(
        "eodag.plugins.authentication.aws_auth.AwsAuth.get_s3_client", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_creodias_s3_links(self, mock_request, mock_s3_client):
        # s3 links should be added to products with register_downloader
        search_plugin = self.get_search_plugin("S1_SAR_GRD", self.provider)
        client = boto3.client("s3", aws_access_key_id="a", aws_secret_access_key="b")
        mock_s3_client.return_value = client
        stubber = Stubber(client)
        s3_response_file = (
            Path(TEST_RESOURCES_PATH) / "provider_responses/creodias_s3_objects.json"
        )
        with open(s3_response_file) as f:
            list_objects_response = json.load(f)
        creodias_search_result_file = (
            Path(TEST_RESOURCES_PATH) / "eodag_search_result_creodias.json"
        )
        with open(creodias_search_result_file) as f:
            creodias_search_result = json.load(f)
        mock_request.return_value = MockResponse(creodias_search_result, 200)

        res = search_plugin.query(collection="S1_SAR_GRD")
        for product in res.data:
            download_plugin = self.plugins_manager.get_download_plugin(product)
            auth_plugin = self.plugins_manager.get_auth_plugin(download_plugin, product)
            stubber.add_response("list_objects", list_objects_response)
            stubber.activate()
            # fails if credentials are missing
            auth_plugin.config.credentials = {
                "aws_access_key_id": "",
                "aws_secret_access_key": "",
            }
            with self.assertRaisesRegex(
                MisconfiguredError,
                r"^Incomplete credentials .* \['aws_access_key_id', 'aws_secret_access_key'\]$",
            ):
                product.register_downloader(download_plugin, auth_plugin)
            auth_plugin.config.credentials = {
                "aws_access_key_id": "foo",
                "aws_secret_access_key": "bar",
            }
            product.register_downloader(download_plugin, auth_plugin)
        assets = res.data[0].assets
        self.assertEqual(3, len(assets))
        # check if s3 links have been created correctly
        for asset in assets.values():
            self.assertIn("s3://eodata/Sentinel-1/SAR/GRD/2014/10/10", asset["href"])

        # no occur should occur and assets should be empty if list_objects does not have content
        # (this situation will occur if the product does not have assets but is a tar file)
        stubber.add_response("list_objects", {})
        download_plugin = self.plugins_manager.get_download_plugin(res.data[0])
        auth_plugin = self.plugins_manager.get_auth_plugin(download_plugin, res.data[0])
        res.data[0].driver = None
        res.data[0].assets = AssetsDict(res.data[0])
        res.data[0].register_downloader(download_plugin, auth_plugin)
        self.assertIsNotNone(res.data[0].driver)
        self.assertEqual(0, len(res.data[0].assets))

    @mock.patch(
        "eodag.plugins.authentication.aws_auth.AwsAuth.get_s3_client", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch._request", autospec=True
    )
    def test_plugins_search_creodias_s3_client_error(
        self, mock_request, mock_s3_client
    ):
        # request error should be raised when there is an error when fetching data from the s3
        search_plugin = self.get_search_plugin("S1_SAR_GRD", self.provider)
        client = boto3.client("s3", aws_access_key_id="a", aws_secret_access_key="b")
        mock_s3_client.return_value = client
        stubber = Stubber(client)

        creodias_search_result_file = (
            Path(TEST_RESOURCES_PATH) / "eodag_search_result_creodias.json"
        )
        with open(creodias_search_result_file) as f:
            creodias_search_result = json.load(f)
        mock_request.return_value = MockResponse(creodias_search_result, 200)

        with self.assertRaises(NotAvailableError):
            res = search_plugin.query(collection="S1_SAR_GRD")
            for product in res.data:
                download_plugin = self.plugins_manager.get_download_plugin(product)
                auth_plugin = self.plugins_manager.get_auth_plugin(
                    download_plugin, product
                )
                auth_plugin.config.credentials = {
                    "aws_access_key_id": "foo",
                    "aws_secret_access_key": "bar",
                }
                stubber.add_client_error("list_objects")
                stubber.activate()
                product.register_downloader(download_plugin, auth_plugin)


class TestSearchPluginECMWFSearch(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        super(TestSearchPluginECMWFSearch, cls).setUpClass()
        providers = ProvidersDict.from_configs(load_default_config())
        cls.plugins_manager = PluginManager(providers)

    def setUp(self):
        self.provider = "cop_ads"
        self.search_plugin = self.get_search_plugin(provider=self.provider)
        self.query_dates = {
            "start_datetime": "2020-01-01",
            "end_datetime": "2020-01-02",
        }
        self.collection = "CAMS_EAC4"
        self.product_dataset = "cams-global-reanalysis-eac4"
        self.collection_params = {
            "ecmwf:dataset": self.product_dataset,
        }
        self.custom_query_params = {
            "ecmwf:dataset": "cams-global-ghg-reanalysis-egg4",
            "ecmwf:step": 0,
            "ecmwf:variable": "carbon_dioxide",
            "ecmwf:pressure_level": "10",
            "ecmwf:model_level": "1",
            "ecmwf:time": "00:00",
            "ecmwf:data_format": "grib",
        }

    def get_search_plugin(self, collection=None, provider=None):
        return next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider=provider
            )
        )

    def test_plugins_search_ecmwfsearch_exclude_end_date(self):
        """ECMWFSearch.query must adapt end date in certain cases"""
        # start & stop as dates -> keep end date as it is
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-02T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop as datetimes, not midnight -> keep and dates as it is
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T02:00:00Z",
            end_datetime="2020-01-02T03:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T02:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-02T03:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop as datetimes, midnight -> exclude end date
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T00:00:00Z",
            end_datetime="2020-01-02T00:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )
        # start & stop same date -> keep end date
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01T00:00:00Z",
            end_datetime="2020-01-01T00:00:00Z",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["start_datetime"],
        )
        self.assertEqual(
            "2020-01-01T00:00:00.000Z",
            eoproduct.properties["end_datetime"],
        )

    def test_plugins_search_ecmwfsearch_dates(self):
        """ECMWFSearch.query must use default dates if missing"""

        # given start & stop
        results = self.search_plugin.query(
            collection=self.collection,
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-01-01T00:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-01-02T00:00:00.000Z",
        )

        # missing start & stop
        results = self.search_plugin.query(
            collection=self.collection,
        )
        eoproduct = results.data[0]
        self.assertNotIn("start_datetime", eoproduct.properties)
        self.assertNotIn("end_datetime", eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_year_month_day_filter(self):
        """ECMWFSearch.query must use have datetime in response if year, month, day used in filters"""
        results = self.search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["02"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        eoproduct = results.data[0]

        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-02-20T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-02-21T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:year"],
            "2020",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:month"],
            ["02"],
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:day"],
            ["20", "21"],
        )
        # month with one digit
        results = self.search_plugin.query(
            prep=PreparedSearch(),
            collection="ERA5_SL",
            **{
                "ecmwf:year": "2020",
                "ecmwf:month": ["2"],
                "ecmwf:day": ["20", "21"],
                "ecmwf:time": ["01:00"],
            },
        )
        eoproduct = results.data[0]
        self.assertEqual(
            eoproduct.properties["start_datetime"],
            "2020-02-20T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["end_datetime"],
            "2020-02-21T01:00:00.000Z",
        )
        self.assertEqual(
            eoproduct.properties["ecmwf:month"],
            ["2"],
        )
        # qs (used for order request) should contain year/month/day but not date
        self.assertIn("qs", eoproduct.properties)
        self.assertIn("day", eoproduct.properties["qs"])
        self.assertIn("month", eoproduct.properties["qs"])
        self.assertIn("year", eoproduct.properties["qs"])
        self.assertNotIn("date", eoproduct.properties["qs"])

    def test_plugins_search_ecmwfsearch_collection_with_alias(self):
        """alias of collection must be used in search result"""
        self.search_plugin.config.collection_config = {
            "_collection": self.collection,
            "alias": "THE.ALIAS",
        }
        results = self.search_plugin.query(
            collection="THE.ALIAS",
            start_datetime="2020-01-01",
            end_datetime="2020-01-02",
        )
        eoproduct = results.data[0]
        self.assertEqual("THE.ALIAS", eoproduct.collection)

    def test_plugins_search_ecmwfsearch_without_collection(self):
        """
        ECMWFSearch.query must build a EOProduct from input parameters without collection.
        For test only, result cannot be downloaded.
        """
        results = self.search_plugin.query(
            PreparedSearch(count=True),
            **{
                "ecmwf:dataset": self.product_dataset,
                "start_datetime": "2020-01-01",
                "end_datetime": "2020-01-02",
            },
        )
        assert results.number_matched == 1
        eoproduct = results.data[0]
        assert eoproduct.geometry.bounds == (-180.0, -90.0, 180.0, 90.0)
        assert eoproduct.properties["start_datetime"] == "2020-01-01T00:00:00.000Z"
        assert eoproduct.properties["end_datetime"] == "2020-01-02T00:00:00.000Z"
        assert eoproduct.properties["title"] == eoproduct.properties["id"]
        assert eoproduct.properties["title"].startswith(
            f"{self.product_dataset.upper()}"
        )
        assert eoproduct.properties["eodag:order_link"].startswith("http")
        assert NOT_AVAILABLE in eoproduct.location

    def test_plugins_search_ecmwfsearch_with_collection(self):
        """ECMWFSearch.query must build a EOProduct from input parameters with predefined collection"""
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, geometry=[1, 2, 3, 4]
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["title"].startswith(self.collection)
        # geom converted using to_nwse_bounds
        assert eoproduct.geometry.bounds == (1.0, 2.0, 3.0, 4.0)
        # check if collection_params is a subset of eoproduct.properties
        assert self.collection_params.items() <= eoproduct.properties.items()

        # collection default settings can be overwritten using search kwargs
        results = self.search_plugin.query(
            **self.query_dates,
            **{"collection": self.collection, "ecmwf:variable": "temperature"},
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["ecmwf:variable"] == "temperature"

    def test_plugins_search_ecmwfsearch_with_area_keeps_qs(self):
        """ECMWFSearch.query with ``area`` must expose geometry in properties and keep ``area`` in qs."""
        # string format: "max_lat/min_lon/min_lat/max_lon"
        area_str = "44.0/1.0/43.0/2.0"
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, area=area_str
        )
        eoproduct = results.data[0]
        # geometry on the EOProduct is the converted polygon
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        # original ``area`` is preserved in qs (used for download/order request)
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["area"], area_str)
        # ``area`` must not leak as a top-level property on the EOProduct
        for key in ("area", "ecmwf:area", "ecmwf_area"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_area_list_not_sorted(self):
        """ECMWFSearch must not sort area parameter even if it's a list."""
        # area as list format: [North, West, South, East]
        # Use values that would be different if sorted
        area_list = [44.0, 1.0, 43.0, 2.0]
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, area=area_list
        )
        eoproduct = results.data[0]
        # geometry on the EOProduct is the converted polygon
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        # original ``area`` order is preserved in qs (not sorted)
        self.assertIn("qs", eoproduct.properties)
        # area should be preserved as-is (not sorted)
        self.assertEqual(eoproduct.properties["qs"]["area"], area_list)

    def test_plugins_search_ecmwfsearch_with_feature_keeps_qs(self):
        """ECMWFSearch.query with ``feature`` must expose geometry in properties and keep ``feature`` in qs."""
        feature = {
            "shape": [
                [43.0, 1.0],
                [44.0, 1.0],
                [44.0, 2.0],
                [43.0, 2.0],
                [43.0, 1.0],
            ],
            "type": "polygon",
        }
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, feature=feature
        )
        eoproduct = results.data[0]
        self.assertEqual(eoproduct.geometry.bounds, (1.0, 43.0, 2.0, 44.0))
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["feature"], feature)
        for key in ("feature", "ecmwf:feature", "ecmwf_feature"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_location_keeps_qs(self):
        """ECMWFSearch.query with ``location`` must expose geometry in properties and keep ``location`` in qs."""
        location = {"latitude": 43.5, "longitude": 1.5}
        results = self.search_plugin.query(
            **self.query_dates, collection=self.collection, location=location
        )
        eoproduct = results.data[0]
        # single location -> point-like polygon (bbox of a point)
        self.assertEqual(eoproduct.geometry.bounds, (1.5, 43.5, 1.5, 43.5))
        self.assertIn("qs", eoproduct.properties)
        self.assertEqual(eoproduct.properties["qs"]["location"], location)
        for key in ("location", "ecmwf:location", "ecmwf_location"):
            self.assertNotIn(key, eoproduct.properties)

    def test_plugins_search_ecmwfsearch_with_custom_collection(self):
        """ECMWFSearch.query must build a EOProduct from input parameters with custom collection"""
        results = self.search_plugin.query(
            **self.query_dates,
            **self.custom_query_params,
        )
        eoproduct = results.data[0]
        assert eoproduct.properties["title"].startswith(
            self.custom_query_params["ecmwf:dataset"].upper()
        )
        # check if custom_query_params is a subset of eoproduct.properties
        for param in self.custom_query_params:
            try:
                # for numeric values
                assert eoproduct.properties[param] == ast.literal_eval(
                    self.custom_query_params[param]
                )
            except Exception:
                assert eoproduct.properties[param] == self.custom_query_params[param]

    def test_plugins_search_ecmwf_temporal_to_eodag(self):
        """ecmwf_temporal_to_eodag must parse all expected dates formats"""
        self.assertEqual(
            ecmwf_temporal_to_eodag(
                dict(day="15", month="02", year="2022", time="0600")
            ),
            ("2022-02-15T06:00:00.000Z", "2022-02-15T06:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(hday="15", hmonth="02", hyear="2022")),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date="2022-02-15")),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(
                dict(date="2022-02-15T00:00:00Z/2022-02-16T00:00:00Z")
            ),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date="20220215/to/20220216")),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        # List of dates
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date=["20220215", "20220216"])),
            ("2022-02-15T00:00:00.000Z", "2022-02-16T00:00:00.000Z"),
        )
        self.assertEqual(
            ecmwf_temporal_to_eodag(dict(date=["20220215"])),
            ("2022-02-15T00:00:00.000Z", "2022-02-15T00:00:00.000Z"),
        )

    def test_plugins_search_ecmwfsearch_get_available_values_from_contraints(self):
        """ECMWFSearch must return available values from constraints"""
        constraints = [
            {"date": ["2025-01-01/2025-06-01"], "variable": ["a", "b"]},
            {"date": ["2024-01-01/2024-12-01"], "variable": ["a", "b", "c"]},
        ]
        form_keywords = ["date", "variable"]

        # with a date range as a string
        input_keywords = {"date": "2025-01-01/2025-02-01", "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form_keywords
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)

        # with a date range as the first element of a string list
        input_keywords = {"date": ["2025-01-01/2025-02-01"], "variable": "a"}
        available_values = self.search_plugin.available_values_from_constraints(
            constraints, input_keywords, form_keywords
        )
        available_values = {k: sorted(v) for k, v in available_values.items()}
        self.assertIn("variable", available_values)
        self.assertListEqual(["a", "b"], available_values["variable"])
        self.assertIn("date", available_values)

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_ok(self, mock__fetch_data):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        constraints[0]["variable"].append("nitrogen_dioxide")
        constraints[0]["type"].append("validated_reanalysis")
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]
        collection_config = {
            "extent": {
                "spatial": {"bbox": [[-180.0, -90.0, 180.0, 90.0]]},
                "temporal": {"interval": [["2001-01-01T00:00:00Z", None]]},
            }
        }
        setattr(self.search_plugin.config, "collection_config", collection_config)

        provider_queryables_from_constraints_file = [
            "ecmwf_year",
            "ecmwf_month",
            "ecmwf_day",
            "ecmwf_time",
            "ecmwf_variable",
            "ecmwf_leadtime_hour",
            "ecmwf_type",
            "ecmwf_product_type",
        ]
        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        # ECMWF-like providers don't have default values anymore: override a default value
        default_values["data_format"] = "grib"
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        # set a parameter among the required ones of the form file with a default value in this form but not among the
        # ones of the constraints file to an empty value to check if its associated queryable has no default value
        eodag_formatted_data_format = "ecmwf:data_format"
        provider_data_format = eodag_formatted_data_format.replace("ecmwf:", "")
        self.assertIn(provider_data_format, default_values)
        self.assertIn(provider_data_format, [param["name"] for param in form])
        data_format_in_form = [
            param for param in form if param["name"] == provider_data_format
        ][0]
        self.assertTrue(data_format_in_form.get("required", False))
        self.assertIsNotNone(data_format_in_form.get("details", {}).get("default"))
        for constraint in constraints:
            self.assertNotIn(provider_data_format, constraint)
        params[eodag_formatted_data_format] = ""

        # use a parameter among the ones of the form file but not among the ones of the constraints file
        # and of provider default configuration to check if an error is raised, which is supposed to not happen
        eodag_formatted_download_format = "ecmwf:download_format"
        provider_download_format = eodag_formatted_download_format.replace("ecmwf:", "")
        self.assertNotIn(eodag_formatted_download_format, default_values)
        self.assertIn(provider_download_format, [param["name"] for param in form])
        for constraint in constraints:
            self.assertNotIn(provider_data_format, constraint)
        params[eodag_formatted_download_format] = "foo"
        # create parameters matching the first constraint
        params["variable"] = "nitrogen_dioxide"

        queryables = self.search_plugin.discover_queryables(**params)
        # no error was raised, as expected
        self.assertIsNotNone(queryables)

        mock__fetch_data.assert_has_calls(
            [
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/constraints.json",
                ),
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/form.json",
                ),
            ]
        )

        # queryables from provider constraints file are added (here the ones of CAMS_EU_AIR_QUALITY_RE for cop_ads)
        for provider_queryable in provider_queryables_from_constraints_file:
            provider_queryable = (
                get_queryable_from_provider(
                    provider_queryable,
                    self.search_plugin.get_metadata_mapping("CAMS_EU_AIR_QUALITY_RE"),
                )
                or provider_queryable
            )
            self.assertIn(provider_queryable, queryables)

        # default properties in provider config are added and must be default values of the queryables
        for property, default_value in self.search_plugin.config.products[
            "CAMS_EU_AIR_QUALITY_RE"
        ].items():
            queryable = queryables.get(property)
            # a special case for eodag_formatted_data_format queryable is required
            # as its default value has been overwritten by an empty value
            if queryable is not None and property == eodag_formatted_data_format:
                self.assertEqual(
                    PydanticUndefined, queryable.__metadata__[0].get_default()
                )
                # queryables with empty default values are required
                self.assertTrue(queryable.__metadata__[0].is_required())
            elif queryable is not None:
                self.assertEqual(default_value, queryable.__metadata__[0].get_default())
                # queryables with default values are not required
                self.assertFalse(queryable.__metadata__[0].is_required())

        # required queryable
        queryable = queryables.get("ecmwf:month")
        if queryable is not None:
            self.assertEqual(["01"], queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())

        # check that queryable constraints from the constraints file are in queryable info
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            variable_constraints = constraints[0]["variable"]
            # remove queryable constraints duplicates to make the assertion works
            self.assertSetEqual(
                set(variable_constraints),
                set(get_args(queryable.__origin__.__args__[0])),
            )

        # reset mock
        mock__fetch_data.reset_mock()
        mock__fetch_data.side_effect = [constraints, form]
        # with additional param
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        params["ecmwf:variable"] = "a"
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)

        # cached values are not used to make the set of unit tests work then the mock is called again
        mock__fetch_data.assert_has_calls(
            [
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/constraints.json",
                ),
                call(
                    mock.ANY,
                    "https://ads.atmosphere.copernicus.eu/api/catalogue/v1/collections/"
                    "cams-europe-air-quality-reanalyses/form.json",
                ),
            ]
        )

        self.assertEqual(12, len(queryables))
        # default properties called in function arguments are added and must be default values of the queryables
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            self.assertEqual("a", queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_ko(self, mock__fetch_data):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"

        # use a wrong parameter, e.g. it is not among the ones of the form file, not among
        # the ones of the constraints file and not among the ones of default provider configuration
        wrong_queryable = "foo"
        self.assertNotIn(wrong_queryable, default_values)
        self.assertNotIn(wrong_queryable, [param["name"] for param in form])
        for constraint in constraints:
            self.assertNotIn(wrong_queryable, constraint)
        params[wrong_queryable] = "bar"

        # Test the function, expecting ValidationError to be raised
        with self.assertRaises(ValidationError) as context:
            self.search_plugin.discover_queryables(**params)
        self.assertEqual(
            f"'{wrong_queryable}' is not a queryable parameter for {self.provider}",
            context.exception.message,
        )

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_discover_queryables_allow_discovered_metadata(
        self, mock__fetch_data
    ):
        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        mock__fetch_data.side_effect = [constraints, form]

        provider_search_plugin_config = copy_deepcopy(self.search_plugin.config)
        self.search_plugin.config.discover_metadata = {
            "auto_discovery": True,
            "search_param": "{metadata}",
            "metadata_pattern": "^(feature|interpolation|grid)$",
        }

        default_values = deepcopy(
            getattr(self.search_plugin.config, "products", {}).get(
                "CAMS_EU_AIR_QUALITY_RE", {}
            )
        )
        default_values.pop("metadata_mapping", None)
        params = deepcopy(default_values)
        params["collection"] = "CAMS_EU_AIR_QUALITY_RE"
        params["interpolation"] = "nearest"

        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIsNotNone(queryables)

        # restore the original config
        self.search_plugin.config = provider_search_plugin_config

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
        return_value={},
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_location",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_feature",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.build_search_result.get_geometry_from_ecmwf_area",
        autospec=True,
    )
    def test_plugins_search_ecmwfsearch_convert_geometry_ok(
        self,
        mock_get_geometry_from_ecmwf_area,
        mock_get_geometry_from_ecmwf_feature,
        mock_get_geometry_from_ecmwf_location,
        mock__fetch_data,
    ):
        """Custom geometry must be converted to Shapely polygon."""
        area = [64.0, 51.0, 52.0, 63.0]
        shape = {
            "shape": [
                [50.0, 50.0],
                [50.0, 60.0],
                [60.0, 60.0],
                [60.0, 50.0],
                [50.0, 50.0],
            ],
            "type": "polygon",
        }
        location = {
            "latitude": 30.0,
            "longitude": 30.0,
        }

        # area
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "area": area,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_area.assert_called_once_with(area)

        # shape
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "feature": shape,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_feature.assert_called_once_with(shape)

        # location
        params = {
            "collection": "CAMS_EU_AIR_QUALITY_RE",
            "location": location,
        }
        queryables = self.search_plugin.discover_queryables(**params)
        self.assertIn("geom", queryables)
        mock_get_geometry_from_ecmwf_location.assert_called_once_with(location)

    @mock.patch(
        "eodag.plugins.search.build_search_result.ECMWFSearch._fetch_data",
        autospec=True,
        return_value={},
    )
    def test_plugins_search_ecmwfsearch_convert_geometry_ko(
        self,
        mock__fetch_data,
    ):
        """
        ``get_geometry_from_ecmwf_*`` must raise an informative exception
        if the given geometry is not well formed.
        """
        # area
        area_values = [
            [1.0, 2.0, 3.0],  # not enough values
            [1.0, 2.0, 3.0, 4.0, 5.0],  # too many values
            [1.0, "lorem", 3.0, 4.0],  # wrong type
        ]
        for area in area_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "area": area,
            }
            with self.assertRaises(ValidationError, msg=area):
                self.search_plugin.discover_queryables(**params)

        # feature
        feature_values = [
            "foo",  # not a dict
            {"foo": "bar"},  # missing 'type'
            {"type": "points"},  # wrong 'type'
            {"type": "polygon"},  # missing 'shape'
            {"type": "polygon", "shape": "boo"},  # shape is not a list
            {
                "type": "polygon",
                "shape": [1, 2, 3],
            },
        ]
        for feature in feature_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "feature": feature,
            }
            with self.assertRaises(ValidationError, msg=feature):
                self.search_plugin.discover_queryables(**params)

        # location
        location_values = [
            "foo",  # not a dict
            {"lorem": "foo", "ipsum": "boo"},  # missing both fields
            {"latitude": 40, "ipsum": "boo"},  # missing one field
            {"latitude": 40, "longitude": "boo"},  # value is not a number
        ]
        for location in location_values:
            params = {
                "collection": "CAMS_EU_AIR_QUALITY_RE",
                "location": location,
            }
            with self.assertRaises(ValidationError, msg=location):
                self.search_plugin.discover_queryables(**params)

    @mock.patch("eodag.utils.requests.requests.sessions.Session.get", autospec=True)
    def test_plugins_search_ecmwf_search_wekeo_discover_queryables(
        self, mock_requests_get
    ):
        # One of the providers that has discover_queryables() configured with QueryStringSearch
        search_plugin = self.get_search_plugin(provider="wekeo_ecmwf")
        self.assertEqual("WekeoECMWFSearch", search_plugin.__class__.__name__)
        self.assertEqual(
            "ECMWFSearch",
            search_plugin.discover_queryables.__func__.__qualname__.split(".")[0],
        )

        constraints_path = os.path.join(TEST_RESOURCES_PATH, "constraints.json")
        with open(constraints_path) as f:
            constraints = json.load(f)
        wekeo_ecmwf_constraints = [constraints[0]]

        form_path = os.path.join(TEST_RESOURCES_PATH, "form.json")
        with open(form_path) as f:
            form = json.load(f)
        wekeo_ecmwf_form = form

        mock_requests_get.side_effect = [
            MockResponse(wekeo_ecmwf_constraints, status_code=200),
            MockResponse(wekeo_ecmwf_form, status_code=200),
        ]

        provider_queryables_from_constraints_file = [
            "ecmwf_year",
            "ecmwf_month",
            "ecmwf_day",
            "ecmwf_time",
            "ecmwf_variable",
            "ecmwf_leadtime_hour",
            "ecmwf_type",
            "ecmwf_product_type",
        ]

        queryables = search_plugin._get_collection_queryables(
            collection="ERA5_SL_MONTHLY", alias=None, filters={}
        )
        self.assertIsNotNone(queryables)

        self.assertEqual(len(mock_requests_get.call_args), 2)
        mock_requests_get.assert_any_call(
            mock.ANY,
            "https://cds.climate.copernicus.eu/api/catalogue/v1/collections/"
            "reanalysis-era5-single-levels-monthly-means/constraints.json",
            headers=USER_AGENT,
            auth=None,
            timeout=60,
        )
        mock_requests_get.assert_any_call(
            mock.ANY,
            "https://cds.climate.copernicus.eu/api/catalogue/v1/collections/"
            "reanalysis-era5-single-levels-monthly-means/form.json",
            headers=USER_AGENT,
            auth=None,
            timeout=60,
        )

        # queryables from provider constraints file are added (here the ones of ERA5_SL_MONTHLY for wekeo_ecmwf)
        for provider_queryable in provider_queryables_from_constraints_file:
            provider_queryable = (
                get_queryable_from_provider(
                    provider_queryable,
                    search_plugin.get_metadata_mapping("ERA5_SL_MONTHLY"),
                )
                or provider_queryable
            )
            self.assertIn(provider_queryable, queryables)

        # default properties in provider config are added and must be default values of the queryables
        for property, default_value in search_plugin.config.products[
            "ERA5_SL_MONTHLY"
        ].items():
            queryable = queryables.get(property)
            if queryable is not None:
                self.assertEqual(default_value, queryable.__metadata__[0].get_default())
                # queryables with default values are not required
                self.assertFalse(queryable.__metadata__[0].is_required())

        # queryables without default values are required
        queryable = queryables.get("month")
        if queryable is not None:
            self.assertEqual(PydanticUndefined, queryable.__metadata__[0].get_default())
            self.assertTrue(queryable.__metadata__[0].is_required())

        # check that queryable constraints from the constraints file are in queryable info
        # (here it is a case where all constraints of "variable" queryable can be taken into account)
        queryable = queryables.get("variable")
        if queryable is not None:
            variable_constraints = []
            for constraint in constraints:
                if "variable" in constraint:
                    variable_constraints.extend(constraint["variable"])
            # remove queryable constraints duplicates to make the assertion works
            self.assertSetEqual(
                set(variable_constraints), set(queryable.__origin__.__args__)
            )

        # reset mock
        mock_requests_get.reset_mock()

        # with additional param
        queryables = search_plugin.discover_queryables(
            collection="ERA5_SL_MONTHLY",
            dataset="EO:ECMWF:DAT:REANALYSIS_ERA5_SINGLE_LEVELS_MONTHLY_MEANS",
            **{"ecmwf:variable": "a"},
        )
        self.assertIsNotNone(queryables)

        self.assertEqual(12, len(queryables))
        # default properties called in function arguments are added and must be default values of the queryables
        queryable = queryables.get("ecmwf:variable")
        if queryable is not None:
            self.assertEqual("a", queryable.__metadata__[0].get_default())
            self.assertFalse(queryable.__metadata__[0].is_required())


class TestSearchPluginCopMarineSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCopMarineSearch, self).setUp()
        self.provider = "cop_marine"
        self.product_data = {
            "id": "PRODUCT_A",
            "type": "Collection",
            "stac_version": "1.0.0",
            "stac_extensions": [
                "https://stac-extensions.github.io/scientific/v1.0.0/schema.json"
            ],
            "title": "Product A",
            "description": "A nice description",
            "license": "other",
            "providers": [
                {"name": "CLS (France)", "roles": ["producer"]},
                {
                    "name": "Copernicus Marine Service",
                    "roles": ["host", "processor"],
                    "url": "https://marine.copernicus.eu",
                },
            ],
            "keywords": [
                "oceanographic-geographical-features",
                "satellite-observation",
                "level-3",
            ],
            "links": [
                {
                    "rel": "root",
                    "href": "../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "parent",
                    "href": "../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "item",
                    "href": "dataset-number-one/dataset.stac.json",
                    "title": "dataset-number-one",
                    "type": "application/json",
                },
                {
                    "rel": "item",
                    "href": "dataset-number-two/dataset.stac.json",
                    "title": "dataset-number-two",
                    "type": "application/json",
                },
                {
                    "rel": "license",
                    "href": "https://marine.copernicus.eu/user-corner/service-commitments-and-licence",
                    "title": "Copernicus Marine Service Commitments and Licence",
                    "type": "text/html",
                },
            ],
            "extent": {
                "temporal": {
                    "interval": [
                        ["1970-01-01T00:00:00.000000Z", "1970-01-01T00:00:00.000000Z"]
                    ]
                },
                "spatial": {"bbox": [[0.0, 0.0, 0.0, 0.0]]},
            },
            "assets": {
                "thumbnail": {
                    "href": "https://catalogue.marine.copernicus.eu/documents/IMG/WAVE_GLO_PHY_SPC_L3_MY_014_006.png",
                    "type": "image/png",
                    "roles": ["thumbnail"],
                    "title": "GLOBAL OCEAN L3 SPECTRAL PARAMETERS FROM REPROCESSED SATELLITE MEASUREMENTS thumbnail",
                }
            },
            "properties": {
                "altId": "450bf368-2407-4c2c-8535-f215a4cda963",
                "created": "2021-04-23",
                "modifiedDate": "2021-04-23",
                "contacts": [
                    {
                        "name": "Jim Gabriel",
                        "organisationName": "Heaven Inc",
                        "responsiblePartyRole": "custodian",
                        "email": "jim.gabriel@heaven.com",
                    }
                ],
                "projection": "WGS84 / Simple Mercator (EPSG:41001)",
                "formats": ["NetCDF-4"],
                "featureTypes": ["Swath", "Trajectory"],
                "tempResolutions": ["Instantaneous"],
                "rank": 15015,
                "areas": [
                    "Global Ocean",
                ],
                "times": ["Past"],
                "sources": ["Satellite observations"],
                "colors": ["Blue Ocean"],
                "directives": [
                    "Water Framework Directive (WFD)",
                    "Maritime Spatial Planning (MSP)",
                ],
                "crs": "EPSG:3857",
                "isStaging": False,
                "admp_updated": "2023-11-07T16:54:54.688320Z",
            },
            "sci:doi": "10.48670/moi-00174",
        }
        self.dataset1_data = {
            "id": "dataset-number-one",
            "type": "Feature",
            "stac_version": "1.0.0",
            "stac_extensions": [
                "https://stac-extensions.github.io/datacube/v2.1.0/schema.json"
            ],
            "geometry": {
                "type": "Polygon",
                "coordinates": [
                    [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]
                ],
            },
            "bbox": [0.0, 0.0, 0.0, 0.0],
            "properties": {
                "title": "dataset-number-one",
                "datetime": "1970-01-01T00:00:00.000000Z",
            },
            "links": [
                {
                    "rel": "root",
                    "href": "../../catalog.stac.json",
                    "title": "Copernicus Marine Data Store",
                    "type": "application/json",
                },
                {
                    "rel": "parent",
                    "href": "../product.stac.json",
                    "title": "PRODUCT A",
                    "type": "application/json",
                },
                {
                    "rel": "collection",
                    "href": "../product.stac.json",
                    "title": "PRODUCT A",
                    "type": "application/json",
                },
            ],
            "assets": {
                "native": {
                    "id": "native",
                    "href": "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one",
                    "type": "application/x-netcdf",
                    "roles": ["data"],
                    "title": "Native dataset",
                    "description": "The original, non-ARCO version of this dataset, as published by the data provider.",
                }
            },
            "collection": "PRODUCT_A",
        }
        self.dataset2_data = deepcopy(self.dataset1_data)
        self.dataset2_data["id"] = "dataset-number-two"
        self.dataset2_data["properties"]["title"] = "dataset-number-two"
        self.dataset2_data["assets"]["native"][
            "href"
        ] = "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-two"

        self.list_objects_response1 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200102_20200103_hdkIFE.KFNEDNF_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200104_20200105_hdkIFEKFNEDNF_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc"
                },
            ]
        }
        self.list_objects_response2 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200102_20200103_fizncnqijei_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200204_20200205_niznjvnqkrf_20210101.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_20200302_20200303_fIZHVCOINine_20210101.nc"
                },
            ]
        }
        self.list_objects_response3 = {
            "Contents": [
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_15325642_fizncnqijei.nc"
                },
                {
                    "Key": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc"
                },
            ]
        }
        self.s3 = boto3.client(
            "s3",
            config=botocore.config.Config(
                # Configures to use subdomain/virtual calling format.
                s3={"addressing_style": "virtual"},
                signature_version=botocore.UNSIGNED,
            ),
        )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_dates(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-one",
                },
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response2,
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="2020-01-01T01:00:00Z",
                end_datetime="2020-02-01T01:00:00Z",
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(3, result.number_matched)
            products_dataset1 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-one"
            ]
            products_dataset2 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-two"
            ]
            self.assertEqual(2, len(products_dataset1))
            self.assertEqual(1, len(products_dataset2))
            self.assertEqual(
                "2020-01-02T00:00:00.000Z",
                products_dataset2[0].properties["start_datetime"],
            )
            self.assertEqual(
                "2020-01-03T00:00:00.000Z",
                products_dataset2[0].properties["end_datetime"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_no_dates_in_id(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        search_plugin.config.products = {
            "PRODUCT_A": {
                "_collection": "PRODUCT_A",
                "code_mapping": {"param": "platform", "index": 1},
            }
        }

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-one"},
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response3,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-two"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                    "Marker": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="1969-01-01T01:00:00Z",
                end_datetime="1970-02-01T01:00:00Z",
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(2, result.number_matched)
            products_dataset2 = [
                product
                for product in result.data
                if product.properties["dataset"] == "dataset-number-two"
            ]
            self.assertEqual(2, len(products_dataset2))
            self.assertEqual(
                "1970-01-01T00:00:00.000000Z",
                products_dataset2[0].properties["start_datetime"],
            )
            self.assertEqual(
                "1970-01-01T00:00:00.000000Z",
                products_dataset2[0].properties["end_datetime"],
            )
            self.assertEqual(
                "15325642",
                products_dataset2[0].properties["platform"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_id(self, mock_requests_get):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            for i in [
                0,
                1,
            ]:  # add responses twice because 2 search requests will be executed
                stubber.add_response(
                    "list_objects",
                    self.list_objects_response1,
                    {
                        "Bucket": "bucket1",
                        "Prefix": "native/PRODUCT_A/dataset-number-one",
                    },
                )
                stubber.add_response(
                    "list_objects",
                    {},
                    {
                        "Bucket": "bucket1",
                        "Marker": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc",
                        "Prefix": "native/PRODUCT_A/dataset-number-one",
                    },
                )
                stubber.add_response(
                    "list_objects",
                    self.list_objects_response2,
                    {
                        "Bucket": "bucket1",
                        "Prefix": "native/PRODUCT_A/dataset-number-two",
                    },
                )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual(
                "item_20200204_20200205_niznjvnqkrf_20210101",
                result.data[0].properties["id"],
            )
            result = search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200102_20200103_hdkIFE.KFNEDNF_20210101",
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual(
                "item_20200102_20200103_hdkIFE.KFNEDNF_20210101",
                result.data[0].properties["id"],
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_search_by_id_no_dates_in_id(
        self, mock_requests_get
    ):
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        search_plugin.config.products = {
            "PRODUCT_A": {
                "_collection": "PRODUCT_A",
                "code_mapping": {"param": "platform", "index": 1},
            }
        }

        with mock.patch("eodag.plugins.search.cop_marine._get_s3_client") as s3_stub:
            s3_stub.return_value = self.s3
            stubber = Stubber(self.s3)
            stubber.add_response(
                "list_objects",
                self.list_objects_response1,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-one"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Marker": "native/PRODUCT_A/dataset-number-one/item_20200302_20200303_hdkIFEKFNEDN_20210101.nc",
                    "Prefix": "native/PRODUCT_A/dataset-number-one",
                },
            )
            stubber.add_response(
                "list_objects",
                self.list_objects_response3,
                {"Bucket": "bucket1", "Prefix": "native/PRODUCT_A/dataset-number-two"},
            )
            stubber.add_response(
                "list_objects",
                {},
                {
                    "Bucket": "bucket1",
                    "Prefix": "native/PRODUCT_A/dataset-number-two",
                    "Marker": "native/PRODUCT_A/dataset-number-two/item_846282_niznjvnqkrf.nc",
                },
            )
            stubber.activate()
            result = search_plugin.query(
                collection="PRODUCT_A", id="item_846282_niznjvnqkrf"
            )
            mock_requests_get.assert_has_calls(
                calls=[
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                    ),
                    call().json(),
                    call(
                        "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                    ),
                    call().json(),
                ]
            )
            self.assertEqual(1, result.number_matched)
            self.assertEqual("item_846282_niznjvnqkrf", result.data[0].properties["id"])

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_query_with_not_intersected_geom(
        self, mock_requests_get
    ):
        """A query with a geometry that does not intersect the dataset geometries must return no result"""
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]

        geometry = get_geometry_from_various(geometry=[10, 20, 30, 40])

        # check that "geometry" does not intersect the dataset geometries
        self.assertFalse(
            get_geometry_from_various(
                geometry=self.dataset1_data["geometry"]
            ).intersects(geometry)
        )
        self.assertFalse(
            get_geometry_from_various(
                geometry=self.dataset2_data["geometry"]
            ).intersects(geometry)
        )

        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        result = search_plugin.query(
            collection="PRODUCT_A",
            geometry=geometry,
        )

        mock_requests_get.assert_has_calls(
            calls=[
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/product.stac.json"
                ),
                call().json(),
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-one/dataset.stac.json"
                ),
                call().json(),
                call(
                    "https://stac.marine.copernicus.eu/metadata/PRODUCT_A/dataset-number-two/dataset.stac.json"
                ),
                call().json(),
            ]
        )

        # check that no result has been found
        self.assertListEqual(result.data, [])
        self.assertEqual(result.number_matched, 0)

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_with_errors(self, mock_requests_get):
        exc = requests.RequestException()
        exc.errno = 404
        mock_requests_get.side_effect = exc
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        with self.assertRaises(UnsupportedCollection):
            search_plugin.query(
                collection="PRODUCT_AX",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )
        mock_requests_get.reset()
        mock_requests_get.side_effect = requests.exceptions.ConnectionError()
        with self.assertRaises(RequestError):
            search_plugin.query(
                collection="PRODUCT_A",
                id="item_20200204_20200205_niznjvnqkrf_20210101",
            )

    @mock.patch("eodag.plugins.search.cop_marine.requests.get")
    def test_plugins_search_cop_marine_normalize_results(self, mock_requests_get):
        """Normalized query results must include asset information fetched from S3"""
        mock_requests_get.return_value.json.side_effect = [
            self.product_data,
            self.dataset1_data,
            self.dataset2_data,
        ]
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)

        asset_filename = "20200115_dm-metno-MODEL-topaz5_ecosmo-ARC-b20200115-fv02.0.nc"

        def mock_s3_list_objects(self, operation_name, *args, **kwargs):
            if operation_name != "ListObjects":
                raise NotImplementedError()
            params = {**args[0], **kwargs}
            prefix = params.get("Prefix", "")
            if prefix == "native/PRODUCT_A/dataset-number-one" and not params.get(
                "Marker"
            ):
                contents = [
                    {
                        "Key": f"{prefix}/{asset_filename}",
                        "LastModified": dt.datetime(
                            2020, 1, 15, tzinfo=dt.timezone.utc
                        ),
                        "ETag": '"d41d8cd98f00b204e9800998ecf8427e"',
                        "Size": 666,
                        "StorageClass": "STANDARD",
                        "Owner": {
                            "DisplayName": "Data Access",
                            "ID": "data-access",
                        },
                    }
                ]
            else:
                contents = []
            return {"IsTruncated": False, "Contents": contents}

        with mock.patch(
            "botocore.client.BaseClient._make_api_call", new=mock_s3_list_objects
        ):
            result = search_plugin.query(
                collection="PRODUCT_A",
                start_datetime="2020-01-01T01:00:00Z",
                end_datetime="2020-02-01T01:00:00Z",
            )

        self.assertIn("native", result[0].assets)
        asset = result[0].assets["native"]
        self.assertEqual(asset.get("title"), "native")
        self.assertEqual(
            asset.get("href"),
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/"
            + asset_filename,
        )
        self.assertEqual(asset.get("type"), "application/x-netcdf")  # hard configured
        self.assertEqual(asset.get("file:size"), 666)
        self.assertEqual(asset.get("file:checksum"), "d41d8cd98f00b204e9800998ecf8427e")
        self.assertEqual(asset.get("cop_marine:owner_id"), "data-access")
        self.assertEqual(asset.get("cop_marine:owner_name"), "Data Access")

    def test_plugins_search_cop_marine_create_product(self):
        """Test creation of EOProduct from input data"""
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        s3_url = "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one"
        item_key = "PRODUCT_A/dataset-number-one/item1_20010501"
        collection_dict = {
            "id": "PRODUCT_A",
            "properties": {"a": "a", "b": "b"},
            "assets": {"thumbnail": {"href": "https://www.abc.com/thumbnailA"}},
        }
        dataset_dict = {
            "id": "dataset-number-one",
            "properties": {
                "c": "c",
                "start_datetime": "2000-01-01T00:00:00Z",
                "end_datetime": "2010-01-01T00:00:00Z",
            },
        }
        product = search_plugin._create_product(
            "PRODUCT_A", item_key, s3_url, dataset_dict, collection_dict
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertEqual("PRODUCT_A", product.collection)
        self.assertEqual(
            "2001-05-01T00:00:00.000Z", product.properties["start_datetime"]
        )
        self.assertEqual("2001-05-01T00:00:00.000Z", product.properties["end_datetime"])
        self.assertEqual(
            "https://www.abc.com/thumbnailA", product.properties["eodag:thumbnail"]
        )
        self.assertEqual("dataset-number-one", product.properties["dataset"])
        self.assertEqual("a", product.properties["a"])
        self.assertEqual("b", product.properties["b"])
        self.assertEqual("c", product.properties["c"])
        self.assertIn("native", product.assets)
        self.assertEqual(
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/item1_20010501",
            product.assets["native"]["href"],
        )

        # with use of dataset dates
        product = search_plugin._create_product(
            "PRODUCT_A", item_key, s3_url, dataset_dict, collection_dict, True
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertEqual("PRODUCT_A", product.collection)
        self.assertEqual("2000-01-01T00:00:00Z", product.properties["start_datetime"])
        self.assertEqual("2010-01-01T00:00:00Z", product.properties["end_datetime"])

        # with asset properties
        product = search_plugin._create_product(
            "PRODUCT_A",
            item_key,
            s3_url,
            dataset_dict,
            collection_dict,
            asset_properties={"z": "z"},
        )
        self.assertEqual("item1_20010501", product.properties["id"])
        self.assertIn("native", product.assets)
        self.assertEqual(
            "https://s3.test.com/bucket1/native/PRODUCT_A/dataset-number-one/item1_20010501",
            product.assets["native"]["href"],
        )
        self.assertIn("z", product.assets["native"])
        self.assertEqual("z", product.assets["native"]["z"])

    def test_plugins_search_cop_marine_discover_queryables(self):
        """Queryables discovery with a CopMarineSearch must return static queryables with an adaptative default value"""  # noqa
        search_plugin = self.get_search_plugin("PRODUCT_A", self.provider)
        kwargs = {"collection": "PRODUCT_A", "provider": self.provider}

        queryables = search_plugin.discover_queryables(**kwargs)

        self.assertIsNotNone(queryables)
        # check that the queryables are the ones expected (they are always the same ones)
        self.assertListEqual(
            list(queryables.keys()), ["collection", "id", "start", "end", "geom"]
        )
        # check that each queryable does not have a default value except the one set in the kwargs
        for key, queryable in queryables.items():
            if key in kwargs:
                self.assertIsNotNone(queryable.__metadata__[0].get_default())
            else:
                self.assertIsNone(queryable.__metadata__[0].get_default())


class TestSearchPluginWekeoSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginWekeoSearch, self).setUp()
        # One of the providers that has a WekeoSearch Search plugin
        provider = "wekeo_main"
        self.wekeomain_search_plugin = self.get_search_plugin(self.collection, provider)
        self.wekeomain_auth_plugin = self.get_auth_plugin(self.wekeomain_search_plugin)

    def test_plugins_search_wekeosearch_init_wekeomain(self):
        """Check that the WekeoSearch plugin is initialized correctly for wekeo_main provider"""

        default_config = load_default_config()["wekeo_main"]
        # "eodag:order_link" in S1_SAR_GRD but not in provider conf or S1_SAR_SLC conf
        self.assertNotIn("eodag:order_link", default_config.search.metadata_mapping)
        self.assertIn(
            "eodag:order_link",
            default_config.products["S1_SAR_GRD"]["metadata_mapping"],
        )
        self.assertNotIn("metadata_mapping", default_config.products["S1_SAR_SLC"])

        # metadata_mapping_from_product: from S1_SAR_GRD to S1_SAR_SLC
        self.assertEqual(
            default_config.products["S1_SAR_SLC"]["metadata_mapping_from_product"],
            "S1_SAR_GRD",
        )

        # check initialized plugin configuration
        self.assertDictEqual(
            self.wekeomain_search_plugin.config.products["S1_SAR_GRD"][
                "metadata_mapping"
            ],
            self.wekeomain_search_plugin.config.products["S1_SAR_SLC"][
                "metadata_mapping"
            ],
        )

        # S3_SRA_BS has both metadata_mapping_from_product and metadata_mapping
        # "metadata_mapping" must override "metadata_mapping_from_product"
        self.assertIn(
            "eodag:order_link",
            default_config.products["S3_SRA_BS"]["metadata_mapping"],
        )
        self.assertIn(
            "eodag:order_link",
            default_config.products["S3_EFR"]["metadata_mapping"],
        )
        self.assertEqual(
            default_config.products["S3_SRA_BS"]["metadata_mapping_from_product"],
            "S3_EFR",
        )
        self.assertNotEqual(
            self.wekeomain_search_plugin.config.products["S3_SRA_BS"][
                "metadata_mapping"
            ]["eodag:order_link"],
            self.wekeomain_search_plugin.config.products["S3_EFR"]["metadata_mapping"][
                "eodag:order_link"
            ],
        )

    @mock.patch(
        "eodag.plugins.search.qssearch.QueryStringSearch.normalize_results",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.build_query_string", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.build_query_string", autospec=True
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.WekeoSearch._request",
        autospec=True,
    )
    def test_plugins_search_wekeosearch_search_wekeomain(
        self,
        mock__request,
        mock_build_qs_postjsonsearch,
        mock_build_qs_stacsearch,
        mock_normalize_results,
    ):
        """A query with a WekeoSearch provider (here wekeo_main) must use build_query_string() of PostJsonSearch"""  # noqa
        mock_build_qs_postjsonsearch.return_value = (
            mock_build_qs_stacsearch.return_value
        ) = (
            {
                "dataset_id": "EO:ESA:DAT:SENTINEL-2",
                "startDate": "2020-08-08",
                "completionDate": "2020-08-16",
                "bbox": [137.772897, 13.134202, 153.749135, 23.885986],
                "processing:level": "S2MSI1C",
            },
            mock.ANY,
        )

        self.wekeomain_search_plugin.query(
            prep=PreparedSearch(
                page=1,
                limit=2,
                auth_plugin=self.wekeomain_auth_plugin,
            ),
            **self.search_criteria_s2_msi_l1c,
        )

        mock__request.assert_called()
        mock_build_qs_postjsonsearch.assert_called()
        mock_build_qs_stacsearch.assert_not_called()

    def test_plugins_search_wekeosearch_search_wekeomain_ko(self):
        """A query with a parameter which is not queryable must
        raise an error if the provider does not allow it"""  # noqa
        # with raised error parameter set to True in the global config of the provider
        provider_search_plugin_config = copy_deepcopy(
            self.wekeomain_search_plugin.config
        )
        self.wekeomain_search_plugin.config.discover_metadata = {
            "raise_mtd_discovery_error": True
        }

        with self.assertRaises(ValidationError) as context:
            self.wekeomain_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.wekeomain_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.wekeomain_search_plugin.provider}",
            context.exception.message,
        )

        # with raised error parameter set to True in the config of the collection of the provider

        # first, update this parameter to False in the global config
        # to show that it is going to be taken over by this new config
        self.wekeomain_search_plugin.config.discover_metadata[
            "raise_mtd_discovery_error"
        ] = False

        self.wekeomain_search_plugin.config.products[
            self.search_criteria_s2_msi_l1c["collection"]
        ]["discover_metadata"] = {"raise_mtd_discovery_error": True}

        with self.assertRaises(ValidationError) as context:
            self.wekeomain_search_plugin.query(
                prep=PreparedSearch(
                    page=1,
                    limit=2,
                    auth_plugin=self.wekeomain_auth_plugin,
                ),
                **{**self.search_criteria_s2_msi_l1c, **{"foo": "bar"}},
            )
        self.assertEqual(
            "Search parameters which are not queryable are disallowed for this collection on this provider: "
            f"please remove 'foo' from your search parameters. Collection: "
            f"{self.search_criteria_s2_msi_l1c['collection']} / provider : {self.wekeomain_search_plugin.provider}",
            context.exception.message,
        )

        # restore the original config
        self.wekeomain_search_plugin.config = provider_search_plugin_config

    @mock.patch(
        "eodag.plugins.search.qssearch.PostJsonSearch.discover_queryables",
        autospec=True,
    )
    @mock.patch(
        "eodag.plugins.search.qssearch.StacSearch.discover_queryables", autospec=True
    )
    def test_plugins_search_postjsonsearch_discover_queryables(
        self,
        mock_stacsearch_discover_queryables,
        mock_postjsonsearch_discover_queryables,
    ):
        """Queryables discovery with a PostJsonSearchWithStacQueryables (here wekeo_main) must use discover_queryables() of StacSearch"""  # noqa
        self.wekeomain_search_plugin.discover_queryables(collection=self.collection)
        mock_stacsearch_discover_queryables.assert_called()
        mock_postjsonsearch_discover_queryables.assert_not_called()


class TestSearchPluginDedtLumi(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginDedtLumi, self).setUp()
        self.provider = "dedt_lumi"
        self.search_plugin = self.get_search_plugin(provider=self.provider)
        self.collection = "DT_CLIMATE_ADAPTATION"

    def test_plugins_apis_dedt_lumi_query_feature(self):
        """Test the proper handling of geom into ecmwf:feature"""

        # Search using geometry
        _expected_feature = {
            "shape": [[43.0, 1.0], [44.0, 1.0], [44.0, 2.0], [43.0, 2.0], [43.0, 1.0]],
            "type": "polygon",
        }
        results = self.search_plugin.query(
            collection=self.collection,
            start="2021-01-01",
            geometry={"lonmin": 1, "latmin": 43, "lonmax": 2, "latmax": 44},
        )
        eoproduct = results.data[0]

        self.assertDictEqual(_expected_feature, eoproduct.properties["qs"]["feature"])

        # Unsupported multi-polygon
        with self.assertRaises(ValidationError):
            self.search_plugin.query(
                collection=self.collection,
                start="2021-01-01",
                geometry="""MULTIPOLYGON (
                    ((1.23 43.42, 1.23 43.76, 1.68 43.76, 1.68 43.42, 1.23 43.42)),
                    ((2.23 43.42, 2.23 43.76, 3.68 43.76, 3.68 43.42, 2.23 43.42))
                )""",
            )


class TestSearchPluginCopGhslSearch(BaseSearchPluginTest):
    def setUp(self):
        super(TestSearchPluginCopGhslSearch, self).setUp()
        self.constraints = [
            {
                "year": ["2000", "2005", "2010"],
                "proj:code": ["EPSG:54009"],
                "tile_size": ["10m"],
            },
            {
                "year": ["2000", "2005", "2010"],
                "proj:code": ["EPSG:4326"],
                "tile_size": ["3ss"],
            },
        ]

    def test_plugins_search_cop_ghsl_convert_bbox(self):
        """test if bboxes given in letres are converted correctly to degrees"""
        # Mollweide coordinate system (ESRI:54009)
        bbox_mollweide = ["-6 041 000", "7 000 000", "-5 041 000", "6 000 000"]
        bbox_degrees = _convert_bbox_to_lonlat_mollweide(bbox_mollweide)
        expected_bbox = [-95.57, 61.3, -67.36, 51.21]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))
        # Mollweide point outside of map
        bbox_mollweide = ["-6 041 000", "9 000 000", "-5 041 000", "8 000 000"]
        bbox_degrees = _convert_bbox_to_lonlat_mollweide(bbox_mollweide)
        expected_bbox = [-180, 89.09, -108.89, 72.77]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))

        # ETRS89/LAEA Europe coordinate system (EPSG:3035)
        bbox_3035 = ["1,944,000", "1,042,000", "2,044,000", "942,000"]
        bbox_degrees = _convert_bbox_to_lonlat_EPSG3035(bbox_3035)
        expected_bbox = [-14.34, 28.99, -13.11, 28.39]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))
        bbox_3035 = ["4,744,000", "1,542,000", "4,844,000", "1,442,000"]
        bbox_degrees = _convert_bbox_to_lonlat_EPSG3035(bbox_3035)
        expected_bbox = [14.7, 36.83, 15.74, 35.86]
        for i, num in enumerate(bbox_degrees):
            self.assertEqual(expected_bbox[i], round(num, 2))

    def test_plugins_search_cop_ghsl_get_available_values_from_constraints(self):
        """test if get_available_values_from_contraints returns the available values for
        the given filters based on the given constraints and throws an error if no values are available"""
        # invalid parameter in filter
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints, {"bla": "1", "year": "2000"}, "PT1"
            )
        # invalid value for parameter
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {"proj:code": "EPSG:54009", "year": "2020", "tile_size": "10m"},
                "PT1",
            )
        # invalid combination
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {"proj:code": "EPSG:54009", "year": "2000", "tile_size": "3ss"},
                "PT1",
            )
        # timespan without valid value for year
        with self.assertRaises(ValidationError):
            _get_available_values_from_constraints(
                self.constraints,
                {
                    "proj:code": "EPSG:54009",
                    "year": ["2011", "2012"],
                    "tile_size": "10m",
                },
                "PT1",
            )

        # valid request for one year
        available_values = _get_available_values_from_constraints(
            self.constraints,
            {"proj:code": "EPSG:54009", "year": "2000", "tile_size": "10m"},
            "PT1",
        )
        expected_available = {
            "year": ["2000", "2005", "2010"],
            "proj:code": ["EPSG:54009"],
            "tile_size": ["10m"],
        }
        self.assertDictEqual(expected_available, available_values)
        # valid request for a time span
        available_values = _get_available_values_from_constraints(
            self.constraints,
            {
                "proj:code": "EPSG:54009",
                "year": ["1999", "2000", "2001"],
                "tile_size": "10m",
            },
            "PT1",
        )
        expected_available = {
            "year": ["2000"],
            "proj:code": ["EPSG:54009"],
            "tile_size": ["10m"],
        }
        self.assertDictEqual(expected_available, available_values)

    def test_plugins_search_cop_ghsl_replace_datetimes(self):
        """test replacement of datetime parameters in the request by year/month
        months should only be calculated if start year and end year are the same
        """
        # no year given, timespan for several years
        params = {
            "start_datetime": "2020-01-01T00:00:00Z",
            "end_datetime": "2023-01-01T00:00:00Z",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020", "2021", "2022", "2023"], params["year"])
        # different date format
        params = {"start_datetime": "2020-01-01", "end_datetime": "2023-01-01"}
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020", "2021", "2022", "2023"], params["year"])
        # datetimes and year given -> keep year
        params = {
            "start_datetime": "2020-01-01T00:00:00Z",
            "end_datetime": "2023-01-01T00:00:00Z",
            "year": "2019",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertEqual("2019", params["year"])

        # one year -> include months
        params = {
            "start_datetime": "2020-08-01T00:00:00Z",
            "end_datetime": "2020-11-01T00:00:00Z",
        }
        _replace_datetimes(params)
        self.assertIn("year", params)
        self.assertListEqual(["2020"], params["year"])
        self.assertIn("month", params)
        self.assertListEqual(["08", "09", "10", "11"], params["month"])

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    def test_plugins_search_cop_ghsl_check_input_parameters_valid(
        self, mock_fetch_constraints
    ):
        """test if the input parameters are correctly validated"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        plugin = next(self.plugins_manager.get_search_plugins(provider="cop_ghsl"))
        # missing parameter
        input_params = {"year": "2020", "proj:code": "EPSG:54009"}
        with self.assertRaises(ValidationError):
            plugin._check_input_parameters_valid("PT1", input_params)

        # valid input with one year
        input_params = {"year": "2000", "proj:code": "EPSG:54009", "tile_size": "10m"}
        plugin._check_input_parameters_valid(
            "PT1", input_params
        )  # nothing should be raised
        # valid input several years
        input_params = {
            "year": ["1999", "2000", "2001"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": ["2000"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        # year in input params should be updated
        self.assertDictEqual(expected_params, input_params)

        # valid input with grouped_by param
        constraints = [
            {
                "year": ["2000", "2005", "2010"],
                "month": ["01", "02", "03"],
                "proj:code": ["EPSG:54009"],
                "tile_size": ["10m"],
            },
            {
                "year": ["2000", "2005", "2010"],
                "month": ["01", "02", "03"],
                "proj:code": ["EPSG:4326"],
                "tile_size": ["3ss"],
            },
        ]
        mock_fetch_constraints.return_value = {"constraints": constraints}
        input_params = {
            "year": "2000",
            "month": ["02", "03", "04"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
            "grouped_by": "month",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": "2000",
            "month": ["02", "03"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        self.assertDictEqual(expected_params, input_params)
        # valid input with grouped_by param, grouping param not given
        input_params = {
            "year": "2000",
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
            "grouped_by": "month",
        }
        plugin._check_input_parameters_valid("PT1", input_params)
        expected_params = {
            "year": "2000",
            "month": ["01", "02", "03"],
            "proj:code": "EPSG:54009",
            "tile_size": "10m",
        }
        self.assertDictEqual(expected_params, input_params)

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
    def test_plugins_search_cop_ghsl_get_tiles_for_filters(
        self, mock_requests_get, mock_fetch_constraints
    ):
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        provider_tiles = {
            "grid": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "unit": "lat/lon",
        }
        mock_requests_get.return_value = MockResponse(
            json_data=provider_tiles, status_code=200
        )
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        product_type_config = deepcopy(plugin.config.products.get(collection, {}))
        input_params = {
            "year": ["2000", "2005"],
            "proj:code": "EPSG:4326",
            "tile_size": "3ss",
            "collection": collection,
        }
        tiles, unit = plugin._get_tiles_for_filters(product_type_config, input_params)
        self.assertEqual("lat/lon", unit)
        self.assertEqual(2, len(tiles))
        self.assertIn("2000", tiles)
        self.assertIn("2005", tiles)
        self.assertEqual(mock_requests_get.call_count, 2)
        ssl_verify = getattr(plugin.config, "ssl_verify", True)
        mock_requests_get.assert_has_calls(
            [
                mock.call(
                    "https://human-settlement.emergency.copernicus.eu/data/tilesDLD/tilesDLD_BUILT_2000_3ss_4326.json",
                    verify=ssl_verify,
                    timeout=5,
                    headers=USER_AGENT,
                ),
                mock.call(
                    "https://human-settlement.emergency.copernicus.eu/data/tilesDLD/tilesDLD_BUILT_2005_3ss_4326.json",
                    verify=ssl_verify,
                    timeout=5,
                    headers=USER_AGENT,
                ),
            ]
        )

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    @mock.patch("eodag.plugins.search.cop_ghsl.requests.get")
    def test_plugins_search_cop_ghsl_get_tile_from_product_id(
        self, mock_requests_get, mock_fetch_constraints
    ):
        """test fetching a tile for a product id"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        provider_tiles = {
            "grid": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "unit": "lat/lon",
        }
        mock_requests_get.return_value = MockResponse(
            json_data=provider_tiles, status_code=200
        )
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        params = {
            "collection": collection,
            "id": "GHS_BUILT_S__4326_3ss_2000_NRES__R3_C4",
        }
        tile, unit = plugin._get_tile_from_product_id(params)
        self.assertEqual("lat/lon", unit)
        self.assertEqual(1, len(tile))
        self.assertEqual(1, len(tile["2000"]))
        self.assertEqual("R3_C4", tile["2000"][0]["tileID"])

    def test_plugins_search_cop_ghsl_create_products_from_tiles(self):
        """test the creation of products based on tiles returned by the provider"""
        tiles = {
            "2000": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
            "2005": [
                {
                    "tileID": "R3_C3",
                    "BBox": ["-160.008", "69.100", "-150.008", "59.100"],
                },
                {
                    "tileID": "R3_C4",
                    "BBox": ["-150.008", "69.100", "-140.008", "59.100"],
                },
                {
                    "tileID": "R3_C5",
                    "BBox": ["-140.008", "69.100", "-130.008", "59.100"],
                },
                {
                    "tileID": "R3_C6",
                    "BBox": ["-130.008", "69.100", "-120.008", "59.100"],
                },
            ],
        }
        collection = "GHS_BUILT_S"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        product_type_config = deepcopy(plugin.config.products.get(collection, {}))
        params = product_type_config
        params["year"] = ["2000", "2005"]
        params["proj:code"] = "EPSG:4326"
        params["tile_size"] = "3ss"
        params["classification"] = "TOTAL"
        params["per_page"] = 5
        params["page"] = 1
        products, count = plugin._create_products_from_tiles(
            tiles, "lat/lon", collection, params, "classification", need_count=True
        )
        self.assertEqual(8, count)
        self.assertEqual(5, len(products))
        properties = products[0].properties
        self.assertEqual("2000-01-01T00:00:00.000Z", properties["start_datetime"])
        self.assertEqual("2000-12-31T23:59:59.000Z", properties["end_datetime"])
        self.assertEqual("2000", properties["year"])
        self.assertEqual("EPSG:4326", properties["proj:code"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_BUILT_S_GLOBE_R2023A/"
            "GHS_BUILT_S_E2000_GLOBE_R2023A_4326_3ss/V1-0/tiles/GHS_BUILT_S_E2000_GLOBE_R2023A_4326_3ss_V1_0_R3_C3.zip",
            properties["eodag:download_link"],
        )
        geometry = get_geometry_from_various(
            geometry=["-160.008", "69.100", "-150.008", "59.100"]
        )
        self.assertEqual(geometry, products[0].geometry)

    def test_plugins_search_cop_ghsl_create_products_without_tiles(self):
        """test if products are created correctly for product types without tiles"""

        prep = PreparedSearch(limit=5)
        # product type with one file
        collection = "GHS_FUA"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        plugin.config.collection_config = {
            "collection": collection,
            "extent": {
                "temporal": {
                    "interval": [["2015-01-01T00:00:00Z", "2015-12-31T00:00:00Z"]]
                }
            },
        }
        products, count = plugin._create_products_without_tiles(collection, prep, {})
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("2015-01-01T00:00:00Z", properties["start_datetime"])
        self.assertEqual("2015-12-31T00:00:00Z", properties["end_datetime"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL//GHS_FUA_UCDB2015_GLOBE_R2019A"
            "/V1-0/GHS_FUA_UCDB2015_GLOBE_R2019A_54009_1K_V1_0.zip",
            properties["eodag:download_link"],
        )
        # product type with several files
        collection = "GHS_UCDB_REGION"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        plugin.config.collection_config = {
            "collection": collection,
            "extent": {
                "temporal": {
                    "interval": [["1975-01-01T00:00:00Z", "2030-12-31T00:00:00Z"]]
                }
            },
        }
        # with filter for region
        products, count = plugin._create_products_without_tiles(
            collection, prep, {"region": "EUROPE", "grouped_by": "region"}
        )
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("1975-01-01T00:00:00Z", properties["start_datetime"])
        self.assertEqual("2030-12-31T00:00:00Z", properties["end_datetime"])
        self.assertEqual("EUROPE", properties["region"])
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_UCDB_GLOBE_R2024A/GHS_UCDB_REGION_GLOBE_R2024A"
            "/GHS_UCDB_REGION_EUROPE_R2024A/V1-1/GHS_UCDB_REGION_EUROPE_R2024A_V1_1.zip",
            properties["eodag:download_link"],
        )

        # product type with several files and assets
        collection = "GHS_ENACT_POP"
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection=collection, provider="cop_ghsl"
            )
        )
        filters = {
            "grouped_by": "month",
            "month": "02",
            "tile_size": "30ss",
            "proj:code": "EPSG:4326",
        }
        product_type_mapping = deepcopy(plugin.config.products.get(collection, {}))
        filters.update(product_type_mapping)
        products, count = plugin._create_products_without_tiles(
            collection, prep, filters
        )
        self.assertEqual(1, count)
        self.assertEqual(1, len(products))
        properties = products[0].properties
        self.assertEqual("2011-02-01T00:00:00.000Z", properties["start_datetime"])
        self.assertEqual("2011-02-28T23:59:59.000Z", properties["end_datetime"])
        self.assertEqual("2011", properties["year"])
        self.assertEqual("02", properties["month"])
        assets = products[0].assets
        self.assertEqual(2, len(assets))
        self.assertIn("day", assets)
        self.assertEqual(
            "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/ENACT/ENACT_POP_2011_EU28_R2020A"
            "/ENACT_POP_D022011_EU28_R2020A_4326_30ss/V1-0/ENACT_POP_D022011_EU28_R2020A_4326_30ss_V1_0.zip",
            assets["day"]["href"],
        )
        self.assertIn("night", assets)

    @mock.patch("eodag.plugins.search.cop_ghsl.CopGhslSearch._fetch_constraints")
    def test_plugins_search_cop_ghsl_discover_queryables(self, mock_fetch_constraints):
        """test that the correct queryables are returned"""
        mock_fetch_constraints.return_value = {"constraints": self.constraints}
        plugin = next(
            self.plugins_manager.get_search_plugins(
                collection="GHS_BUILT_S", provider="cop_ghsl"
            )
        )
        kwargs = {"collection": "GHS_BUILT_S"}
        collection_config = plugin.config.products.get("GHS_BUILT_S", {})
        kwargs.update(collection_config)
        kwargs.pop("metadata_mapping")
        queryables = plugin.discover_queryables(**kwargs)
        self.assertEqual(6, len(queryables))
        expected_queryables = [
            "year",
            "tile_size",
            "proj:code",
            "start",
            "end",
            "geom",
        ]
        for qu in expected_queryables:
            self.assertIn(qu, queryables)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m", "3ss"], get_args(tile_size_queryable))

        # test filtering queryables
        kwargs["proj:code"] = "EPSG:54009"
        queryables = plugin.discover_queryables(**kwargs)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m"], get_args(tile_size_queryable))
        # with array
        kwargs["proj:code"] = ["EPSG:54009"]
        queryables = plugin.discover_queryables(**kwargs)
        tile_size_queryable = queryables["tile_size"]
        self.assertIn(Literal["10m"], get_args(tile_size_queryable))

        # product type without geometry filter
        constraints = deepcopy(self.constraints)
        for constraint in constraints:
            constraint["year"] = ["2011"]
            constraint["month"] = ["01", "02"]
        mock_fetch_constraints.return_value = {"constraints": constraints}
        kwargs = {"collection": "GHS_ENACT_POP"}
        collection_config = plugin.config.products.get("GHS_ENACT_POP", {})
        kwargs.update(collection_config)
        kwargs.pop("metadata_mapping")
        kwargs.pop("assets_mapping")
        queryables = plugin.discover_queryables(**kwargs)
        self.assertEqual(6, len(queryables))
        expected_queryables = [
            "year",
            "tile_size",
            "proj:code",
            "month",
            "start",
            "end",
        ]
        for qu in expected_queryables:
            self.assertIn(qu, queryables)


class TestSearchPluginEumetsatDsSearch(BaseSearchPluginTest):
    def test_plugins_search_eumetsatds_normalize(self):
        """Test the normalization of search results for eumetsat_ds provider"""
        with open(
            self.provider_resp_dir / "eumetsat_ds_search.json", encoding="utf-8"
        ) as f:
            raw_features = json.load(f)["features"]

        search_plugin = self.get_search_plugin(provider="eumetsat_ds")
        normalized = search_plugin.normalize_results(RawSearchResult(raw_features))

        self.assertTrue(len(normalized) > 0)
        base_href = (
            "https://api.eumetsat.int/data/download/1.0.0/collections/"
            "EO%3AEUM%3ADAT%3A0921/products/PREmm20201201000000120IMPGS01GL/"
            "entry?name="
        )
        self.assertDictEqual(
            dict(normalized[0].assets),
            {
                "EOPMetadata.xml": {
                    "href": base_href + "EOPMetadata.xml",
                    "title": "EOPMetadata.xml",
                    "roles": ["metadata"],
                    "eumesat_ds:type": "Link",
                    "type": "application/xml",
                },
                "manifest.xml": {
                    "href": base_href + "manifest.xml",
                    "title": "manifest.xml",
                    "roles": ["metadata"],
                    "eumesat_ds:type": "Link",
                    "type": "application/xml",
                },
                "nc": {
                    "href": base_href + "PREmm20201201000000120IMPGS01GL.nc",
                    "title": "nc",
                    "roles": ["data"],
                    "eumesat_ds:type": "Link",
                    "type": "application/x-netcdf",
                },
            },
        )
