Skip to content

Commit b5eb4bd

Browse files
authored
feat(functions): Add unit and integration tests for task queue api support (#764)
* Unit and Integration tests for task queues. * fix: copyright year * fix: remove commented code
1 parent dbfdb6e commit b5eb4bd

File tree

3 files changed

+352
-0
lines changed

3 files changed

+352
-0
lines changed

integration/test_functions.py

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Copyright 2024 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Integration tests for firebase_admin.functions module."""
16+
17+
import pytest
18+
19+
import firebase_admin
20+
from firebase_admin import functions
21+
from integration import conftest
22+
23+
24+
@pytest.fixture(scope='module')
25+
def app(request):
26+
cred, _ = conftest.integration_conf(request)
27+
return firebase_admin.initialize_app(cred, name='integration-functions')
28+
29+
30+
class TestFunctions:
31+
32+
_TEST_FUNCTIONS_PARAMS = [
33+
{'function_name': 'function-name'},
34+
{'function_name': 'projects/test-project/locations/test-location/functions/function-name'},
35+
{'function_name': 'function-name', 'extension_id': 'extension-id'},
36+
{
37+
'function_name': \
38+
'projects/test-project/locations/test-location/functions/function-name',
39+
'extension_id': 'extension-id'
40+
}
41+
]
42+
43+
@pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS)
44+
def test_task_queue(self, task_queue_params):
45+
queue = functions.task_queue(**task_queue_params)
46+
assert queue is not None
47+
assert callable(queue.enqueue)
48+
assert callable(queue.delete)
49+
50+
@pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS)
51+
def test_task_queue_app(self, task_queue_params, app):
52+
assert app.name == 'integration-functions'
53+
queue = functions.task_queue(**task_queue_params, app=app)
54+
assert queue is not None
55+
assert callable(queue.enqueue)
56+
assert callable(queue.delete)

tests/test_functions.py

+292
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
# Copyright 2024 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Test cases for the firebase_admin.functions module."""
16+
17+
from datetime import datetime, timedelta
18+
import json
19+
import time
20+
import pytest
21+
22+
import firebase_admin
23+
from firebase_admin import functions
24+
from tests import testutils
25+
26+
27+
_DEFAULT_DATA = {'city': 'Seattle'}
28+
_CLOUD_TASKS_URL = 'https://cloudtasks.googleapis.com/v2/'
29+
_DEFAULT_TASK_PATH = \
30+
'projects/test-project/locations/us-central1/queues/test-function-name/tasks/test-task-id'
31+
_DEFAULT_REQUEST_URL = \
32+
_CLOUD_TASKS_URL + 'projects/test-project/locations/us-central1/queues/test-function-name/tasks'
33+
_DEFAULT_TASK_URL = _CLOUD_TASKS_URL + _DEFAULT_TASK_PATH
34+
_DEFAULT_RESPONSE = json.dumps({'name': _DEFAULT_TASK_PATH})
35+
_ENQUEUE_TIME = datetime.utcnow()
36+
_SCHEDULE_TIME = _ENQUEUE_TIME + timedelta(seconds=100)
37+
38+
class TestTaskQueue:
39+
@classmethod
40+
def setup_class(cls):
41+
cred = testutils.MockCredential()
42+
firebase_admin.initialize_app(cred, {'projectId': 'test-project'})
43+
44+
@classmethod
45+
def teardown_class(cls):
46+
testutils.cleanup_apps()
47+
48+
def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE):
49+
if not app:
50+
app = firebase_admin.get_app()
51+
functions_service = functions._get_functions_service(app)
52+
recorder = []
53+
functions_service._http_client.session.mount(
54+
_CLOUD_TASKS_URL,
55+
testutils.MockAdapter(payload, status, recorder))
56+
return functions_service, recorder
57+
58+
@pytest.mark.parametrize('function_name', [
59+
'projects/test-project/locations/us-central1/functions/test-function-name',
60+
'locations/us-central1/functions/test-function-name',
61+
'test-function-name',
62+
])
63+
def test_task_queue_function_name(self, function_name):
64+
queue = functions.task_queue(function_name)
65+
assert queue._resource.resource_id == 'test-function-name'
66+
assert queue._resource.project_id == 'test-project'
67+
assert queue._resource.location_id == 'us-central1'
68+
69+
def test_task_queue_empty_function_name_error(self):
70+
with pytest.raises(ValueError) as excinfo:
71+
functions.task_queue('')
72+
assert str(excinfo.value) == 'function_name "" must be a non-empty string.'
73+
74+
def test_task_queue_non_string_function_name_error(self):
75+
with pytest.raises(ValueError) as excinfo:
76+
functions.task_queue(1234)
77+
assert str(excinfo.value) == 'function_name "1234" must be a string.'
78+
79+
@pytest.mark.parametrize('function_name', [
80+
'/test',
81+
'test/',
82+
'test-project/us-central1/test-function-name',
83+
'projects/test-project/functions/test-function-name',
84+
'functions/test-function-name',
85+
])
86+
def test_task_queue_invalid_function_name_error(self, function_name):
87+
with pytest.raises(ValueError) as excinfo:
88+
functions.task_queue(function_name)
89+
assert str(excinfo.value) == 'Invalid resource name format.'
90+
91+
def test_task_queue_extension_id(self):
92+
queue = functions.task_queue("test-function-name", "test-extension-id")
93+
assert queue._resource.resource_id == 'ext-test-extension-id-test-function-name'
94+
assert queue._resource.project_id == 'test-project'
95+
assert queue._resource.location_id == 'us-central1'
96+
97+
def test_task_queue_empty_extension_id_error(self):
98+
with pytest.raises(ValueError) as excinfo:
99+
functions.task_queue('test-function-name', '')
100+
assert str(excinfo.value) == 'extension_id "" must be a non-empty string.'
101+
102+
def test_task_queue_non_string_extension_id_error(self):
103+
with pytest.raises(ValueError) as excinfo:
104+
functions.task_queue('test-function-name', 1234)
105+
assert str(excinfo.value) == 'extension_id "1234" must be a string.'
106+
107+
108+
def test_task_enqueue(self):
109+
_, recorder = self._instrument_functions_service()
110+
queue = functions.task_queue('test-function-name')
111+
task_id = queue.enqueue(_DEFAULT_DATA)
112+
assert len(recorder) == 1
113+
assert recorder[0].method == 'POST'
114+
assert recorder[0].url == _DEFAULT_REQUEST_URL
115+
assert recorder[0].headers['Content-Type'] == 'application/json'
116+
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
117+
assert task_id == 'test-task-id'
118+
119+
def test_task_enqueue_with_extension(self):
120+
resource_name = (
121+
'projects/test-project/locations/us-central1/queues/'
122+
'ext-test-extension-id-test-function-name/tasks'
123+
)
124+
extension_response = json.dumps({'name': resource_name + '/test-task-id'})
125+
_, recorder = self._instrument_functions_service(payload=extension_response)
126+
queue = functions.task_queue('test-function-name', 'test-extension-id')
127+
task_id = queue.enqueue(_DEFAULT_DATA)
128+
assert len(recorder) == 1
129+
assert recorder[0].method == 'POST'
130+
assert recorder[0].url == _CLOUD_TASKS_URL + resource_name
131+
assert recorder[0].headers['Content-Type'] == 'application/json'
132+
assert recorder[0].headers['Authorization'] == 'Bearer mock-token'
133+
assert task_id == 'test-task-id'
134+
135+
def test_task_delete(self):
136+
_, recorder = self._instrument_functions_service()
137+
queue = functions.task_queue('test-function-name')
138+
queue.delete('test-task-id')
139+
assert len(recorder) == 1
140+
assert recorder[0].method == 'DELETE'
141+
assert recorder[0].url == _DEFAULT_TASK_URL
142+
143+
144+
class TestTaskQueueOptions:
145+
146+
_DEFAULT_TASK_OPTS = {'schedule_delay_seconds': None, 'schedule_time': None, \
147+
'dispatch_deadline_seconds': None, 'task_id': None, 'headers': None}
148+
149+
non_alphanumeric_chars = [
150+
',', '.', '?', '!', ':', ';', "'", '"', '(', ')', '[', ']', '{', '}',
151+
'@', '&', '*', '+', '=', '$', '%', '#', '~', '\\', '/', '|', '^',
152+
'\t', '\n', '\r', '\f', '\v', '\0', '\a', '\b',
153+
'é', 'ç', 'ö', '❤️', '€', '¥', '£', '←', '→', '↑', '↓', 'π', 'Ω', 'ß'
154+
]
155+
156+
@classmethod
157+
def setup_class(cls):
158+
cred = testutils.MockCredential()
159+
firebase_admin.initialize_app(cred, {'projectId': 'test-project'})
160+
161+
@classmethod
162+
def teardown_class(cls):
163+
testutils.cleanup_apps()
164+
165+
def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE):
166+
if not app:
167+
app = firebase_admin.get_app()
168+
functions_service = functions._get_functions_service(app)
169+
recorder = []
170+
functions_service._http_client.session.mount(
171+
_CLOUD_TASKS_URL,
172+
testutils.MockAdapter(payload, status, recorder))
173+
return functions_service, recorder
174+
175+
176+
@pytest.mark.parametrize('task_opts_params', [
177+
{
178+
'schedule_delay_seconds': 100,
179+
'schedule_time': None,
180+
'dispatch_deadline_seconds': 200,
181+
'task_id': 'test-task-id',
182+
'headers': {'x-test-header': 'test-header-value'}
183+
},
184+
{
185+
'schedule_delay_seconds': None,
186+
'schedule_time': _SCHEDULE_TIME,
187+
'dispatch_deadline_seconds': 200,
188+
'task_id': 'test-task-id',
189+
'headers': {'x-test-header': 'test-header-value'}
190+
},
191+
])
192+
def test_task_options(self, task_opts_params):
193+
_, recorder = self._instrument_functions_service()
194+
queue = functions.task_queue('test-function-name')
195+
task_opts = functions.TaskOptions(**task_opts_params)
196+
queue.enqueue(_DEFAULT_DATA, task_opts)
197+
198+
assert len(recorder) == 1
199+
task = json.loads(recorder[0].body.decode())['task']
200+
201+
schedule_time = datetime.fromisoformat(task['schedule_time'][:-1])
202+
delta = abs(schedule_time - _SCHEDULE_TIME)
203+
assert delta <= timedelta(seconds=15)
204+
205+
assert task['dispatch_deadline'] == '200s'
206+
assert task['http_request']['headers']['x-test-header'] == 'test-header-value'
207+
assert task['name'] == _DEFAULT_TASK_PATH
208+
209+
210+
def test_schedule_set_twice_error(self):
211+
_, recorder = self._instrument_functions_service()
212+
opts = functions.TaskOptions(schedule_delay_seconds=100, schedule_time=datetime.utcnow())
213+
queue = functions.task_queue('test-function-name')
214+
with pytest.raises(ValueError) as excinfo:
215+
queue.enqueue(_DEFAULT_DATA, opts)
216+
assert len(recorder) == 0
217+
assert str(excinfo.value) == \
218+
'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.'
219+
220+
221+
@pytest.mark.parametrize('schedule_time', [
222+
time.time(),
223+
str(datetime.utcnow()),
224+
datetime.utcnow().isoformat(),
225+
datetime.utcnow().isoformat() + 'Z',
226+
])
227+
def test_invalid_schedule_time_error(self, schedule_time):
228+
_, recorder = self._instrument_functions_service()
229+
opts = functions.TaskOptions(schedule_time=schedule_time)
230+
queue = functions.task_queue('test-function-name')
231+
with pytest.raises(ValueError) as excinfo:
232+
queue.enqueue(_DEFAULT_DATA, opts)
233+
assert len(recorder) == 0
234+
assert str(excinfo.value) == 'schedule_time should be UTC datetime.'
235+
236+
237+
@pytest.mark.parametrize('schedule_delay_seconds', [
238+
-1,
239+
'100',
240+
'-1',
241+
-1.23,
242+
1.23
243+
])
244+
def test_invalid_schedule_delay_seconds_error(self, schedule_delay_seconds):
245+
_, recorder = self._instrument_functions_service()
246+
opts = functions.TaskOptions(schedule_delay_seconds=schedule_delay_seconds)
247+
queue = functions.task_queue('test-function-name')
248+
with pytest.raises(ValueError) as excinfo:
249+
queue.enqueue(_DEFAULT_DATA, opts)
250+
assert len(recorder) == 0
251+
assert str(excinfo.value) == 'schedule_delay_seconds should be positive int.'
252+
253+
254+
@pytest.mark.parametrize('dispatch_deadline_seconds', [
255+
14,
256+
1801,
257+
-15,
258+
-1800,
259+
0,
260+
'100',
261+
'-1',
262+
-1.23,
263+
1.23,
264+
])
265+
def test_invalid_dispatch_deadline_seconds_error(self, dispatch_deadline_seconds):
266+
_, recorder = self._instrument_functions_service()
267+
opts = functions.TaskOptions(dispatch_deadline_seconds=dispatch_deadline_seconds)
268+
queue = functions.task_queue('test-function-name')
269+
with pytest.raises(ValueError) as excinfo:
270+
queue.enqueue(_DEFAULT_DATA, opts)
271+
assert len(recorder) == 0
272+
assert str(excinfo.value) == \
273+
'dispatch_deadline_seconds should be int in the range of 15s to 1800s (30 mins).'
274+
275+
276+
@pytest.mark.parametrize('task_id', [
277+
'task/1',
278+
'task.1',
279+
'a'*501,
280+
*non_alphanumeric_chars
281+
])
282+
def test_invalid_task_id_error(self, task_id):
283+
_, recorder = self._instrument_functions_service()
284+
opts = functions.TaskOptions(task_id=task_id)
285+
queue = functions.task_queue('test-function-name')
286+
with pytest.raises(ValueError) as excinfo:
287+
queue.enqueue(_DEFAULT_DATA, opts)
288+
assert len(recorder) == 0
289+
assert str(excinfo.value) == (
290+
'task_id can contain only letters ([A-Za-z]), numbers ([0-9]), '
291+
'hyphens (-), or underscores (_). The maximum length is 500 characters.'
292+
)

tests/testutils.py

+4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ class MockGoogleCredential(credentials.Credentials):
119119
def refresh(self, request):
120120
self.token = 'mock-token'
121121

122+
@property
123+
def service_account_email(self):
124+
return 'mock-email'
125+
122126

123127
class MockCredential(firebase_admin.credentials.Base):
124128
"""A mock Firebase credential implementation."""

0 commit comments

Comments
 (0)