shahar1 commented on code in PR #74664:
URL: https://github.com/apache/airflow/pull/74664#discussion_r4500476331


##########
providers/google/src/airflow/providers/google/firebase/hooks/firestore.py:
##########
@@ -84,9 +91,188 @@ def get_conn(self):
             self._conn = build_from_document(non_authorized_conn._rootDesc, 
http=http_authorized)
         return self._conn
 
+    def get_operation(self, operation_name: str) -> dict[str, Any]:
+        """
+        Retrieve the current state of a long-running Firestore operation.
+
+        :param operation_name: The resource name of the operation.
+        """
+        service = self.get_conn()
+        return (
+            service.projects()
+            .databases()
+            .operations()
+            .get(name=operation_name)
+            .execute(num_retries=self.num_retries)
+        )
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def list_operations(
+        self, database_id: str = "(default)", project_id: str = 
PROVIDE_PROJECT_ID
+    ) -> list[dict[str, Any]]:
+        """
+        List long-running operations on the specified Firestore database.
+
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+            If set to None or missing, the default project_id from the Google 
Cloud connection is used.
+        """
+        service = self.get_conn()
+        name = f"projects/{project_id}/databases/{database_id}"
+        operations_resource = service.projects().databases().operations()
+        all_operations: list[dict[str, Any]] = []
+        page_token: str | None = None
+
+        while True:
+            list_kwargs: dict[str, Any] = {"name": name}
+            if page_token:
+                list_kwargs["pageToken"] = page_token
+            response = 
operations_resource.list(**list_kwargs).execute(num_retries=self.num_retries)
+            if not isinstance(response, dict):
+                break
+            operations = response.get("operations", [])
+            if isinstance(operations, list):
+                all_operations.extend(operations)
+            next_page_token = response.get("nextPageToken")
+            if not isinstance(next_page_token, str) or not next_page_token:
+                break
+            page_token = next_page_token
+
+        return all_operations
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def find_matching_export_operation(
+        self,
+        body: dict[str, Any],
+        database_id: str = "(default)",
+        project_id: str = PROVIDE_PROJECT_ID,
+        include_completed: bool = True,
+    ) -> dict[str, Any] | None:
+        """
+        Find an existing running or completed export operation matching the 
export request body.
+
+        :param body: The export request body.
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+        :param include_completed: Whether to match already completed 
operations in addition to
+            in-progress operations.
+        """
+        target_uri = body.get("outputUriPrefix")
+        if not target_uri or not isinstance(target_uri, str):
+            return None
+        target_collections = sorted(body.get("collectionIds") or [])
+        target_namespaces = sorted(body.get("namespaceIds") or [])
+
+        completed_match: dict[str, Any] | None = None
+        for op in self.list_operations(database_id=database_id, 
project_id=project_id):
+            if not isinstance(op, dict):
+                continue
+            metadata = op.get("metadata")
+            if not isinstance(metadata, dict):
+                metadata = {}
+
+            meta_type = metadata.get("@type", "")
+            if meta_type and not meta_type.endswith("ExportDocumentsMetadata"):
+                continue
+
+            op_state = metadata.get("operationState", "")
+            if op.get("error") or op_state in {"FAILED", "CANCELLED", 
"CANCELLING"}:
+                continue
+
+            op_collections = sorted(metadata.get("collectionIds") or [])
+            if target_collections != op_collections:
+                continue
+
+            op_namespaces = sorted(metadata.get("namespaceIds") or [])
+            if target_namespaces != op_namespaces:
+                continue
+
+            response_dict = op.get("response")
+            op_uri = metadata.get("outputUriPrefix") or (
+                response_dict.get("outputUriPrefix") if 
isinstance(response_dict, dict) else None
+            )
+            if not isinstance(op_uri, str) or op_uri.rstrip("/") != 
target_uri.rstrip("/"):

Review Comment:
   **Minor.** A bucket-only URI won't match here. The API accepts 
