Skip to content

Commit 7acfd39

Browse files
committed
feat(fdc): Add raw HTTP setup and cleanup fixtures to integration test suite
- Type Annotations: Added from __future__ import annotations to dataconnect.py for clean return type hints. - Integration Test Fixtures: Added setup_and_cleanup_database fixture in integration/test_data_connect.py using raw HTTP bash scripts (seed.sh and cleanup.sh) before/after every test without relying on the SDK under test. - Emulator Cleanup: Added integration/emulators/cleanup.sh script executing raw HTTP POST deleteMany mutations via curl. - Test Naming & CI: Updated TestImpersonation test method names to start with test_impersonation_ and removed unnecessary credentials flag from ci.yml.
1 parent d67d88d commit 7acfd39

5 files changed

Lines changed: 65 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
- name: Run Functions emulator tests
5656
run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py'
5757
- name: Run Data Connect emulator tests
58-
run: firebase emulators:exec --config integration/emulators/firebase.json --only dataconnect --project fake-project-id './integration/emulators/seed.sh && DATA_CONNECT_EMULATOR_HOST=localhost:9399 pytest integration/test_data_connect.py --cert=tests/data/service_account.json'
58+
run: firebase emulators:exec --config integration/emulators/firebase.json --only dataconnect --project fake-project-id 'DATA_CONNECT_EMULATOR_HOST=localhost:9399 pytest integration/test_data_connect.py'
5959

6060

6161
lint:

firebase_admin/dataconnect.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
Firebase apps.
1919
"""
2020

21+
from __future__ import annotations
22+
2123
from collections.abc import Mapping
2224
from dataclasses import dataclass, asdict, is_dataclass
2325
import typing
@@ -134,12 +136,12 @@ def __init__(
134136
super().__init__(auth_claims=auth_claims)
135137

136138
@staticmethod
137-
def unauthenticated() -> 'Impersonation':
139+
def unauthenticated() -> Impersonation:
138140
"""Returns impersonation configuration for unauthenticated requests."""
139141
return Impersonation(unauthenticated=True)
140142

141143
@staticmethod
142-
def authenticated(auth_claims: Dict[str, Any]) -> 'Impersonation':
144+
def authenticated(auth_claims: Dict[str, Any]) -> Impersonation:
143145
"""Returns impersonation configuration for authenticated requests.
144146
145147
# TODO: More strongly type auth_claims later.
@@ -166,8 +168,6 @@ class ExecuteGraphqlResponse(Generic[_Data]):
166168

167169

168170
class DataConnect:
169-
170-
171171
"""Represents a Firebase Data Connect client instance.
172172
173173
This client provides access to the Firebase Data Connect service
@@ -257,7 +257,6 @@ def execute_graphql_read(
257257
)
258258

259259

260-
261260
class _DataConnectService:
262261
"""Service that maintains a collection of DataConnect clients."""
263262

@@ -357,8 +356,6 @@ def _validate_variables_type(
357356
type_name = getattr(expected_type, '__name__', str(expected_type))
358357
raise ValueError(f"variables must be of type {type_name}")
359358

360-
361-
362359
def _validate_impersonation_options(self, impersonate: Any) -> None:
363360
"""Validates impersonation dictionary options."""
364361
if impersonate is not None:
@@ -531,7 +528,6 @@ def _parse_graphql_response(
531528
# TODO(b/406281627): Add support for partial errors.
532529
return ExecuteGraphqlResponse(data=resp_dict.get("data"))
533530

534-
535531
def _execute_graphql_helper(
536532
self,
537533
query: str,
@@ -555,7 +551,6 @@ def _execute_graphql_helper(
555551
resp_dict = self._make_gql_request(url=url, headers=headers, payload=payload)
556552
return self._parse_graphql_response(resp_dict)
557553

558-
559554
def execute_graphql(
560555
self,
561556
query: str,

integration/emulators/cleanup.sh

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
# Shell script to clear all test data from the Firebase Data Connect emulator.
3+
set -e
4+
5+
EMULATOR_HOST="${DATA_CONNECT_EMULATOR_HOST:-127.0.0.1:9399}"
6+
ENDPOINT="http://${EMULATOR_HOST}/v1/projects/test-project/locations/us-west2/services/my-service:executeGraphql"
7+
8+
send_gql_mutation() {
9+
local payload="$1"
10+
local response
11+
response=$(curl -s -X POST "${ENDPOINT}" \
12+
-H "Authorization: Bearer owner" \
13+
-H "Content-Type: application/json" \
14+
-d "${payload}")
15+
16+
if [ -z "${response}" ]; then
17+
echo "Failed to receive response from Data Connect emulator at ${ENDPOINT}" >&2
18+
exit 1
19+
fi
20+
}
21+
22+
send_gql_mutation '{"query": "mutation { email_deleteMany(all: true) user_deleteMany(all: true) }"}'

integration/test_data_connect.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,27 @@
1414

1515
"""Integration tests for firebase_admin.dataconnect module (execute_graphql)."""
1616

17+
import os
18+
import subprocess
1719
import pytest
1820

19-
from firebase_admin import dataconnect, exceptions
21+
import firebase_admin
22+
from firebase_admin import _utils, dataconnect, exceptions
23+
from integration import conftest
24+
25+
def integration_conf(request):
26+
host_override = os.environ.get('DATA_CONNECT_EMULATOR_HOST')
27+
if host_override:
28+
return _utils.EmulatorAdminCredentials(), 'fake-project-id'
29+
30+
return conftest.integration_conf(request)
31+
32+
@pytest.fixture(scope='module', autouse=True)
33+
def default_app(request):
34+
cred, project_id = integration_conf(request)
35+
return firebase_admin.initialize_app(
36+
cred, options={'projectId': project_id})
37+
2038

2139
CONNECTOR_CONFIG = dataconnect.ConnectorConfig(
2240
location='us-west2',
@@ -122,6 +140,17 @@
122140
user_deleteMany(all: true)
123141
}"""
124142

