diff --git a/firebase_admin/project_management.py b/firebase_admin/project_management.py index ed292b80..ffa75ea6 100644 --- a/firebase_admin/project_management.py +++ b/firebase_admin/project_management.py @@ -62,6 +62,19 @@ def ios_app(app_id, app=None): return IOSApp(app_id=app_id, service=_get_project_management_service(app)) +def web_app(app_id, app=None): + """Obtains a reference to a Web app in the associated Firebase project. + + Args: + app_id: The app ID that identifies this Web app. + app: An App instance (optional). + + Returns: + WebApp: An ``WebApp`` instance. + """ + return WebApp(app_id=app_id, service=_get_project_management_service(app)) + + def list_android_apps(app=None): """Lists all Android apps in the associated Firebase project. @@ -87,6 +100,19 @@ def list_ios_apps(app=None): return _get_project_management_service(app).list_ios_apps() +def list_web_apps(app=None): + """Lists all Web apps in the associated Firebase project. + + Args: + app: An App instance (optional). + + Returns: + list: a list of ``WebApp`` instances referring to each Web app in the Firebase + project. + """ + return _get_project_management_service(app).list_web_apps() + + def create_android_app(package_name, display_name=None, app=None): """Creates a new Android app in the associated Firebase project. @@ -115,6 +141,19 @@ def create_ios_app(bundle_id, display_name=None, app=None): return _get_project_management_service(app).create_ios_app(bundle_id, display_name) +def create_web_app(display_name=None, app=None): + """Creates a new Web app in the associated Firebase project. + + Args: + display_name: A nickname for this Web app (optional). + app: An App instance (optional). + + Returns: + WebApp: An ``WebApp`` instance that is a reference to the newly created app. + """ + return _get_project_management_service(app).create_web_app(display_name) + + def _check_is_string_or_none(obj, field_name): if obj is None or isinstance(obj, str): return obj @@ -293,6 +332,61 @@ def get_config(self): return self._service.get_ios_app_config(self._app_id) +class WebApp: + """A reference to a Web app within a Firebase project. + + Note: Unless otherwise specified, all methods defined in this class make an RPC. + + Please use the module-level function ``web_app(app_id)`` to obtain instances of this class + instead of instantiating it directly. + """ + def __init__(self, app_id, service): + self._app_id = app_id + self._service = service + + @property + def app_id(self): + """Returns the app ID of the Web app to which this instance refers. + + Note: This method does not make an RPC. + + Returns: + string: The app ID of the Web app to which this instance refers. + """ + return self._app_id + + def get_metadata(self): + """Retrieves detailed information about this Web app. + + Returns: + WebAppMetadata: An ``WebAppMetadata`` instance. + + Raises: + FirebaseError: If an error occurs while communicating with the Firebase Project + Management Service. + """ + return self._service.get_web_app_metadata(self._app_id) + + def set_display_name(self, new_display_name): + """Updates the display name attribute of this Web app to the one given. + + Args: + new_display_name: The new display name for this Web app. + + Returns: + NoneType: None. + + Raises: + FirebaseError: If an error occurs while communicating with the Firebase Project + Management Service. + """ + return self._service.set_web_app_display_name(self._app_id, new_display_name) + + def get_config(self): + """Retrieves the configuration artifact associated with this Web app.""" + return self._service.get_web_app_config(self._app_id) + + class _AppMetadata: """Detailed information about a Firebase Android or iOS app.""" @@ -332,6 +426,9 @@ def __eq__(self, other): self.display_name == other.display_name and self.project_id == other.project_id) # pylint: enable=protected-access + def __ne__(self, other): + return not self.__eq__(other) + class AndroidAppMetadata(_AppMetadata): """Android-specific information about an Android Firebase app.""" @@ -350,9 +447,6 @@ def __eq__(self, other): return (super(AndroidAppMetadata, self).__eq__(other) and self.package_name == other.package_name) - def __ne__(self, other): - return not self.__eq__(other) - def __hash__(self): return hash( (self._name, self.app_id, self.display_name, self.project_id, self.package_name)) @@ -374,13 +468,16 @@ def bundle_id(self): def __eq__(self, other): return super(IOSAppMetadata, self).__eq__(other) and self.bundle_id == other.bundle_id - def __ne__(self, other): - return not self.__eq__(other) - def __hash__(self): return hash((self._name, self.app_id, self.display_name, self.project_id, self.bundle_id)) +class WebAppMetadata(_AppMetadata): + + def __hash__(self): + return hash((self._name, self.app_id, self.display_name, self.project_id)) + + class SHACertificate: """Represents a SHA-1 or SHA-256 certificate associated with an Android app.""" @@ -468,6 +565,7 @@ class _ProjectManagementService: ANDROID_APP_IDENTIFIER_NAME = 'packageName' IOS_APPS_RESOURCE_NAME = 'iosApps' IOS_APP_IDENTIFIER_NAME = 'bundleId' + WEB_APPS_RESOURCE_NAME = 'webApps' def __init__(self, app): project_id = app.project_id @@ -499,13 +597,25 @@ def get_ios_app_metadata(self, app_id): metadata_class=IOSAppMetadata, app_id=app_id) - def _get_app_metadata(self, platform_resource_name, identifier_name, metadata_class, app_id): - """Retrieves detailed information about an Android or iOS app.""" + def get_web_app_metadata(self, app_id): + return self._get_app_metadata( + platform_resource_name=_ProjectManagementService.WEB_APPS_RESOURCE_NAME, + metadata_class=WebAppMetadata, + app_id=app_id) + + def _get_app_metadata( + self, + platform_resource_name, + metadata_class, + app_id, + identifier_name=None): + """Retrieves detailed information about a Firebase app.""" _check_is_nonempty_string(app_id, 'app_id') path = '/v1beta1/projects/-/{0}/{1}'.format(platform_resource_name, app_id) response = self._make_request('get', path) + args = [] if not identifier_name else [response[identifier_name]] return metadata_class( - response[identifier_name], + *args, name=response['name'], app_id=response['appId'], display_name=response.get('displayName') or None, @@ -523,6 +633,12 @@ def set_ios_app_display_name(self, app_id, new_display_name): new_display_name=new_display_name, platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME) + def set_web_app_display_name(self, app_id, new_display_name): + self._set_display_name( + app_id=app_id, + new_display_name=new_display_name, + platform_resource_name=_ProjectManagementService.WEB_APPS_RESOURCE_NAME) + def _set_display_name(self, app_id, new_display_name, platform_resource_name): """Sets the display name of an Android or iOS app.""" path = '/v1beta1/projects/-/{0}/{1}?updateMask=displayName'.format( @@ -540,6 +656,11 @@ def list_ios_apps(self): platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, app_class=IOSApp) + def list_web_apps(self): + return self._list_apps( + platform_resource_name=_ProjectManagementService.WEB_APPS_RESOURCE_NAME, + app_class=WebApp) + def _list_apps(self, platform_resource_name, app_class): """Lists all the Android or iOS apps within the Firebase project.""" path = '/v1beta1/projects/{0}/{1}?pageSize={2}'.format( @@ -568,30 +689,28 @@ def _list_apps(self, platform_resource_name, app_class): def create_android_app(self, package_name, display_name=None): return self._create_app( platform_resource_name=_ProjectManagementService.ANDROID_APPS_RESOURCE_NAME, - identifier_name=_ProjectManagementService.ANDROID_APP_IDENTIFIER_NAME, - identifier=package_name, display_name=display_name, - app_class=AndroidApp) + app_class=AndroidApp, + identifier={_ProjectManagementService.ANDROID_APP_IDENTIFIER_NAME: package_name}) def create_ios_app(self, bundle_id, display_name=None): return self._create_app( platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, - identifier_name=_ProjectManagementService.IOS_APP_IDENTIFIER_NAME, - identifier=bundle_id, display_name=display_name, - app_class=IOSApp) + app_class=IOSApp, + identifier={_ProjectManagementService.IOS_APP_IDENTIFIER_NAME: bundle_id}) - def _create_app( - self, - platform_resource_name, - identifier_name, - identifier, - display_name, - app_class): - """Creates an Android or iOS app.""" + def create_web_app(self, display_name=None): + return self._create_app( + platform_resource_name=_ProjectManagementService.WEB_APPS_RESOURCE_NAME, + display_name=display_name, + app_class=WebApp) + + def _create_app(self, platform_resource_name, display_name, app_class, identifier=None): + """Creates a Firebase app.""" _check_is_string_or_none(display_name, 'display_name') path = '/v1beta1/projects/{0}/{1}'.format(self._project_id, platform_resource_name) - request_body = {identifier_name: identifier} + request_body = identifier or {} if display_name: request_body['displayName'] = display_name response = self._make_request('post', path, json=request_body) @@ -628,12 +747,23 @@ def get_ios_app_config(self, app_id): return self._get_app_config( platform_resource_name=_ProjectManagementService.IOS_APPS_RESOURCE_NAME, app_id=app_id) + def get_web_app_config(self, app_id): + return self._get_app_config( + platform_resource_name=_ProjectManagementService.WEB_APPS_RESOURCE_NAME, app_id=app_id) + def _get_app_config(self, platform_resource_name, app_id): + """Fetches Firebase app specific configuration""" path = '/v1beta1/projects/-/{0}/{1}/config'.format(platform_resource_name, app_id) response = self._make_request('get', path) - # In Python 2.7, the base64 module works with strings, while in Python 3, it works with - # bytes objects. This line works in both versions. - return base64.standard_b64decode(response['configFileContents']).decode(encoding='utf-8') + try: + config_file_contents = response['configFileContents'] + except KeyError: + # Web apps return a plain dict + return response + else: + # In Python 2.7, the base64 module works with strings, while in Python 3, it works with + # bytes objects. This line works in both versions. + return base64.standard_b64decode(config_file_contents).decode(encoding='utf-8') def get_sha_certificates(self, app_id): path = '/v1beta1/projects/-/androidApps/{0}/sha'.format(app_id) diff --git a/integration/test_project_management.py b/integration/test_project_management.py index ca648f12..0df89a4c 100644 --- a/integration/test_project_management.py +++ b/integration/test_project_management.py @@ -62,6 +62,16 @@ def ios_app(default_app): bundle_id=TEST_APP_BUNDLE_ID, display_name=TEST_APP_DISPLAY_NAME_PREFIX) +@pytest.fixture(scope='module') +def web_app(default_app): + del default_app + web_apps = project_management.list_web_apps() + for web_app in web_apps: + if _starts_with(web_app.get_metadata().display_name, TEST_APP_DISPLAY_NAME_PREFIX): + return web_app + return project_management.create_web_app(display_name=TEST_APP_DISPLAY_NAME_PREFIX) + + def test_create_android_app_already_exists(android_app): del android_app @@ -204,3 +214,35 @@ def test_get_ios_app_config(ios_app, project_id): assert plist['BUNDLE_ID'] == TEST_APP_BUNDLE_ID assert plist['PROJECT_ID'] == project_id assert plist['GOOGLE_APP_ID'] == ios_app.app_id + + +def test_web_set_display_name_and_get_metadata(web_app, project_id): + app_id = web_app.app_id + web_app = project_management.web_app(app_id) + new_display_name = '{0} helloworld {1}'.format( + TEST_APP_DISPLAY_NAME_PREFIX, random.randint(0, 10000)) + + web_app.set_display_name(new_display_name) + metadata = project_management.web_app(app_id).get_metadata() + web_app.set_display_name(TEST_APP_DISPLAY_NAME_PREFIX) # Revert the display name. + + assert metadata._name == 'projects/{0}/webApps/{1}'.format(project_id, app_id) + assert metadata.app_id == app_id + assert metadata.project_id == project_id + assert metadata.display_name == new_display_name + + +def test_list_web_apps(web_app): + del web_app + + web_apps = project_management.list_web_apps() + + assert any(_starts_with(web_app.get_metadata().display_name, TEST_APP_DISPLAY_NAME_PREFIX) + for web_app in web_apps) + + +def test_get_web_app_config(web_app, project_id): + config = web_app.get_config() + + assert config['projectId'] == project_id + assert config['appId'] == web_app.app_id diff --git a/tests/test_project_management.py b/tests/test_project_management.py index 18319551..64fb622d 100644 --- a/tests/test_project_management.py +++ b/tests/test_project_management.py @@ -102,6 +102,36 @@ 'projectId': 'test-project-id', 'bundleId': 'com.hello.world.ios', }) +WEB_APP_OPERATION_SUCCESSFUL_RESPONSE = json.dumps({ + 'name': 'operations/abcdefg', + 'done': True, + 'response': { + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'displayName': 'My Web App', + 'projectId': 'test-project-id', + }, +}) +WEB_APP_NO_DISPLAY_NAME_OPERATION_SUCCESSFUL_RESPONSE = json.dumps({ + 'name': 'operations/abcdefg', + 'done': True, + 'response': { + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'projectId': 'test-project-id', + }, +}) +WEB_APP_METADATA_RESPONSE = json.dumps({ + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'displayName': 'My Web App', + 'projectId': 'test-project-id', +}) +WEB_APP_NO_DISPLAY_NAME_METADATA_RESPONSE = json.dumps({ + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'projectId': 'test-project-id', +}) LIST_ANDROID_APPS_RESPONSE = json.dumps({'apps': [ { @@ -165,6 +195,33 @@ 'projectId': 'test-project-id', 'bundleId': 'com.hello.world.ios2', }]}) +LIST_WEB_APPS_RESPONSE = json.dumps({'apps': [ + { + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'displayName': 'My Web App', + 'projectId': 'test-project-id', + }, + { + 'name': 'projects/test-project-id/webApps/1:12345678:web:darkweb', + 'appId': '1:12345678:web:darkweb', + 'projectId': 'test-project-id', + }]}) +LIST_WEB_APPS_PAGE_1_RESPONSE = json.dumps({ + 'apps': [{ + 'name': 'projects/test-project-id/webApps/1:12345678:web:deepweb', + 'appId': '1:12345678:web:deepweb', + 'displayName': 'My Web App', + 'projectId': 'test-project-id', + }], + 'nextPageToken': 'nextpagetoken', +}) +LIST_WEB_APPS_PAGE_2_RESPONSE = json.dumps({ + 'apps': [{ + 'name': 'projects/test-project-id/webApps/1:12345678:web:darkweb', + 'appId': '1:12345678:web:darkweb', + 'projectId': 'test-project-id', + }]}) # In Python 2.7, the base64 module works with strings, while in Python 3, it works with bytes # objects. This line works in both versions. @@ -197,6 +254,11 @@ app_id='1:12345678:android:deadbeef', display_name='My iOS App', project_id='test-project-id') +WEB_APP_METADATA = project_management.WebAppMetadata( + name='projects/test-project-id/webApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name='My Web App', + project_id='test-project-id') ALREADY_EXISTS_RESPONSE = ('{"error": {"status": "ALREADY_EXISTS", ' '"message": "The resource already exists"}}') @@ -494,6 +556,90 @@ def test_sha_certificate_cert_type(self): assert SHA_256_CERTIFICATE.cert_type == 'SHA_256' +class TestWebAppMetadata: + + def test_create_web_app_metadata_errors(self): + # name must be a non-empty string. + with pytest.raises(ValueError): + project_management.WebAppMetadata( + name='', + app_id='1:12345678:web:deepweb', + display_name='My Web App', + project_id='test-project-id') + # app_id must be a non-empty string. + with pytest.raises(ValueError): + project_management.WebAppMetadata( + name='projects/test-project-id/androidApps/1:12345678:web:deepweb', + app_id='', + display_name='My Web App', + project_id='test-project-id') + # display_name must be a string or None. + with pytest.raises(ValueError): + project_management.WebAppMetadata( + name='projects/test-project-id/androidApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name=0, + project_id='test-project-id') + # project_id must be a nonempty string. + with pytest.raises(ValueError): + project_management.WebAppMetadata( + name='projects/test-project-id/androidApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name='projects/test-project-id/androidApps/1:12345678:web:deepweb', + project_id='') + + def test_web_app_metadata_eq_and_hash(self): + metadata_1 = WEB_APP_METADATA + metadata_2 = project_management.WebAppMetadata( + name='different', + app_id='1:12345678:web:deepweb', + display_name='My Web App', + project_id='test-project-id') + metadata_3 = project_management.WebAppMetadata( + name='projects/test-project-id/webApps/1:12345678:web:deepweb', + app_id='different', + display_name='My Web App', + project_id='test-project-id') + metadata_4 = project_management.WebAppMetadata( + name='projects/test-project-id/webApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name=None, + project_id='test-project-id') + metadata_5 = project_management.WebAppMetadata( + name='projects/test-project-id/webApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name='My Web App', + project_id='different') + metadata_6 = project_management.WebAppMetadata( + name='projects/test-project-id/webApps/1:12345678:web:deepweb', + app_id='1:12345678:web:deepweb', + display_name='My Web App', + project_id='test-project-id') + ios_metadata = IOS_APP_METADATA + + # Don't trigger __ne__. + assert not metadata_1 == ios_metadata # pylint: disable=unneeded-not + assert metadata_1 != ios_metadata + assert metadata_1 != metadata_2 + assert metadata_1 != metadata_3 + assert metadata_1 != metadata_4 + assert metadata_1 != metadata_5 + assert set([metadata_1, metadata_2, metadata_6]) == set([metadata_1, metadata_2]) + + def test_web_app_metadata_name(self): + assert (WEB_APP_METADATA._name == + 'projects/test-project-id/webApps/1:12345678:web:deepweb') + + def test_web_app_metadata_app_id(self): + assert WEB_APP_METADATA.app_id == '1:12345678:web:deepweb' + + def test_web_app_metadata_display_name(self): + assert WEB_APP_METADATA.display_name == 'My Web App' + + def test_web_app_metadata_project_id(self): + assert WEB_APP_METADATA.project_id == 'test-project-id' + + class BaseProjectManagementTest: @classmethod def setup_class(cls): @@ -790,6 +936,113 @@ def test_create_ios_app_polling_limit_exceeded(self): assert len(recorder) == 3 +class TestCreateWebApp(BaseProjectManagementTest): + _CREATION_URL = 'https://firebase.googleapis.com/v1beta1/projects/test-project-id/webApps' + + def test_create_web_app_without_display_name(self): + recorder = self._instrument_service( + statuses=[200, 200, 200], + responses=[ + OPERATION_IN_PROGRESS_RESPONSE, # Request to create Web app asynchronously. + OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. + WEB_APP_NO_DISPLAY_NAME_OPERATION_SUCCESSFUL_RESPONSE, # Operation completed. + ]) + + web_app = project_management.create_web_app() + + assert web_app.app_id == '1:12345678:web:deepweb' + assert len(recorder) == 3 + self._assert_request_is_correct( + recorder[0], 'POST', TestCreateWebApp._CREATION_URL, {}) + self._assert_request_is_correct( + recorder[1], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') + self._assert_request_is_correct( + recorder[2], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') + + def test_create_web_app(self): + recorder = self._instrument_service( + statuses=[200, 200, 200], + responses=[ + OPERATION_IN_PROGRESS_RESPONSE, # Request to create Web app asynchronously. + OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. + WEB_APP_OPERATION_SUCCESSFUL_RESPONSE, # Creation operation completed. + ]) + + web_app = project_management.create_web_app(display_name='My Web App') + + assert web_app.app_id == '1:12345678:web:deepweb' + assert len(recorder) == 3 + body = {'displayName': 'My Web App'} + self._assert_request_is_correct( + recorder[0], 'POST', TestCreateWebApp._CREATION_URL, body) + self._assert_request_is_correct( + recorder[1], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') + self._assert_request_is_correct( + recorder[2], 'GET', 'https://firebase.googleapis.com/v1/operations/abcdefg') + + def test_create_web_app_already_exists(self): + recorder = self._instrument_service(statuses=[409], responses=[ALREADY_EXISTS_RESPONSE]) + + with pytest.raises(exceptions.AlreadyExistsError) as excinfo: + project_management.create_web_app(display_name='My Web App') + + assert 'The resource already exists' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_create_web_app_polling_rpc_error(self): + recorder = self._instrument_service( + statuses=[200, 200, 503], # Error 503 means that backend servers are over capacity. + responses=[ + OPERATION_IN_PROGRESS_RESPONSE, # Request to create Web app asynchronously. + OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. + UNAVAILABLE_RESPONSE, # Error 503. + ]) + + with pytest.raises(exceptions.UnavailableError) as excinfo: + project_management.create_web_app(display_name='My Web App') + + assert 'Backend servers are over capacity' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 3 + + def test_create_web_app_polling_failure(self): + recorder = self._instrument_service( + statuses=[200, 200, 200], + responses=[ + OPERATION_IN_PROGRESS_RESPONSE, # Request to create Web app asynchronously. + OPERATION_IN_PROGRESS_RESPONSE, # Creation operation is still not done. + OPERATION_FAILED_RESPONSE, # Operation is finished, but terminated with an error. + ]) + + with pytest.raises(exceptions.UnknownError) as excinfo: + project_management.create_web_app(display_name='My Web App') + + assert 'Polling finished, but the operation terminated in an error' in str(excinfo.value) + assert excinfo.value.cause is None + assert excinfo.value.http_response is not None + assert len(recorder) == 3 + + def test_create_web_app_polling_limit_exceeded(self): + project_management._ProjectManagementService.MAXIMUM_POLLING_ATTEMPTS = 2 + recorder = self._instrument_service( + statuses=[200, 200, 200], + responses=[ + OPERATION_IN_PROGRESS_RESPONSE, # Request to create Web app asynchronously. + OPERATION_IN_PROGRESS_RESPONSE, # Creation Operation is still not done. + OPERATION_IN_PROGRESS_RESPONSE, # Creation Operation is still not done. + ]) + + with pytest.raises(exceptions.DeadlineExceededError) as excinfo: + project_management.create_web_app(display_name='My Web App') + + assert 'Polling deadline exceeded' in str(excinfo.value) + assert excinfo.value.cause is None + assert len(recorder) == 3 + + class TestListAndroidApps(BaseProjectManagementTest): _LISTING_URL = ('https://firebase.googleapis.com/v1beta1/projects/test-project-id/' 'androidApps?pageSize=100') @@ -916,6 +1169,69 @@ def test_list_ios_apps_multiple_pages_rpc_error(self): assert len(recorder) == 2 +class TestListWebApps(BaseProjectManagementTest): + _LISTING_URL = ('https://firebase.googleapis.com/v1beta1/projects/test-project-id/' + 'webApps?pageSize=100') + _LISTING_PAGE_2_URL = ('https://firebase.googleapis.com/v1beta1/projects/test-project-id/' + 'webApps?pageToken=nextpagetoken&pageSize=100') + + def test_list_web_apps(self): + recorder = self._instrument_service(statuses=[200], responses=[LIST_WEB_APPS_RESPONSE]) + + web_apps = project_management.list_web_apps() + + expected_app_ids = set(['1:12345678:web:deepweb', '1:12345678:web:darkweb']) + assert set(app.app_id for app in web_apps) == expected_app_ids + assert len(recorder) == 1 + self._assert_request_is_correct(recorder[0], 'GET', TestListWebApps._LISTING_URL) + + def test_list_web_apps_rpc_error(self): + recorder = self._instrument_service(statuses=[503], responses=[UNAVAILABLE_RESPONSE]) + + with pytest.raises(exceptions.UnavailableError) as excinfo: + project_management.list_web_apps() + + assert 'Backend servers are over capacity' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_list_web_apps_empty_list(self): + recorder = self._instrument_service(statuses=[200], responses=[json.dumps(dict())]) + + web_apps = project_management.list_web_apps() + + assert web_apps == [] + assert len(recorder) == 1 + self._assert_request_is_correct(recorder[0], 'GET', TestListWebApps._LISTING_URL) + + def test_list_web_apps_multiple_pages(self): + recorder = self._instrument_service( + statuses=[200, 200], + responses=[LIST_WEB_APPS_PAGE_1_RESPONSE, LIST_WEB_APPS_PAGE_2_RESPONSE]) + + web_apps = project_management.list_web_apps() + + expected_app_ids = set(['1:12345678:web:deepweb', '1:12345678:web:darkweb']) + assert set(app.app_id for app in web_apps) == expected_app_ids + assert len(recorder) == 2 + self._assert_request_is_correct(recorder[0], 'GET', TestListWebApps._LISTING_URL) + self._assert_request_is_correct(recorder[1], 'GET', TestListWebApps._LISTING_PAGE_2_URL) + + def test_list_web_apps_multiple_pages_rpc_error(self): + recorder = self._instrument_service( + statuses=[200, 503], + responses=[LIST_WEB_APPS_PAGE_1_RESPONSE, UNAVAILABLE_RESPONSE]) + + with pytest.raises(exceptions.UnavailableError) as excinfo: + project_management.list_web_apps() + + assert 'Backend servers are over capacity' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 2 + + class TestAndroidApp(BaseProjectManagementTest): _GET_METADATA_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/androidApps/' '1:12345678:android:deadbeef') @@ -1248,3 +1564,117 @@ def evaluate(): project_management.ios_app(app_id='1:12345678:ios:ca5cade5', app=app) testutils.run_without_project_id(evaluate) + + +class TestWebApp(BaseProjectManagementTest): + _GET_METADATA_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/webApps/' + '1:12345678:web:deepweb') + _SET_DISPLAY_NAME_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/webApps/' + '1:12345678:web:deepweb?updateMask=displayName') + _GET_CONFIG_URL = ('https://firebase.googleapis.com/v1beta1/projects/-/webApps/' + '1:12345678:web:deepweb/config') + + @pytest.fixture + def web_app(self): + return project_management.web_app('1:12345678:web:deepweb') + + def test_get_metadata_no_display_name(self, web_app): + recorder = self._instrument_service( + statuses=[200], responses=[WEB_APP_NO_DISPLAY_NAME_METADATA_RESPONSE]) + + metadata = web_app.get_metadata() + + assert metadata._name == 'projects/test-project-id/webApps/1:12345678:web:deepweb' + assert metadata.app_id == '1:12345678:web:deepweb' + assert metadata.display_name is None + assert metadata.project_id == 'test-project-id' + assert len(recorder) == 1 + self._assert_request_is_correct(recorder[0], 'GET', TestWebApp._GET_METADATA_URL) + + def test_get_metadata(self, web_app): + recorder = self._instrument_service(statuses=[200], responses=[WEB_APP_METADATA_RESPONSE]) + + metadata = web_app.get_metadata() + + assert metadata._name == 'projects/test-project-id/webApps/1:12345678:web:deepweb' + assert metadata.app_id == '1:12345678:web:deepweb' + assert metadata.display_name == 'My Web App' + assert metadata.project_id == 'test-project-id' + assert len(recorder) == 1 + self._assert_request_is_correct(recorder[0], 'GET', TestWebApp._GET_METADATA_URL) + + def test_get_metadata_unknown_error(self, web_app): + recorder = self._instrument_service( + statuses=[428], responses=['precondition required error']) + + with pytest.raises(exceptions.UnknownError) as excinfo: + web_app.get_metadata() + + message = 'Unexpected HTTP response with status: 428; body: precondition required error' + assert str(excinfo.value) == message + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_get_metadata_not_found(self, web_app): + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) + + with pytest.raises(exceptions.NotFoundError) as excinfo: + web_app.get_metadata() + + assert 'Failed to find the resource' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_set_display_name(self, web_app): + recorder = self._instrument_service(statuses=[200], responses=[json.dumps({})]) + new_display_name = 'A new display name!' + + web_app.set_display_name(new_display_name) + + assert len(recorder) == 1 + body = {'displayName': new_display_name} + self._assert_request_is_correct( + recorder[0], 'PATCH', TestWebApp._SET_DISPLAY_NAME_URL, body) + + def test_set_display_name_not_found(self, web_app): + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) + new_display_name = 'A new display name!' + + with pytest.raises(exceptions.NotFoundError) as excinfo: + web_app.set_display_name(new_display_name) + + assert 'Failed to find the resource' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_get_config(self, web_app): + recorder = self._instrument_service(statuses=[200], responses=[TEST_APP_CONFIG_RESPONSE]) + + config = web_app.get_config() + + assert config == 'hello world' + assert len(recorder) == 1 + self._assert_request_is_correct(recorder[0], 'GET', TestWebApp._GET_CONFIG_URL) + + def test_get_config_not_found(self, web_app): + recorder = self._instrument_service(statuses=[404], responses=[NOT_FOUND_RESPONSE]) + + with pytest.raises(exceptions.NotFoundError) as excinfo: + web_app.get_config() + + assert 'Failed to find the resource' in str(excinfo.value) + assert excinfo.value.cause is not None + assert excinfo.value.http_response is not None + assert len(recorder) == 1 + + def test_raises_if_app_has_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + + with pytest.raises(ValueError): + project_management.web_app(app_id='1:12345678:web:deepweb', app=app) + + testutils.run_without_project_id(evaluate)