`gs://BUCKET_NAME` and documents that "a prefix will be generated based on the 
start time", so the operation should report the generated prefix rather than 
the requested one. A retry would then start a second full export while the 
first is still running. I haven't confirmed this against the live API.
   
   That is no worse than today, but the reattach behavior doesn't apply to this 
form. Please state that in the `reattach_on_restart` docs, or log a warning 
when `target_uri` has no path.



##########
providers/google/src/airflow/providers/google/firebase/hooks/firestore.py:
##########
@@ -84,9 +91,188 @@ def get_conn(self):
             self._conn = build_from_document(non_authorized_conn._rootDesc, 
http=http_authorized)
         return self._conn
 
+    def get_operation(self, operation_name: str) -> dict[str, Any]:
+        """
+        Retrieve the current state of a long-running Firestore operation.
+
+        :param operation_name: The resource name of the operation.
+        """
+        service = self.get_conn()
+        return (
+            service.projects()
+            .databases()
+            .operations()
+            .get(name=operation_name)
+            .execute(num_retries=self.num_retries)
+        )
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def list_operations(
+        self, database_id: str = "(default)", project_id: str = 
PROVIDE_PROJECT_ID
+    ) -> list[dict[str, Any]]:
+        """
+        List long-running operations on the specified Firestore database.
+
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+            If set to None or missing, the default project_id from the Google 
Cloud connection is used.
+        """
+        service = self.get_conn()
+        name = f"projects/{project_id}/databases/{database_id}"
+        operations_resource = service.projects().databases().operations()
+        all_operations: list[dict[str, Any]] = []
+        page_token: str | None = None
+
+        while True:
+            list_kwargs: dict[str, Any] = {"name": name}
+            if page_token:
+                list_kwargs["pageToken"] = page_token
+            response = 
operations_resource.list(**list_kwargs).execute(num_retries=self.num_retries)
+            if not isinstance(response, dict):

Review Comment:
   **Nit.** This guard turns a malformed response into "no operations", so the 
task would go on to submit a new export. The `isinstance` guards in this method 
also appear to be what keeps the existing tests such as 
`test_immediately_complete` passing. Those tests mock `get_conn`, so 
`list().execute()` returns a `MagicMock`, and without the guards the truthy 
page token would keep the `while True` loop running forever.
   
   I'd drop the guards and stub `list` in those tests.



##########
providers/google/src/airflow/providers/google/firebase/hooks/firestore.py:
##########
@@ -84,9 +91,188 @@ def get_conn(self):
             self._conn = build_from_document(non_authorized_conn._rootDesc, 
http=http_authorized)
         return self._conn
 