143+
@pytest.fixture(autouse=True)
144+
def setup_and_cleanup_database():
145+
"""Initializes database via seed.sh before each test and wipes it via cleanup.sh afterwards."""
146+
script_dir = os.path.dirname(__file__)
147+
seed_script = os.path.join(script_dir, 'emulators', 'seed.sh')
148+
cleanup_script = os.path.join(script_dir, 'emulators', 'cleanup.sh')
149+
150+
subprocess.run(['bash', seed_script], check=True)
151+
yield
152+
subprocess.run(['bash', cleanup_script], check=True)
153+
125154
# Impersonation Options
126155
OPTS_UNAUTHORIZED_CLAIMS = dataconnect.GraphqlOptions(
127156
impersonate=dataconnect.Impersonation.unauthenticated()
@@ -140,7 +169,6 @@
140169
})
141170
)
142171

143-
144172
class TestExecuteGraphql:
145173
"""Integration tests for execute_graphql method."""
146174

@@ -192,7 +220,6 @@ def test_execute_graphql_mutation(self):
192220
QUERY_GET_EMAIL, options=get_email_options
193221
)
194222
assert query_email_resp.data['email'] == UPDATED_FRED_EMAIL
195-
dc_client.execute_graphql(UPSERT_FRED_EMAIL)
196223

197224

198225
class TestExecuteGraphqlRead:
@@ -265,7 +292,6 @@ def test_execute_graphql_impersonated_mutation_authenticated(self):
265292
query_options = dataconnect.GraphqlOptions(variables={'id': {'id': user_id}})
266293
query_resp = dc_client.execute_graphql(QUERY_GET_USER_BY_ID, options=query_options)
267294
assert query_resp.data['user'] == FREDRICK_USER
268-
dc_client.execute_graphql(UPSERT_FRED_USER)
269295

270296
def test_execute_graphql_impersonated_mutation_unauthenticated_fails(self):
271297
"""Tests mutation with unauthenticated impersonation fails."""

tests/test_data_connect.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -830,48 +830,48 @@ def test_parse_graphql_response_non_dict_error(self):
830830

831831
class TestImpersonation:
832832

833-
def test_unauthenticated_factory(self):
833+
def test_impersonation_unauthenticated_factory(self):
834834
"""Tests factory method for unauthenticated impersonation."""
835835
imp = dataconnect.Impersonation.unauthenticated()
836836
assert imp == {"unauthenticated": True}
837837

838-
def test_authenticated_factory(self):
838+
def test_impersonation_authenticated_factory(self):
839839
"""Tests factory method for authenticated impersonation."""
840840
imp = dataconnect.Impersonation.authenticated(TEST_AUTH_CLAIMS)
841841
assert imp == {"auth_claims": TEST_AUTH_CLAIMS}
842842

843-
def test_constructor_unauthenticated(self):
843+
def test_impersonation_constructor_unauthenticated(self):
844844
"""Tests direct constructor with unauthenticated=True."""
845845
imp = dataconnect.Impersonation(unauthenticated=True)
846846
assert imp == {"unauthenticated": True}
847847

848-
def test_constructor_auth_claims(self):
848+
def test_impersonation_constructor_auth_claims(self):
849849
"""Tests direct constructor with auth_claims dict."""
850850
imp = dataconnect.Impersonation(auth_claims=TEST_AUTH_CLAIMS)
851851
assert imp == {"auth_claims": TEST_AUTH_CLAIMS}
852852

853-
def test_constructor_neither_unauth_nor_claims_fails(self):
853+
def test_impersonation_constructor_neither_unauth_nor_claims_fails(self):
854854
"""Tests specifying neither unauthenticated nor claims raises ValueError."""
855855
with pytest.raises(
856856
ValueError,
857857
match="Impersonation requires either 'unauthenticated=True' or 'auth_claims'."
858858
):
859859
dataconnect.Impersonation()
860860

861-
def test_constructor_both_unauth_and_claims_fails(self):
861+
def test_impersonation_constructor_both_unauth_and_claims_fails(self):
862862
"""Tests specifying both unauthenticated and claims raises ValueError."""
863863
with pytest.raises(
864864
ValueError,
865865
match="Cannot specify both 'unauthenticated' and 'auth_claims'."
866866
):
867867
dataconnect.Impersonation(unauthenticated=True, auth_claims={"sub": "123"})
868868

869-
def test_constructor_invalid_unauthenticated_type(self):
869+
def test_impersonation_constructor_invalid_unauthenticated_type(self):
870870
"""Tests non-boolean unauthenticated raises ValueError."""
871871
with pytest.raises(ValueError, match="'unauthenticated' must be a boolean."):
872872
dataconnect.Impersonation(unauthenticated="not-a-bool")
873873

874-
def test_constructor_invalid_auth_claims_type(self):
874+
def test_impersonation_constructor_invalid_auth_claims_type(self):
875875
"""Tests non-dict auth_claims raises ValueError."""
876876
with pytest.raises(ValueError, match="'auth_claims' must be a dictionary."):
877877
dataconnect.Impersonation(auth_claims="not-a-dict")

0 commit comments

Comments
 (0)