Repository: CS-SI/eodag
Pull request #1400 (2024-11-14 13:31:04)

fix(plugins): remove default HTTPDownload zip extension
- [x] Remove default `.zip` extensions used when a file to download extension is unknown.
- [x] In `HTTPDownload`, use the value obtained with  `_check_product_filename()` to set the downloaded file extension.
- [x] remove `output_extension` from `providers.yaml`

github-actions[bot]: ## Test Results
    4 files  ±0      4 suites  ±0   5m 59s ⏱️ -1s
  572 tests  - 1    569 ✅  - 1   3 💤 ±0  0 ❌ ±0 
2 288 runs   - 4  2 192 ✅  - 4  96 💤 ±0  0 ❌ ±0 

Results for commit ca37e61e. ± Comparison against base commit 175e167d.

<details>
  <summary>This pull request <b>removes</b> 1 test.</summary>

```
tests.units.test_download_plugins.TestDownloadPluginHttp ‑ test_plugins_download_http_tar_file_with_zip_extension_ok
```
</details>

[test-results]:data:application/gzip;base64,H4sIAPT8NWcC/03MyQ7CIBSF4VdpWLsoUCZfxiBDcmNbDMPK+O6CFuzy/27ueSEPq0voOi2XCaUCeYQtUWcIe03KVIV6yu3IBOl1S8WYRlz96QHP9jPAa1grzANcjCEeEsveNgmR8qi+SbAig36binc4bX77PGnCtkGugYymwnHsmFwU9nZmWlArMKWWWXznXmqOCTcMvT/dfcbnCAEAAA==