+    def get_operation(self, operation_name: str) -> dict[str, Any]:
+        """
+        Retrieve the current state of a long-running Firestore operation.
+
+        :param operation_name: The resource name of the operation.
+        """
+        service = self.get_conn()
+        return (
+            service.projects()
+            .databases()
+            .operations()
+            .get(name=operation_name)
+            .execute(num_retries=self.num_retries)
+        )
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def list_operations(
+        self, database_id: str = "(default)", project_id: str = 
PROVIDE_PROJECT_ID
+    ) -> list[dict[str, Any]]:
+        """
+        List long-running operations on the specified Firestore database.
+
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+            If set to None or missing, the default project_id from the Google 
Cloud connection is used.
+        """
+        service = self.get_conn()
+        name = f"projects/{project_id}/databases/{database_id}"
+        operations_resource = service.projects().databases().operations()
+        all_operations: list[dict[str, Any]] = []
+        page_token: str | None = None
+
+        while True:
+            list_kwargs: dict[str, Any] = {"name": name}
+            if page_token:
+                list_kwargs["pageToken"] = page_token
+            response = 
operations_resource.list(**list_kwargs).execute(num_retries=self.num_retries)
+            if not isinstance(response, dict):
+                break
+            operations = response.get("operations", [])
+            if isinstance(operations, list):
+                all_operations.extend(operations)
+            next_page_token = response.get("nextPageToken")
+            if not isinstance(next_page_token, str) or not next_page_token:
+                break
+            page_token = next_page_token
+
+        return all_operations
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def find_matching_export_operation(
+        self,
+        body: dict[str, Any],
+        database_id: str = "(default)",
+        project_id: str = PROVIDE_PROJECT_ID,
+        include_completed: bool = True,
+    ) -> dict[str, Any] | None:
+        """
+        Find an existing running or completed export operation matching the 
export request body.
+
+        :param body: The export request body.
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+        :param include_completed: Whether to match already completed 
operations in addition to
+            in-progress operations.
+        """
+        target_uri = body.get("outputUriPrefix")
+        if not target_uri or not isinstance(target_uri, str):
+            return None
+        target_collections = sorted(body.get("collectionIds") or [])
+        target_namespaces = sorted(body.get("namespaceIds") or [])
+
+        completed_match: dict[str, Any] | None = None
+        for op in self.list_operations(database_id=database_id, 
project_id=project_id):
+            if not isinstance(op, dict):
+                continue
+            metadata = op.get("metadata")
+            if not isinstance(metadata, dict):
+                metadata = {}
+
+            meta_type = metadata.get("@type", "")
+            if meta_type and not meta_type.endswith("ExportDocumentsMetadata"):
+                continue
+
+            op_state = metadata.get("operationState", "")
+            if op.get("error") or op_state in {"FAILED", "CANCELLED", 
"CANCELLING"}:
+                continue
+
+            op_collections = sorted(metadata.get("collectionIds") or [])
+            if target_collections != op_collections:
+                continue
+
+            op_namespaces = sorted(metadata.get("namespaceIds") or [])
+            if target_namespaces != op_namespaces:
+                continue
+
+            response_dict = op.get("response")
+            op_uri = metadata.get("outputUriPrefix") or (
+                response_dict.get("outputUriPrefix") if 
isinstance(response_dict, dict) else None
+            )
+            if not isinstance(op_uri, str) or op_uri.rstrip("/") != 
target_uri.rstrip("/"):
+                continue
+
+            is_done = bool(op.get("done"))
+            if not is_done:
+                return op
+            if include_completed and completed_match is None:
+                completed_match = op
+
+        return completed_match
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def start_export_documents(
+        self,
+        body: dict[str, Any],
+        database_id: str = "(default)",
+        project_id: str = PROVIDE_PROJECT_ID,
+        reattach_on_restart: bool = True,
+    ) -> dict[str, Any]:
+        """
+        Start a Firestore export or return an existing running/completed 
operation when reattaching.
+
+        :param body: The request body.
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+        :param reattach_on_restart: If True, check for an existing in-progress 
export operation
+            matching ``body`` before submitting a new export request, and 
recover if the export
+            already completed on a prior attempt.
+        """
+        if reattach_on_restart:
+            existing_op = self.find_matching_export_operation(
+                body=body,
+                database_id=database_id,
+                project_id=project_id,
+                include_completed=False,
+            )
+            if existing_op is not None:
+                self.log.info(
+                    "Reattaching to in-progress Firestore export operation 
%s.",
+                    existing_op.get("name"),
+                )
+                return existing_op
+
+        service = self.get_conn()
+        name = f"projects/{project_id}/databases/{database_id}"
+        try:
+            return (
+                service.projects()
+                .databases()
+                .exportDocuments(name=name, body=body)
+                .execute(num_retries=self.num_retries)
+            )
+        except HttpError as err:
+            if reattach_on_restart and err.resp.status in {400, 409}:

Review Comment:
   **Major.** On a 400/409, a completed match is returned as success, but 
nothing ties that operation to an earlier attempt of this task. The operations 
list keeps recently completed exports, so this also matches an older run.
   
   Concrete case: a scheduled Dag with a fixed `outputUriPrefix`, like the 
how-to example in `example_firestore.py`.
   
   1. Day 1 exports normally.
   2. On day 2, the pre-check ignores completed operations and 
`exportDocuments` returns 400 because the path exists.
   3. This lookup finds day 1's operation, and the task succeeds with no new 
export.
   
   Today that run fails loudly. With this change, downstream tasks read stale 
data and the task state shows nothing wrong.
   
   The status code is also the only check on the error. An unrelated 400, such 
as an invalid `snapshotTime`, is reported as success too when an old match 
exists.
   
   Suggestion: accept a completed operation only if it started after this Dag 
run did. The operator can pass `context["dag_run"].start_date`, and the hook 
can compare it with `metadata.startTime`. Alternatively, make completed-export 
recovery opt-in and keep in-progress reattach as the default.
   
   Either way, the `reattach_on_restart` docstrings on the hook and operator 
need updating. They describe checking for completed operations before 
submitting, but that check runs only after the HTTP error.



##########
providers/google/src/airflow/providers/google/firebase/hooks/firestore.py:
##########
@@ -84,9 +91,188 @@ def get_conn(self):
             self._conn = build_from_document(non_authorized_conn._rootDesc, 
http=http_authorized)
         return self._conn
 
+    def get_operation(self, operation_name: str) -> dict[str, Any]:
+        """
+        Retrieve the current state of a long-running Firestore operation.
+
+        :param operation_name: The resource name of the operation.
+        """
+        service = self.get_conn()
+        return (
+            service.projects()
+            .databases()
+            .operations()
+            .get(name=operation_name)
+            .execute(num_retries=self.num_retries)
+        )
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def list_operations(
+        self, database_id: str = "(default)", project_id: str = 
PROVIDE_PROJECT_ID
+    ) -> list[dict[str, Any]]:
+        """
+        List long-running operations on the specified Firestore database.
+
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+            If set to None or missing, the default project_id from the Google 
Cloud connection is used.
+        """
+        service = self.get_conn()
+        name = f"projects/{project_id}/databases/{database_id}"
+        operations_resource = service.projects().databases().operations()
+        all_operations: list[dict[str, Any]] = []
+        page_token: str | None = None
+
+        while True:
+            list_kwargs: dict[str, Any] = {"name": name}
+            if page_token:
+                list_kwargs["pageToken"] = page_token
+            response = 
operations_resource.list(**list_kwargs).execute(num_retries=self.num_retries)
+            if not isinstance(response, dict):
+                break
+            operations = response.get("operations", [])
+            if isinstance(operations, list):
+                all_operations.extend(operations)
+            next_page_token = response.get("nextPageToken")
+            if not isinstance(next_page_token, str) or not next_page_token:
+                break
+            page_token = next_page_token
+
+        return all_operations
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def find_matching_export_operation(
+        self,
+        body: dict[str, Any],
+        database_id: str = "(default)",
+        project_id: str = PROVIDE_PROJECT_ID,
+        include_completed: bool = True,
+    ) -> dict[str, Any] | None:
+        """
+        Find an existing running or completed export operation matching the 
export request body.
+
+        :param body: The export request body.
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+        :param include_completed: Whether to match already completed 
operations in addition to
+            in-progress operations.
+        """
+        target_uri = body.get("outputUriPrefix")
+        if not target_uri or not isinstance(target_uri, str):
+            return None
+        target_collections = sorted(body.get("collectionIds") or [])
+        target_namespaces = sorted(body.get("namespaceIds") or [])
+
+        completed_match: dict[str, Any] | None = None
+        for op in self.list_operations(database_id=database_id, 
project_id=project_id):
+            if not isinstance(op, dict):
+                continue
+            metadata = op.get("metadata")
+            if not isinstance(metadata, dict):
+                metadata = {}
+
+            meta_type = metadata.get("@type", "")
+            if meta_type and not meta_type.endswith("ExportDocumentsMetadata"):
+                continue
+
+            op_state = metadata.get("operationState", "")
+            if op.get("error") or op_state in {"FAILED", "CANCELLED", 
"CANCELLING"}:
+                continue
+
+            op_collections = sorted(metadata.get("collectionIds") or [])
+            if target_collections != op_collections:
+                continue
+
+            op_namespaces = sorted(metadata.get("namespaceIds") or [])
+            if target_namespaces != op_namespaces:
+                continue
+
+            response_dict = op.get("response")
+            op_uri = metadata.get("outputUriPrefix") or (
+                response_dict.get("outputUriPrefix") if 
isinstance(response_dict, dict) else None
+            )
+            if not isinstance(op_uri, str) or op_uri.rstrip("/") != 
target_uri.rstrip("/"):
+                continue
+
+            is_done = bool(op.get("done"))
+            if not is_done:
+                return op
+            if include_completed and completed_match is None:
+                completed_match = op
+
+        return completed_match
+
+    @GoogleBaseHook.fallback_to_default_project_id
+    def start_export_documents(
+        self,
+        body: dict[str, Any],
+        database_id: str = "(default)",
+        project_id: str = PROVIDE_PROJECT_ID,
+        reattach_on_restart: bool = True,
+    ) -> dict[str, Any]:
+        """
+        Start a Firestore export or return an existing running/completed 
operation when reattaching.
+
+        :param body: The request body.
+        :param database_id: The Database ID.
+        :param project_id: Optional, Google Cloud Project project_id where the 
database belongs.
+        :param reattach_on_restart: If True, check for an existing in-progress 
export operation
+            matching ``body`` before submitting a new export request, and 
recover if the export
+            already completed on a prior attempt.
+        """
+        if reattach_on_restart:
+            existing_op = self.find_matching_export_operation(

Review Comment:
   **Minor.** This pre-check runs on every attempt, including the first, and it 
needs `datastore.operations.list`. The predefined import/export roles include 
that permission. A custom role with only `datastore.databases.export` and 
`datastore.operations.get` works today, but after upgrading it gets a 403 here 
before any export is submitted.
   
   Could a 403 on this lookup log a warning and carry on to the export? If you 
prefer to keep it strict, this needs a changelog note. From 
`providers/AGENTS.md`: "When a provider change needs a user-visible note 
(typically a breaking change or important behavior change that warrants 
explanation), update the provider's `docs/changelog.rst` directly in the same 
PR".



##########
providers/google/tests/unit/google/firebase/hooks/test_firestore.py:
##########
@@ -131,6 +136,229 @@ def test_error_operation(self, _, get_conn_mock):
         with pytest.raises(AirflowException, match="error"):
             self.hook.export_documents(body=EXPORT_DOCUMENT_BODY, 
project_id=TEST_PROJECT_ID)
 
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.get_conn")
+    def test_list_operations(self, get_conn_mock):
+        service_mock = get_conn_mock.return_value
+        mock_list = 
service_mock.projects.return_value.databases.return_value.operations.return_value.list
+        second_operation = {"name": "operation-name-2"}
+        mock_list.return_value.execute.side_effect = [
+            {"operations": [TEST_OPERATION], "nextPageToken": "page-2"},
+            {"operations": [second_operation]},
+        ]
+
+        ops = self.hook.list_operations(project_id=TEST_PROJECT_ID)
+
+        assert ops == [TEST_OPERATION, second_operation]
+        assert mock_list.call_args_list == [
+            
mock.call(name="projects/firestore--project-id/databases/(default)"),
+            
mock.call(name="projects/firestore--project-id/databases/(default)", 
pageToken="page-2"),
+        ]
+
+    @pytest.mark.parametrize(
+        ("body", "operations", "include_completed", "expected_name"),
+        [
+            pytest.param(
+                EXPORT_DOCUMENT_BODY,
+                [
+                    "invalid-entry",
+                    {
+                        "name": "op-other-type",
+                        "metadata": {"@type": 
"type.googleapis.com/ImportDocumentsMetadata"},
+                    },
+                    {
+                        "name": "op-failed",
+                        "metadata": {
+                            "@type": 
"type.googleapis.com/ExportDocumentsMetadata",
+                            "operationState": "FAILED",
+                            "outputUriPrefix": 
"gs://test-bucket/test-namespace",
+                            "collectionIds": ["test-collection"],
+                        },
+                    },
+                    {
+                        "name": "op-wrong-collection",
+                        "metadata": {
+                            "outputUriPrefix": 
"gs://test-bucket/test-namespace/",
+                            "collectionIds": ["other"],
+                        },
+                    },
+                    {
+                        "name": "op-wrong-namespace",
+                        "metadata": {
+                            "outputUriPrefix": 
"gs://test-bucket/test-namespace/",
+                            "collectionIds": ["test-collection"],
+                            "namespaceIds": ["ns1"],
+                        },
+                    },
+                    {
+                        "name": "op-wrong-uri",
+                        "metadata": {
+                            "outputUriPrefix": "gs://other-bucket/path",
+                            "collectionIds": ["test-collection"],
+                        },
+                    },
+                    {
+                        "name": "op-completed",
+                        "done": True,
+                        "metadata": "non-dict-metadata",
+                        "response": {"outputUriPrefix": 
"gs://test-bucket/test-namespace"},
+                    },
+                    {
+                        "name": "op-in-progress",
+                        "done": False,
+                        "metadata": {
+                            "@type": 
"type.googleapis.com/google.firestore.admin.v1.ExportDocumentsMetadata",
+                            "operationState": "PROCESSING",
+                            "outputUriPrefix": 
"gs://test-bucket/test-namespace",
+                            "collectionIds": ["test-collection"],
+                        },
+                    },
+                ],
+                True,
+                "op-in-progress",
+                id="prefers_in_progress_match",
+            ),
+            pytest.param(
+                EXPORT_DOCUMENT_BODY,
+                [
+                    {
+                        "name": "op-completed",
+                        "done": True,
+                        "metadata": {
+                            "collectionIds": ["test-collection"],
+                        },
+                        "response": {"outputUriPrefix": 
"gs://test-bucket/test-namespace"},
+                    }
+                ],
+                True,
+                "op-completed",
+                id="matches_completed_operation_via_response_uri",
+            ),
+            pytest.param(
+                EXPORT_DOCUMENT_BODY,
+                [
+                    {
+                        "name": "op-completed",
+                        "done": True,
+                        "metadata": {
+                            "collectionIds": ["test-collection"],
+                        },
+                        "response": {"outputUriPrefix": 
"gs://test-bucket/test-namespace"},
+                    }
+                ],
+                False,
+                None,
+                id="ignores_completed_when_include_completed_false",
+            ),
+            pytest.param(
+                {"collectionIds": ["test-collection"]},
+                [
+                    {
+                        "name": "op-done-no-target-uri",
+                        "done": True,
+                        "metadata": {"collectionIds": ["test-collection"]},
+                    }
+                ],
+                True,
+                None,
+                id="returns_none_when_no_target_uri_specified",
+            ),
+        ],
+    )
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.list_operations")
+    def test_find_matching_export_operation(
+        self, mock_list_ops, body, operations, include_completed, expected_name
+    ):
+        mock_list_ops.return_value = operations
+        result = self.hook.find_matching_export_operation(
+            body=body, project_id=TEST_PROJECT_ID, 
include_completed=include_completed
+        )
+        if expected_name is None:
+            assert result is None
+        else:
+            assert result is not None
+            assert result["name"] == expected_name
+
+    @mock.patch(
+        
"airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.find_matching_export_operation"
+    )
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.get_conn")
+    def test_export_documents_reattaches_to_in_progress_operation(self, 
get_conn_mock, mock_find_op):
+        service_mock = get_conn_mock.return_value
+        mock_export_documents = 
service_mock.projects.return_value.databases.return_value.exportDocuments
+        mock_operation_get = (
+            
service_mock.projects.return_value.databases.return_value.operations.return_value.get
+        )
+        mock_find_op.return_value = {"name": "existing-op", "done": False}
+        mock_operation_get.return_value.execute.return_value = 
TEST_DONE_OPERATION
+
+        self.hook.export_documents(body=EXPORT_DOCUMENT_BODY, 
project_id=TEST_PROJECT_ID)
+
+        mock_export_documents.assert_not_called()
+        mock_operation_get.assert_called_once_with(name="existing-op")
+
+    @mock.patch(
+        
"airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.find_matching_export_operation"
+    )
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.get_conn")
+    def test_export_documents_skips_when_already_completed_on_http_400(self, 
get_conn_mock, mock_find_op):
+        service_mock = get_conn_mock.return_value
+        mock_export_documents = 
service_mock.projects.return_value.databases.return_value.exportDocuments
+        mock_operation_get = (
+            
service_mock.projects.return_value.databases.return_value.operations.return_value.get
+        )
+        http_400 = HttpError(httplib2.Response({"status": 400}), b"Path 
already exists")
+        mock_export_documents.return_value.execute.side_effect = http_400
+        mock_find_op.side_effect = [
+            None,
+            {
+                "name": "completed-op",
+                "done": True,
+                "response": {"outputUriPrefix": "x"},
+            },
+        ]
+
+        self.hook.export_documents(body=EXPORT_DOCUMENT_BODY, 
project_id=TEST_PROJECT_ID)
+
+        mock_export_documents.assert_called_once()
+        mock_operation_get.assert_not_called()
+
+    @mock.patch(
+        
"airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.find_matching_export_operation"
+    )
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.get_conn")
+    def 
test_start_export_documents_recovers_on_http_400_race_in_progress(self, 
get_conn_mock, mock_find_op):
+        service_mock = get_conn_mock.return_value
+        mock_export_documents = 
service_mock.projects.return_value.databases.return_value.exportDocuments
+        http_400 = HttpError(httplib2.Response({"status": 400}), b"Operation 
already in progress")
+        mock_export_documents.return_value.execute.side_effect = http_400
+        mock_find_op.side_effect = [None, {"name": "race-op", "done": False}]
+
+        op = self.hook.start_export_documents(body=EXPORT_DOCUMENT_BODY, 
project_id=TEST_PROJECT_ID)
+
+        assert op == {"name": "race-op", "done": False}
+
+    @mock.patch(
+        
"airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.find_matching_export_operation"
+    )
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreHook.get_conn")
+    def test_start_export_documents_reraises_http_error_when_no_match(self, 
get_conn_mock, mock_find_op):
+        service_mock = get_conn_mock.return_value
+        mock_export_documents = 
service_mock.projects.return_value.databases.return_value.exportDocuments
+        http_400 = HttpError(httplib2.Response({"status": 400}), b"Invalid 
argument")
+        mock_export_documents.return_value.execute.side_effect = http_400
+        mock_find_op.return_value = None
+
+        with pytest.raises(HttpError):
+            self.hook.start_export_documents(
+                body=EXPORT_DOCUMENT_BODY, project_id=TEST_PROJECT_ID, 
reattach_on_restart=False

Review Comment:
   **Minor.** With `reattach_on_restart=False` the lookup is never reached, so 
this test would still pass with the whole `except` block removed. The case in 
the test name, reattach on with no match, isn't covered.
   
   From the review instructions: "**Flag any changed or added behaviour without 
a corresponding test, and flag tests that a reviewer cannot fail by reverting 
the PR's change.**"
   
   Dropping the kwarg and asserting `mock_find_op.call_count == 2` would cover 
it. The `False` case could be a separate test asserting 
`mock_find_op.assert_not_called()`.



##########
providers/google/tests/unit/google/firebase/hooks/test_firestore.py:
##########
@@ -256,3 +484,22 @@ def test_create_build(self, mock_get_conn, 
mock_project_id):
             == "The project id must be passed either as keyword project_id 
parameter or as project_id extra in "
             "Google Cloud connection definition. Both are not set!"
         )
+
+
+class TestCloudFirestoreAsyncHook:
+    @pytest.mark.asyncio
+    
@mock.patch("airflow.providers.google.firebase.hooks.firestore.CloudFirestoreAsyncHook.get_sync_hook")
+    async def test_get_operation(self, mock_get_sync_hook):
+        sync_hook_mock = mock.MagicMock()

Review Comment:
   **Nit.** From the review instructions: "**Flag any `mock.Mock()` or 
`mock.MagicMock()` without `spec` or `autospec`.** Unspec'd mocks silently 
accept any attribute access, hiding real bugs."
   
   ```suggestion
           sync_hook_mock = mock.create_autospec(CloudFirestoreHook, 
instance=True)
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to