github-actions[bot]: ![badge](https://raw.githubusercontent.com/CS-SI/eodag/_xml_coverage_reports/data/1398-remove-default-download-zip-extension/./badge.svg)


## Code Coverage (Ubuntu)

<details>


```
Filename                                     Stmts    Miss  Cover    Missing
-----------------------------------------  -------  ------  -------  --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
__init__.py                                      8       0  100.00%
cli.py                                         314      59  81.21%   657-716, 818-869, 873
config.py                                      429      27  93.71%   83-85, 94, 102, 106-108, 179, 190, 695-697, 810-813, 856-857, 866-867, 972, 1035-1040, 1042
crunch.py                                        5       5  0.00%    20-24
api/__init__.py                                  0       0  100.00%
api/core.py                                    767      69  91.00%   374, 664, 708-711, 749, 793, 827, 872-877, 903, 994, 1062, 1200, 1285-1297, 1333, 1335, 1363, 1367-1378, 1391-1397, 1480-1483, 1516-1536, 1588, 1605-1609, 1621-1624, 1980, 2004-2010, 2261, 2265-2269, 2278-2280, 2312
api/search_result.py                            58       4  93.10%   92, 101, 108, 122
api/product/__init__.py                          6       0  100.00%
api/product/_assets.py                          48       5  89.58%   75, 147, 155, 158-162
api/product/_product.py                        187      20  89.30%   70-72, 237-238, 313, 342, 399, 413-416, 429, 453-456, 499-505
api/product/metadata_mapping.py                680      69  89.85%   130-132, 227, 259-260, 306-307, 317-329, 331, 342, 348-360, 405-406, 443, 464-467, 490, 498-499, 575-576, 600-601, 607-610, 625-626, 775, 821, 976, 992-997, 1124, 1138-1158, 1178, 1183, 1312, 1326, 1397, 1449, 1489-1493, 1508
api/product/drivers/__init__.py                  6       0  100.00%
api/product/drivers/base.py                      6       1  83.33%   38
plugins/__init__.py                              0       0  100.00%
plugins/base.py                                 21       2  90.48%   48, 55
plugins/manager.py                             172      15  91.28%   116-121, 171, 209, 231, 235, 259, 281, 399-402, 414-415
plugins/apis/__init__.py                         0       0  100.00%
plugins/apis/base.py                             4       0  100.00%
plugins/apis/ecmwf.py                           91       8  91.21%   159-161, 208-209, 235-237
plugins/apis/usgs.py                           180      31  82.78%   155, 257, 291, 326-328, 333, 359-360, 365, 395-402, 413-418, 440-446, 448-454, 477
plugins/authentication/__init__.py               6       1  83.33%   31
plugins/authentication/aws_auth.py              19       0  100.00%
plugins/authentication/base.py                  17       2  88.24%   43, 56
plugins/authentication/generic.py               14       2  85.71%   51, 61
plugins/authentication/header.py                19       0  100.00%
plugins/authentication/keycloak.py              46       7  84.78%   151-154, 175-180
plugins/authentication/oauth.py                 13       7  46.15%   42-44, 48-51
plugins/authentication/openid_connect.py       206      27  86.89%   80-81, 93-111, 149, 155-183, 191, 323-326, 352
plugins/authentication/qsauth.py                34       1  97.06%   91
plugins/authentication/sas_auth.py              47       1  97.87%   76
plugins/authentication/token.py                 92      16  82.61%   114, 143, 145, 176-189, 245-249
plugins/authentication/token_exchange.py        36      14  61.11%   75, 92-120
plugins/crunch/__init__.py                       0       0  100.00%
plugins/crunch/base.py                          10       1  90.00%   43
plugins/crunch/filter_date.py                   59      14  76.27%   52-57, 69, 78, 87, 90, 102-104, 113-115, 122
plugins/crunch/filter_latest_intersect.py       47       8  82.98%   52-53, 69, 78-81, 83, 90-93
plugins/crunch/filter_latest_tpl_name.py        31       1  96.77%   83
plugins/crunch/filter_overlap.py                66      18  72.73%   28-30, 66-69, 76-79, 85, 93, 104-120
plugins/crunch/filter_property.py               30       7  76.67%   55-60, 63-64, 80-84
plugins/download/__init__.py                     0       0  100.00%
plugins/download/aws.py                        491     163  66.80%   282, 295, 362-365, 379-383, 425-427, 431, 463-464, 470-474, 503, 535, 539, 546, 576-584, 588, 620-628, 639-641, 672-746, 764-824, 835-840, 852-865, 890, 905-907, 910, 920-928, 936-949, 959-990, 997-1009, 1047, 1073, 1118-1120, 1340
plugins/download/base.py                       255      54  78.82%   136, 164, 240-243, 296-297, 314-320, 345, 354-358, 364-365, 407, 410-424, 436, 440, 504-508, 538-539, 547-564, 571-579, 581-585, 628, 650, 672, 680
plugins/download/creodias_s3.py                 17       9  47.06%   53-67
plugins/download/http.py                       525     106  79.81%   236, 332-335, 337-338, 345-350, 368-383, 400-402, 414, 462, 469-475, 493, 507, 521, 529-531, 547-552, 563, 581, 624-627, 656, 724-730, 759-823, 841, 871-880, 902-903, 932-937, 943, 946, 963, 978-979, 1011-1012, 1019, 1081, 1096, 1155-1156, 1162, 1172, 1208, 1244, 1264, 1299-1301
plugins/download/s3rest.py                     116      24  79.31%   118, 154, 161, 196, 223-230, 233-235, 239, 250-256, 264-265, 268-272, 295, 316-319
plugins/search/__init__.py                      22       0  100.00%
plugins/search/base.py                         132      13  90.15%   100, 104, 115, 274, 294, 350-351, 371, 374-382
plugins/search/build_search_result.py          188      24  87.23%   100, 141-142, 148, 159, 292-295, 324, 359-376, 425, 454, 457, 467, 484, 512, 514
plugins/search/cop_marine.py                   240      47  80.42%   56, 64-66, 76-77, 82, 87-88, 104, 106, 109, 175-176, 218, 224, 228, 232, 245, 256-257, 265, 293, 297, 312, 316, 320, 324, 328-332, 338-341, 344-358, 375, 424-428, 433, 445
plugins/search/creodias_s3.py                   55       3  94.55%   58, 76, 110
plugins/search/csw.py                          105      81  22.86%   98-99, 103-104, 112-160, 166-179, 187-219, 237-278
plugins/search/data_request_search.py          202      69  65.84%   188-191, 207, 218, 222-223, 234, 239, 244, 251, 264-267, 321-322, 326, 336-342, 347, 373-376, 384-395, 412, 414, 421-424, 426-427, 445-449, 482, 492, 503, 516, 522-537, 542
plugins/search/qssearch.py                     774     101  86.95%   458, 516, 530, 534-540, 568-572, 681-693, 737, 753, 763, 782-797, 834-837, 908-909, 957, 976, 983, 995, 1052, 1073, 1076-1077, 1086-1087, 1096-1097, 1106-1107, 1134, 1205-1210, 1214-1223, 1257, 1279, 1356, 1446, 1533, 1536-1537, 1619-1623, 1685, 1688, 1694-1695, 1716, 1743-1755, 1762, 1794-1796, 1806-1812, 1842, 1865, 1870-1871, 1886, 1902, 1987-1990, 1995-1998, 2007, 2037-2041, 2047
plugins/search/static_stac_search.py            75      13  82.67%   98-124, 140, 153
rest/__init__.py                                 4       2  50.00%   21-22
rest/cache.py                                   33       7  78.79%   35-37, 53-55, 59, 68
rest/config.py                                  25       0  100.00%
rest/constants.py                                6       0  100.00%
rest/core.py                                   251      63  74.90%   216-217, 273, 281, 299-316, 331-369, 460, 502-533, 680, 687-735
rest/errors.py                                  69       5  92.75%   106, 116, 127, 143-144
rest/server.py                                 188      24  87.23%   89, 112-114, 277-282, 310, 495-497, 514-519, 548, 550, 554-555, 559-560
rest/stac.py                                   319      63  80.25%   306, 328, 380-383, 410-437, 468-470, 493, 525-526, 608-648, 670-686, 778-782, 789, 843-844, 905, 995-997
rest/types/__init__.py                           0       0  100.00%
rest/types/collections_search.py                13      13  0.00%    18-44
rest/types/eodag_search.py                     176       5  97.16%   225-229, 282, 285, 353
rest/types/queryables.py                        55       1  98.18%   163
rest/types/stac_search.py                      125       7  94.40%   138, 184, 199-201, 209, 213
rest/utils/__init__.py                          93      12  87.10%   108-109, 128-130, 182, 192-206
rest/utils/cql_evaluate.py                      48       5  89.58%   69, 76, 90, 97, 105
rest/utils/rfc3339.py                           23       3  86.96%   48, 60, 62
types/__init__.py                              114      14  87.72%   65, 82, 141-144, 211, 225-234, 244, 265, 278
types/bbox.py                                   43      19  55.81%   46-61, 72-74, 85-87, 99-101, 113-115, 123
types/download_args.py                           9       0  100.00%
types/queryables.py                             80       0  100.00%
types/search_args.py                            70      18  74.29%   60-64, 71-88, 103
types/whoosh.py                                 15       0  100.00%
utils/__init__.py                              502      38  92.43%   85, 193-194, 203-230, 233, 248, 328-332, 405-409, 428-430, 509, 524, 564-565, 929-932, 940-941, 979-980, 1027-1028, 1151
utils/constraints.py                           119      38  68.07%   62, 89-98, 139, 144, 148, 159, 182-184, 194, 208-224, 233-244
utils/exceptions.py                             41       0  100.00%
utils/import_system.py                          28      19  32.14%   64-78, 89-99
utils/logging.py                                28       1  96.43%   41
utils/notebook.py                               44      23  47.73%   25-29, 36-41, 58-62, 72-78, 83-87
utils/repr.py                                   30       8  73.33%   36, 38, 42, 76, 94-101
utils/requests.py                               55      11  80.00%   64, 86, 88, 90, 92, 94, 110, 118-120, 128
utils/rest.py                                   36       1  97.22%   55
utils/stac_reader.py                           111      45  59.46%   56-57, 63-85, 95-97, 101, 137, 153-156, 203-212, 222-252
TOTAL                                         9724    1594  83.61%
```

</details>


### Diff against develop

```
Filename                    Stmts    Miss  Cover
------------------------  -------  ------  -------
plugins/download/base.py       +2      +3  -1.02%
plugins/download/http.py      -12      -2  -0.08%
TOTAL                         -10      +1  -0.02%
```


Results for commit: ca37e61e58491fd05a73d7133d5d1b6f8a6126c5


_Minimum allowed coverage is `70%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentartifacts/unit-test-results-python3.13-ubuntu-latest/coverage.xml -->

Patch:
diff --git a/docs/notebooks/tutos/tuto_cds.ipynb b/docs/notebooks/tutos/tuto_cds.ipynb
index da2a735d..138ea3f3 100644
--- a/docs/notebooks/tutos/tuto_cds.ipynb
+++ b/docs/notebooks/tutos/tuto_cds.ipynb
@@ -752,7 +752,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.12.5"
+   "version": "3.13.0"
   },
   "widgets": {
    "application/vnd.jupyter.widget-state+json": {
diff --git a/docs/notebooks/tutos/tuto_ecmwf.ipynb b/docs/notebooks/tutos/tuto_ecmwf.ipynb
index 7aa7418e..dd99afd2 100644
--- a/docs/notebooks/tutos/tuto_ecmwf.ipynb
+++ b/docs/notebooks/tutos/tuto_ecmwf.ipynb
@@ -767,7 +767,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.10"
+   "version": "3.13.0"
   },
   "widgets": {
    "application/vnd.jupyter.widget-state+json": {
diff --git a/eodag/api/product/_product.py b/eodag/api/product/_product.py
index 8d1d6fdc..f7bd1c75 100644
--- a/eodag/api/product/_product.py
+++ b/eodag/api/product/_product.py
@@ -116,6 +116,7 @@ class EOProduct:
     properties: Dict[str, Any]
     product_type: Optional[str]
     location: str
+    filename: str
     remote_location: str
     search_kwargs: Any
     geometry: BaseGeometry
diff --git a/eodag/plugins/download/base.py b/eodag/plugins/download/base.py
index f1f48eb1..be2466dc 100644
--- a/eodag/plugins/download/base.py
+++ b/eodag/plugins/download/base.py
@@ -202,8 +202,8 @@ class Download(PluginTopic):
             or getattr(self.config, "output_dir", tempfile.gettempdir())
             or tempfile.gettempdir()
         )
-        output_extension = kwargs.get("output_extension", None) or getattr(
-            self.config, "output_extension", ".zip"
+        output_extension = kwargs.get("output_extension") or getattr(
+            self.config, "output_extension", ""
         )
 
         # Strong asumption made here: all products downloaded will be zip files
@@ -343,13 +343,7 @@ class Download(PluginTopic):
             if delete_archive is not None
             else getattr(self.config, "delete_archive", True)
         )
-        output_extension = kwargs.pop("output_extension", ".zip")
-
-        product_path = (
-            fs_path[: fs_path.index(output_extension)]
-            if output_extension in fs_path
-            else fs_path
-        )
+        product_path, _ = os.path.splitext(fs_path)
         product_path_exists = os.path.exists(product_path)
         if product_path_exists and os.path.isfile(product_path):
             logger.info(
diff --git a/eodag/plugins/download/http.py b/eodag/plugins/download/http.py
index 0f7e48e9..5d256f9f 100644
--- a/eodag/plugins/download/http.py
+++ b/eodag/plugins/download/http.py
@@ -27,6 +27,7 @@ from datetime import datetime
 from email.message import Message
 from itertools import chain
 from json import JSONDecodeError
+from pathlib import Path
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -122,8 +123,6 @@ class HTTPDownload(Download):
           default: ``5``
         * :attr:`~eodag.config.PluginConfig.ssl_verify` (``bool``): if the ssl certificates should be verified in
           requests; default: ``True``
-        * :attr:`~eodag.config.PluginConfig.output_extension` (``str``): which extension should be used for the
-          downloaded file
         * :attr:`~eodag.config.PluginConfig.no_auth_download` (``bool``): if the download should be done without
           authentication; default: ``True``
         * :attr:`~eodag.config.PluginConfig.order_enabled` (``bool``): if the product has to be ordered to download it;
@@ -140,9 +139,8 @@ class HTTPDownload(Download):
           configuration to handle the order status; contains information which method to use, how the response data is
           interpreted, which status corresponds to success, ordered and error and what should be done on success.
         * :attr:`~eodag.config.PluginConfig.products` (``Dict[str, Dict[str, Any]``): product type specific config; the
-          keys are the product types, the values are dictionaries which can contain the keys
-          :attr:`~eodag.config.PluginConfig.output_extension` and :attr:`~eodag.config.PluginConfig.extract` to
-          overwrite the provider config for a specific product type
+          keys are the product types, the values are dictionaries which can contain the key
+          :attr:`~eodag.config.PluginConfig.extract` to overwrite the provider config for a specific product type
 
     """
 
@@ -585,13 +583,6 @@ class HTTPDownload(Download):
             )
             progress_callback = ProgressCallback(disable=True)
 
-        output_extension = getattr(self.config, "products", {}).get(
-            product.product_type, {}
-        ).get("output_extension", None) or getattr(
-            self.config, "output_extension", ".zip"
-        )
-        kwargs["output_extension"] = kwargs.get("output_extension", output_extension)
-
         fs_path, record_filename = self._prepare_download(
             product,
             progress_callback=progress_callback,
@@ -610,7 +601,7 @@ class HTTPDownload(Download):
             try:
                 fs_path = self._download_assets(
                     product,
-                    fs_path.replace(".zip", ""),
+                    fs_path,
                     record_filename,
                     auth,
                     progress_callback,
@@ -635,74 +626,54 @@ class HTTPDownload(Download):
             wait: float,
             timeout: float,
             **kwargs: Unpack[DownloadConf],
-        ) -> None:
-            chunks = self._stream_download(product, auth, progress_callback, **kwargs)
+        ) -> os.PathLike:
             is_empty = True
+            chunk_iterator = self._stream_download(
+                product, auth, progress_callback, **kwargs
+            )
+            if fs_path is not None:
+                ext = Path(product.filename).suffix
+                path = Path(fs_path).with_suffix(ext)
 
-            with open(fs_path, "wb") as fhandle:
-                for chunk in chunks:
-                    is_empty = False
-                    fhandle.write(chunk)
+                with open(path, "wb") as fhandle:
+                    for chunk in chunk_iterator:
+                        is_empty = False
+                        progress_callback(len(chunk))
+                        fhandle.write(chunk)
+                self.stream.close()  # Closing response stream
 
-            if is_empty:
-                raise DownloadError(f"product {product.properties['id']} is empty")
+                if is_empty:
+                    raise DownloadError(f"product {product.properties['id']} is empty")
 
-        download_request(product, auth, progress_callback, wait, timeout, **kwargs)
+                return path
+            else:
+                raise DownloadError(
+                    f"download of product {product.properties['id']} failed"
+                )
+
+        path = download_request(
+            product, auth, progress_callback, wait, timeout, **kwargs
+        )
 
         with open(record_filename, "w") as fh:
             fh.write(url)
         logger.debug("Download recorded in %s", record_filename)
 
-        # Check that the downloaded file is really a zip file
-        if not zipfile.is_zipfile(fs_path) and output_extension == ".zip":
-            logger.warning(
-                "Downloaded product is not a Zip File. Please check its file type before using it"
-            )
-            new_fs_path = os.path.join(
-                os.path.dirname(fs_path),
-                sanitize(product.properties["title"]),
-            )
-            if os.path.isfile(fs_path) and not tarfile.is_tarfile(fs_path):
-                if not os.path.isdir(new_fs_path):
-                    os.makedirs(new_fs_path)
-                shutil.move(fs_path, new_fs_path)
-                file_path = os.path.join(new_fs_path, os.path.basename(fs_path))
-                new_file_path = file_path[: file_path.index(".zip")]
-                shutil.move(file_path, new_file_path)
-            # in the case where the outputs extension has not been set
-            # to ".tar" in the product type nor provider configuration
-            elif tarfile.is_tarfile(fs_path):
-                if not new_fs_path.endswith(".tar"):
-                    new_fs_path += ".tar"
-                shutil.move(fs_path, new_fs_path)
-                kwargs["output_extension"] = ".tar"
-                product_path = self._finalize(
-                    new_fs_path,
-                    progress_callback=progress_callback,
-                    **kwargs,
-                )
-                product.location = path_to_uri(product_path)
-                return product_path
-            else:
-                # not a file (dir with zip extension)
-                shutil.move(fs_path, new_fs_path)
-            product.location = path_to_uri(new_fs_path)
-            return new_fs_path
-
-        if os.path.isfile(fs_path) and not (
-            zipfile.is_zipfile(fs_path) or tarfile.is_tarfile(fs_path)
+        if os.path.isfile(path) and not (
+            zipfile.is_zipfile(path) or tarfile.is_tarfile(path)
         ):
             new_fs_path = os.path.join(
-                os.path.dirname(fs_path),
+                os.path.dirname(path),
                 sanitize(product.properties["title"]),
             )
             if not os.path.isdir(new_fs_path):
                 os.makedirs(new_fs_path)
-            shutil.move(fs_path, new_fs_path)
+            shutil.move(path, new_fs_path)
             product.location = path_to_uri(new_fs_path)
             return new_fs_path
+
         product_path = self._finalize(
-            fs_path,
+            str(path),
             progress_callback=progress_callback,
             **kwargs,
         )
@@ -743,15 +714,6 @@ class HTTPDownload(Download):
                     ext = guess_extension(content_type)
                     if ext:
                         filename += ext
-                else:
-                    output_extension: Optional[str] = (
-                        getattr(self.config, "products", {})
-                        .get(product.product_type, {})
-                        .get("output_extension")
-                    )
-                    if output_extension:
-                        filename += output_extension
-
         return filename
 
     def _stream_download_dict(
@@ -834,17 +796,20 @@ class HTTPDownload(Download):
                 else:
                     pass
 
-        chunks = self._stream_download(product, auth, progress_callback, **kwargs)
+        chunk_iterator = self._stream_download(
+            product, auth, progress_callback, **kwargs
+        )
+
         # start reading chunks to set product.headers
         try:
-            first_chunk = next(chunks)
+            first_chunk = next(chunk_iterator)
         except StopIteration:
             # product is empty file
             logger.error("product %s is empty", product.properties["id"])
             raise NotAvailableError(f"product {product.properties['id']} is empty")
 
         return StreamResponse(
-            content=chain(iter([first_chunk]), chunks),
+            content=chain(iter([first_chunk]), chunk_iterator),
             headers=product.headers,
         )
 
@@ -998,7 +963,7 @@ class HTTPDownload(Download):
             auth = None
 
         s = requests.Session()
-        with s.request(
+        self.stream = s.request(
             req_method,
             req_url,
             stream=True,
@@ -1008,48 +973,46 @@ class HTTPDownload(Download):
             timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT,
             verify=ssl_verify,
             **req_kwargs,
-        ) as self.stream:
-            try:
-                self.stream.raise_for_status()
-            except requests.exceptions.Timeout as exc:
-                raise TimeOutError(
-                    exc, timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT
-                ) from exc
-            except RequestException as e:
-                self._process_exception(e, product, ordered_message)
-            else:
-                # check if product was ordered
-
-                if getattr(
-                    self.stream, "status_code", None
-                ) is not None and self.stream.status_code == getattr(
-                    self.config, "order_status", {}
-                ).get(
-                    "ordered", {}
-                ).get(
-                    "http_code"
-                ):
-                    product.properties["storageStatus"] = "ORDERED"
-                    self._process_exception(None, product, ordered_message)
-                stream_size = self._check_stream_size(product) or None
-
-                product.headers = self.stream.headers
-                filename = self._check_product_filename(product) or None
-                product.headers[
-                    "content-disposition"
-                ] = f"attachment; filename={filename}"
-                content_type = product.headers.get("Content-Type")
-                guessed_content_type = (
-                    guess_file_type(filename) if filename and not content_type else None
-                )
-                if guessed_content_type is not None:
-                    product.headers["Content-Type"] = guessed_content_type
+        )
+        try:
+            self.stream.raise_for_status()
+        except requests.exceptions.Timeout as exc:
+            raise TimeOutError(exc, timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT) from exc
+        except RequestException as e:
+            self._process_exception(e, product, ordered_message)
+            raise DownloadError(
+                f"download of {product.properties['id']} is empty"
+            ) from e
+        else:
+            # check if product was ordered
+
+            if getattr(
+                self.stream, "status_code", None
+            ) is not None and self.stream.status_code == getattr(
+                self.config, "order_status", {}
+            ).get(
+                "ordered", {}
+            ).get(
+                "http_code"
+            ):
+                product.properties["storageStatus"] = "ORDERED"
+                self._process_exception(None, product, ordered_message)
+            stream_size = self._check_stream_size(product) or None
+
+            product.headers = self.stream.headers
+            filename = self._check_product_filename(product)
+            product.headers["content-disposition"] = f"attachment; filename={filename}"
+            content_type = product.headers.get("Content-Type")
+            guessed_content_type = (
+                guess_file_type(filename) if filename and not content_type else None
+            )
+            if guessed_content_type is not None:
+                product.headers["Content-Type"] = guessed_content_type
 
-                progress_callback.reset(total=stream_size)
-                for chunk in self.stream.iter_content(chunk_size=64 * 1024):
-                    if chunk:
-                        progress_callback(len(chunk))
-                        yield chunk
+            progress_callback.reset(total=stream_size)
+
+            product.filename = filename
+            return self.stream.iter_content(chunk_size=64 * 1024)
 
     def _stream_download_assets(
         self,
diff --git a/eodag/resources/providers.yml b/eodag/resources/providers.yml
index d225aedc..bcd71616 100644
--- a/eodag/resources/providers.yml
+++ b/eodag/resources/providers.yml
@@ -55,31 +55,22 @@
     # see also https://dds.cr.usgs.gov/ee-data/coveragemaps/shp/ee/
     LANDSAT_C2L1:
       dataset: landsat_ot_c2_l1
-      output_extension: .tar.gz
     LANDSAT_C2L2:
       dataset: landsat_ot_c2_l2
-      output_extension: .tar.gz
     LANDSAT_TM_C1:
       dataset: landsat_tm_c1
-      output_extension: .tar.gz
     LANDSAT_TM_C2L1:
       dataset: landsat_tm_c2_l1
-      output_extension: .tar.gz
     LANDSAT_TM_C2L2:
       dataset: landsat_tm_c2_l2
-      output_extension: .tar.gz
     LANDSAT_ETM_C1:
       dataset: landsat_etm_c1
-      output_extension: .tar.gz
     LANDSAT_ETM_C2L1:
       dataset: landsat_etm_c2_l1
-      output_extension: .tar.gz
     LANDSAT_ETM_C2L2:
       dataset: landsat_etm_c2_l2
-      output_extension: .tar.gz
     S2_MSI_L1C:
       dataset: SENTINEL_2A
-      output_extension: .zip
     GENERIC_PRODUCT_TYPE:
       dataset: '{productType}'
 
@@ -1929,7 +1920,6 @@
     type: HTTPDownload
     timeout: 30
     ssl_verify: true
-    extract: false
     auth_error_code: 401
     order_enabled: true
     order_method: POST
@@ -1951,37 +1941,6 @@
         need_search: true
         metadata_mapping:
           downloadLink: $.json.asset.value.href
-    products:
-      CAMS_GAC_FORECAST:
-        output_extension: .grib
-      CAMS_GFE_GFAS:
-        output_extension: .grib
-      CAMS_EU_AIR_QUALITY_FORECAST:
-        output_extension: .grib
-      CAMS_EU_AIR_QUALITY_RE:
-        output_extension: .zip
-        extract: true
-      CAMS_GRF:
-        output_extension: .zip
-        extract: true
-      CAMS_GRF_AUX:
-        output_extension: .zip
-        extract: true
-      CAMS_SOLAR_RADIATION:
-        output_extension: .csv
-      CAMS_GREENHOUSE_EGG4_MONTHLY:
-        output_extension: .grib
-      CAMS_GREENHOUSE_EGG4:
-        output_extension: .grib
-      CAMS_GREENHOUSE_INVERSION:
-        output_extension: .grib
-      CAMS_GLOBAL_EMISSIONS:
-        output_extension: .zip
-        extract: true
-      CAMS_EAC4:
-        output_extension: .grib
-      CAMS_EAC4_MONTHLY:
-        output_extension: .grib
   search: !plugin
     type: ECMWFSearch
     ssl_verify: true
@@ -2185,7 +2144,6 @@
     type: HTTPDownload
     timeout: 30
     ssl_verify: true
-    extract: false
     auth_error_code: 401
     order_enabled: true
     order_method: POST
@@ -2207,39 +2165,6 @@
         need_search: true
         metadata_mapping:
           downloadLink: $.json.asset.value.href
-    products:
-      AG_ERA5:
-        output_extension: .zip
-        extract: true
-      UERRA_EUROPE_SL:
-        output_extension: .grib
-      GLACIERS_DIST_RANDOLPH:
-        output_extension: .zip
-        extract: true
-      SATELLITE_CARBON_DIOXIDE:
-        output_extension: .zip
-        extract: true
-      SATELLITE_FIRE_BURNED_AREA:
-        output_extension: .zip
-        extract: true
-      SATELLITE_METHANE:
-        output_extension: .zip
-        extract: true
-      SEASONAL_POSTPROCESSED_PL:
-        output_extension: .grib
-      SEASONAL_POSTPROCESSED_SL:
-        output_extension: .grib
-      SEASONAL_ORIGINAL_SL:
-        output_extension: .grib
-      SEASONAL_ORIGINAL_PL:
-        output_extension: .grib
-      SEASONAL_MONTHLY_PL:
-        output_extension: .grib
-      SEASONAL_MONTHLY_SL:
-        output_extension: .grib
-      SIS_HYDRO_MET_PROJ:
-        output_extension: .zip
-        extract: true
   search: !plugin
     type: ECMWFSearch
     ssl_verify: true
@@ -2872,7 +2797,6 @@
           orderId: '$.json.id'
           downloadLink: 'http://queueresults.meteoblue.com/{orderId}'
           downloadMethod: '{$.null#replace_str("Not Available","GET")}'
-    output_extension: .nc
   auth: !plugin
     type: HttpQueryStringAuth
     matching_url: https?://[a-z]+.meteoblue.com
@@ -5472,9 +5396,7 @@
     no_auth_download: True
     products:
       DT_EXTREMES:
-        output_extension: .grib
       DT_CLIMATE_ADAPTATION:
-        output_extension: .grib
   auth: !plugin
     type: OIDCAuthorizationCodeFlowAuth
     matching_url: https://[-\w\.]+.dte.destination-earth.eu
@@ -5558,13 +5480,6 @@
       on_success:
         metadata_mapping:
           downloadLink: '{orderStatusLink}'
-    products:
-      DT_EXTREMES:
-        output_extension: .grib
-      DT_CLIMATE_ADAPTATION:
-        output_extension: .grib
-      FIRE_HISTORICAL:
-        output_extension: .grib
   auth: !plugin
     type: OIDCTokenExchangeAuth
     matching_url: https://[-\w\.]+.data.destination-earth.eu
@@ -6648,9 +6563,6 @@
         need_search: true
         metadata_mapping:
           downloadLink: $.json.asset.value.href
-    products:
-      FIRE_HISTORICAL:
-        output_extension: .grib
   search: !plugin
     type: ECMWFSearch
     ssl_verify: true
diff --git a/eodag/types/download_args.py b/eodag/types/download_args.py
index 5fe048ba..a4d1e499 100644
--- a/eodag/types/download_args.py
+++ b/eodag/types/download_args.py
@@ -17,7 +17,7 @@
 # limitations under the License.
 from __future__ import annotations
 
-from typing import Dict, Optional, TypedDict
+from typing import Dict, Optional, TypedDict, Union
 
 
 class DownloadConf(TypedDict, total=False):
@@ -33,7 +33,7 @@ class DownloadConf(TypedDict, total=False):
     """
 
     output_dir: str
-    output_extension: str
+    output_extension: Union[str, None]
     extract: bool
     dl_url_params: Dict[str, str]
     delete_archive: bool


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index 3d02f228..fd939c31 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -25,6 +25,7 @@ import tempfile
 import unittest
 from collections import OrderedDict, namedtuple
 from io import StringIO
+from pathlib import Path
 from unittest import mock  # PY3
 
 from owslib.etree import etree
@@ -304,12 +305,12 @@ class EODagTestCase(unittest.TestCase):
     ):
         self._set_download_simulation()
         self.tmp_download_dir = tempfile.TemporaryDirectory()
+        if output_dir is None:
+            output_dir = str(Path(self.tmp_download_dir.name).parent)
         dl_config = config.PluginConfig.from_mapping(
             {
                 "base_uri": "fake_base_uri" if base_uri is None else base_uri,
-                "output_dir": self.tmp_download_dir.name
-                if output_dir is None
-                else output_dir,
+                "output_dir": output_dir,
                 "extract": True if extract is None else extract,
                 "delete_archive": False if delete_archive is None else delete_archive,
             }
@@ -321,6 +322,8 @@ class EODagTestCase(unittest.TestCase):
         return product
 
     def _clean_product(self, product_path):
+        if os.path.exists(product_path):
+            shutil.rmtree(product_path)
         self.tmp_download_dir.cleanup()
 
     def _set_download_simulation(self):
@@ -335,7 +338,10 @@ class EODagTestCase(unittest.TestCase):
                 with open(self.local_product_as_archive_path, "rb") as fh:
                     response.__zip_buffer = io.BytesIO(fh.read())
                 cl = response.__zip_buffer.getbuffer().nbytes
-                response.headers = {"content-length": cl}
+                response.headers = {
+                    "content-length": cl,
+                    "content-disposition": "attachment; filename=foobar.zip",
+                }
                 response.url = "http://foo.bar"
 
             def __enter__(response):
@@ -355,6 +361,9 @@ class EODagTestCase(unittest.TestCase):
             def raise_for_status(response):
                 pass
 
+            def close(response):
+                pass
+
         return Response()
 
 
diff --git a/tests/units/test_download_plugins.py b/tests/units/test_download_plugins.py
index 48fba8fb..9d5f8a47 100644
--- a/tests/units/test_download_plugins.py
+++ b/tests/units/test_download_plugins.py
@@ -23,7 +23,6 @@ import stat
 import tarfile
 import unittest
 import zipfile
-from itertools import chain
 from pathlib import Path
 from tempfile import NamedTemporaryFile, TemporaryDirectory, gettempdir
 from typing import Any, Callable, Dict
@@ -175,7 +174,7 @@ class TestDownloadPluginBase(BaseDownloadPluginTest):
         """Download._prepare_download must use collision avoidance suffix"""
 
         self.product.properties["title"] = "needs sanitïze"
-        self.product.properties["id"] = "alsô"
+        self.product.properties["id"] = "alsô.zip"
         self.product.location = self.product.remote_location = "somewhere"
 
         plugin = self.get_download_plugin(self.product)
@@ -211,7 +210,7 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
                     self.__zip_buffer = io.BytesIO(fh.read())
                 cl = self.__zip_buffer.getbuffer().nbytes
                 self.headers = {"content-length": cl}
-                self.url = "http://foo.bar"
+                self.url = "http://foo.bar/product.zip"
 
             def __enter__(self):
                 return self
@@ -230,6 +229,9 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             def raise_for_status(self):
                 pass
 
+            def close(self):
+                pass
+
         return Response()
 
     def _set_download_simulation(
@@ -329,103 +331,14 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             eoproduct_props,
             product_type,
         )
-        output_extension = getattr(
-            product.downloader.config, "output_extension", ".zip"
-        )
-        path = product.download()
-
-        self.assertTrue(
-            path == os.path.join(self.output_dir, product.properties["title"] + ".zip")
-            and os.path.isfile(path)
-            and output_extension == ".zip"
-            and zipfile.is_zipfile(path)
-        )
-
-        # check if the hidden directory ".downloaded" have been created with the product zip file
-        self.assertEqual(len(os.listdir(self.output_dir)), 2)
-
-        mock_requests_session.assert_called_once_with(
-            mock.ANY,
-            "get",
-            product.remote_location,
-            stream=True,
-            auth=None,
-            params={},
-            headers=USER_AGENT,
-            timeout=DEFAULT_STREAM_REQUESTS_TIMEOUT,
-            verify=True,
-        )
-
-    @mock.patch("eodag.plugins.download.http.requests.Session.request", autospec=True)
-    def test_plugins_download_http_tar_file_with_zip_extension_ok(
-        self, mock_requests_session
-    ):
-        """HTTPDownload.download() must change the outputs extension from '.zip' to '.tar' and keep the content
-        of the output as it is when it is a tar file and outputs extension is set to '.zip'"""
-
-        provider = "wekeo_main"
-        download_url = (
-            "https://prism-dem-open.copernicus.eu/pd-desk-open-access/prismDownload/"
-            "COP-DEM_GLO-30-DGED__2022_1/Copernicus_DSM_10_S90_00_W141_00.tar"
-        )
-        local_filename = "Copernicus_DSM_10_S90_00_W141_00"
-        local_product_as_archive_path = os.path.abspath(
-            os.path.join(
-                TEST_RESOURCES_PATH,
-                "products",
-                "as_archive",
-                "{}.tar".format(local_filename),
-            )
-        )
-        product_type = "COP_DEM_GLO30_DGED"
-        platform = None
-        instrument = None
-
-        eoproduct_props = {
-            "id": "Copernicus_DSM_10_S90_00_W141_00",
-            "geometry": {
-                "type": "Polygon",
-                "coordinates": [
-                    [
-                        [-142, -89],
-                        [-142, -87],
-                        [-140, -87],
-                        [-140, -89],
-                        [-142, -89],
-                    ]
-                ],
-            },
-            "productType": product_type,
-            "platform": "TerraSAR",
-            "platformSerialIdentifier": platform,
-            "instrument": instrument,
-            "title": local_filename,
-            "downloadLink": download_url,
-        }
-
-        # Put an empty string as value of properties which are not relevant for the test
-        eoproduct_props.update(
-            {key: "" for key in DEFAULT_METADATA_MAPPING if key not in eoproduct_props}
-        )
-
-        product = self._dummy_downloadable_product(
-            mock_requests_session,
-            local_product_as_archive_path,
-            provider,
-            eoproduct_props,
-            product_type,
-        )
-        output_extension = getattr(
-            product.downloader.config, "output_extension", ".zip"
-        )
         path = product.download()
 
-        self.assertTrue(
-            path == os.path.join(self.output_dir, product.properties["title"] + ".tar")
-            and os.path.isfile(path)
-            and output_extension == ".zip"
-            and tarfile.is_tarfile(path)
+        expected_path = os.path.join(
+            self.output_dir, product.properties["title"] + ".zip"
         )
+        self.assertEqual(path, expected_path)
+        self.assertTrue(os.path.isfile(path))
+        self.assertTrue(zipfile.is_zipfile(path))
 
         # check if the hidden directory ".downloaded" have been created with the product zip file
         self.assertEqual(len(os.listdir(self.output_dir)), 2)
@@ -445,17 +358,19 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
     @mock.patch(
         "eodag.plugins.download.http.HTTPDownload._stream_download", autospec=True
     )
+    @mock.patch("eodag.plugins.download.http.HTTPDownload.stream", create=True)
     def test_plugins_download_http_nonzip_file_with_zip_extension_ok(
-        self, mock_stream_download
+        self, mock_stream, mock_stream_download
     ):
         """HTTPDownload.download() must create an output directory
         when the result is a non-zip file with a '.zip' outputs extension"""
 
         plugin = self.get_download_plugin(self.product)
-        output_extension = getattr(plugin.config, "output_extension", ".zip")
         self.product.location = self.product.remote_location = "http://somewhere"
         self.product.properties["id"] = "someproduct"
-        mock_stream_download.return_value = chain(iter([b"a"]))
+
+        self.product.filename = "dummy_product.nc"
+        mock_stream_download.return_value = [b"a"]
         progress_callback = ProgressCallback(disable=True)
 
         path = plugin.download(
@@ -464,19 +379,16 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             progress_callback=progress_callback,
         )
 
-        self.assertTrue(
-            path == os.path.join(self.output_dir, "dummy_product")
-            and os.path.isdir(path)
-            and output_extension == ".zip"
-        )
+        # Verify output directory
+        self.assertEqual(path, os.path.join(self.output_dir, "dummy_product"))
+        self.assertTrue(os.path.isdir(path))
 
+        # Verify output file
         file_path = os.path.join(path, os.listdir(path)[0])
-        self.assertTrue(
-            len(os.listdir(path)) == 1
-            and os.path.isfile(file_path)
-            and file_path.find(".zip") == -1
-            and not (zipfile.is_zipfile(file_path) or tarfile.is_tarfile(file_path))
-        )
+        self.assertEqual(len(os.listdir(path)), 1)
+        self.assertNotIn(".zip", file_path)
+        self.assertFalse(zipfile.is_zipfile(file_path))
+        self.assertFalse(tarfile.is_tarfile(file_path))
 
         # check if the hidden directory ".downloaded" have been created with the one of the product
         self.assertEqual(len(os.listdir(self.output_dir)), 2)
@@ -487,14 +399,14 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             None,
             progress_callback,
             output_dir=self.output_dir,
-            output_extension=output_extension,
         )
 
     @mock.patch(
         "eodag.plugins.download.http.HTTPDownload._stream_download", autospec=True
     )
+    @mock.patch("eodag.plugins.download.http.HTTPDownload.stream", create=True)
     def test_plugins_download_http_file_without_zip_extension_ok(
-        self, mock_stream_download
+        self, mock_stream, mock_stream_download
     ):
         """HTTPDownload.download() must create an output directory
         when the result is a file without a '.zip' outputs extension"""
@@ -509,10 +421,10 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             ),
         )
 
-        mock_stream_download.return_value = chain(iter([b"a"]))
+        product.filename = "dummy_product.nc"
+        mock_stream_download.return_value = [b"a"]
 
         plugin = self.get_download_plugin(product)
-        output_extension = getattr(plugin.config, "output_extension", ".zip")
         product.location = product.remote_location = "http://somewhere"
         product.properties["id"] = "someproduct"
         progress_callback = ProgressCallback(disable=True)
@@ -521,21 +433,22 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             product, output_dir=self.output_dir, progress_callback=progress_callback
         )
 
-        self.assertTrue(
-            path == os.path.join(self.output_dir, "dummy_product")
-            and os.path.isdir(path)
-            and not output_extension == ".zip"
-        )
+        # Verify output directory
+        expected_path = os.path.join(self.output_dir, "dummy_product")
+        self.assertIsNotNone(path)
+        self.assertEqual(path, expected_path)
+        self.assertTrue(os.path.isdir(path))
 
+        # Verify output file
         file_path = os.path.join(path, os.listdir(path)[0])
-        self.assertTrue(
-            len(os.listdir(path)) == 1
-            and os.path.isfile(file_path)
-            and file_path.find(".zip") == -1
-            and not (zipfile.is_zipfile(file_path) or tarfile.is_tarfile(file_path))
-        )
-
-        # check if the hidden directory ".downloaded" have been created with the one of the product
+        self.assertEqual(len(os.listdir(path)), 1)
+        self.assertTrue(os.path.isfile(file_path))
+        self.assertNotIn(".zip", file_path)
+        self.assertFalse(zipfile.is_zipfile(file_path))
+        self.assertFalse(tarfile.is_tarfile(file_path))
+
+        # check if the hidden directory ".downloaded" have been created with the one of
+        # the product
         self.assertEqual(len(os.listdir(self.output_dir)), 2)
 
         mock_stream_download.assert_called_once_with(
@@ -544,16 +457,16 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
             None,
             progress_callback,
             output_dir=self.output_dir,
-            output_extension=output_extension,
         )
 
     @mock.patch(
         "eodag.plugins.download.http.HTTPDownload._stream_download", autospec=True
     )
+    @mock.patch("eodag.plugins.download.http.HTTPDownload.stream", create=True)
     @mock.patch("eodag.plugins.download.http.requests.head", autospec=True)
     @mock.patch("eodag.plugins.download.http.requests.get", autospec=True)
     def test_plugins_download_http_ignore_assets(
-        self, mock_requests_get, mock_requests_head, mock_stream_download
+        self, mock_requests_get, mock_requests_head, mock_stream, mock_stream_download
     ):
         """HTTPDownload.download() must ignore assets if configured to"""
 
@@ -567,7 +480,9 @@ class TestDownloadPluginHttp(BaseDownloadPluginTest):
         mock_requests_get.return_value.__enter__.return_value.iter_content.side_effect = lambda *x, **y: io.BytesIO(
             b"some content"
         )
-        mock_stream_download.return_value = chain(iter([b"a"]))
+
+        mock_stream_download.return_value = [b"a"]
+        self.product.filename = "dummy_product.nc"
 
         # download asset if ignore_assets = False
         plugin.config.ignore_assets = False
diff --git a/tests/units/test_eoproduct.py b/tests/units/test_eoproduct.py
index d0b167bc..e05b2f37 100644
--- a/tests/units/test_eoproduct.py
+++ b/tests/units/test_eoproduct.py
@@ -295,53 +295,46 @@ class TestEOProduct(EODagTestCase):
         """eoproduct.download must save the product at output_dir and create a .downloaded dir"""  # noqa
         # Setup
         product = self._dummy_downloadable_product()
-        try:
-            with self.assertLogs(level="INFO") as cm:
-                # Download
-                product_dir_path = product.download()
-                self.assertIn(
-                    "Download url: %s" % product.remote_location, str(cm.output)
-                )
-                self.assertIn(
-                    "Remote location of the product is still available", str(cm.output)
-                )
+        with self.assertLogs(level="INFO") as cm:
+            # Download
+            product_dir_path = product.download()
+            self.addCleanup(self._clean_product, product_dir_path)
+            self.assertIn("Download url: %s" % product.remote_location, str(cm.output))
+            self.assertIn(
+                "Remote location of the product is still available", str(cm.output)
+            )
 
-            # Check that the mocked request was properly called.
-            self.requests_request.assert_called_once()
-            download_records_dir = pathlib.Path(product_dir_path).parent / ".downloaded"
-            # A .downloaded folder should be created, including a text file that
-            # lists the downloaded product by their url
-            self.assertTrue(download_records_dir.is_dir())
-            files_in_records_dir = list(download_records_dir.iterdir())
-            self.assertEqual(len(files_in_records_dir), 1)
-            records_file = files_in_records_dir[0]
-            actual_download_url = records_file.read_text()
-            self.assertEqual(actual_download_url, self.download_url)
-            # Since extraction is True by default, check that the returned path is the
-            # product's directory.
-            self.assertTrue(os.path.isdir(product_dir_path))
-            # Check that the ZIP file is still there
-            product_dir_path = pathlib.Path(product_dir_path)
-            product_zip = product_dir_path.parent / (product_dir_path.name + ".zip")
-            self.assertTrue(zipfile.is_zipfile(product_zip))
-            # check that product is not downloaded again
-            with self.assertLogs(level="INFO") as cm:
-                product.download()
-                self.assertIn(
-                    "Product already present on this platform", str(cm.output)
-                )
-            # check that product is not downloaded again even if location has not been updated
-            product.location = product.remote_location
-            with self.assertLogs(level="INFO") as cm:
-                product.download()
-                self.assertIn("Product already downloaded", str(cm.output))
-                self.assertIn(
-                    "Extraction cancelled, destination directory already exists",
-                    str(cm.output),
-                )
-        finally:
-            # Teardown
-            self._clean_product(product_dir_path)
+        # Check that the mocked request was properly called.
+        self.requests_request.assert_called_once()
+        download_records_dir = pathlib.Path(product_dir_path).parent / ".downloaded"
+        # A .downloaded folder should be created, including a text file that
+        # lists the downloaded product by their url
+        self.assertTrue(download_records_dir.is_dir())
+        files_in_records_dir = list(download_records_dir.iterdir())
+        self.assertEqual(len(files_in_records_dir), 1)
+        records_file = files_in_records_dir[0]
+        actual_download_url = records_file.read_text()
+        self.assertEqual(actual_download_url, self.download_url)
+        # Since extraction is True by default, check that the returned path is the
+        # product's directory.
+        self.assertTrue(os.path.isdir(product_dir_path))
+        # Check that the ZIP file is still there
+        product_dir_path = pathlib.Path(product_dir_path)
+        product_zip = product_dir_path.parent / (product_dir_path.name + ".zip")
+        self.assertTrue(zipfile.is_zipfile(product_zip))
+        # check that product is not downloaded again
+        with self.assertLogs(level="INFO") as cm:
+            product.download()
+            self.assertIn("Product already present on this platform", str(cm.output))
+        # check that product is not downloaded again even if location has not been updated
+        product.location = product.remote_location
+        with self.assertLogs(level="INFO") as cm:
+            product.download()
+            self.assertIn("Product already downloaded", str(cm.output))
+            self.assertIn(
+                "Extraction cancelled, destination directory already exists",
+                str(cm.output),
+            )
 
     def test_eoproduct_download_http_delete_archive(self):
         """eoproduct.download must delete the downloaded archive"""  # noqa
@@ -375,21 +368,17 @@ class TestEOProduct(EODagTestCase):
         """eoproduct.download over must be able to extract a product"""
         # Setup
         product = self._dummy_downloadable_product(extract=True)
-        try:
-            # Download
-            product_dir_path = product.download()
-            product_dir_path = pathlib.Path(product_dir_path)
-            # The returned path must be a directory.
-            self.assertTrue(product_dir_path.is_dir())
-            # Check that the extracted dir has at least one file, there are more
-            # but that should be enough.
-            self.assertGreaterEqual(len(list(product_dir_path.glob("**/*"))), 1)
-            # The zip file should be around
-            product_zip_file = product_dir_path.with_suffix(".zip")
-            self.assertTrue(product_zip_file.is_file)
-        finally:
-            # Teardown
-            self._clean_product(product_dir_path)
+        product_dir_path = product.download()
+        self.addCleanup(self._clean_product, product_dir_path)
+        product_dir_path = pathlib.Path(product_dir_path)
+        # The returned path must be a directory.
+        self.assertTrue(product_dir_path.is_dir())
+        # Check that the extracted dir has at least one file, there are more
+        # but that should be enough.
+        self.assertGreaterEqual(len(list(product_dir_path.glob("**/*"))), 1)
+        # The zip file should be around
+        product_zip_file = product_dir_path.with_suffix(".zip")
+        self.assertTrue(product_zip_file.is_file)
 
     # TODO: add a test on tarfiles extraction