diff --git a/src/index.d.ts b/src/index.d.ts index 79d498e219..6c61e32b48 100755 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -5252,11 +5252,11 @@ declare namespace admin.machineLearning { readonly validationError?: string; readonly published: boolean; readonly etag: string; - readonly modelHash: string; + readonly modelHash?: string; readonly locked: boolean; waitForUnlocked(maxTimeSeconds?: number): Promise; - readonly tfLiteModel?: TFLiteModel; + readonly tfliteModel?: TFLiteModel; } /** diff --git a/src/machine-learning/machine-learning-api-client.ts b/src/machine-learning/machine-learning-api-client.ts new file mode 100644 index 0000000000..766752e7e0 --- /dev/null +++ b/src/machine-learning/machine-learning-api-client.ts @@ -0,0 +1,194 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient } from '../utils/api-request'; +import { PrefixedFirebaseError } from '../utils/error'; +import { FirebaseMachineLearningError, MachineLearningErrorCode } from './machine-learning-utils'; +import * as utils from '../utils/index'; +import * as validator from '../utils/validator'; +import { FirebaseApp } from '../firebase-app'; + +const ML_V1BETA1_API = 'https://mlkit.googleapis.com/v1beta1'; +const FIREBASE_VERSION_HEADER = { + 'X-Firebase-Client': 'fire-admin-node/', +}; + +export interface StatusErrorResponse { + readonly code: number; + readonly message: string; +} + +export interface ModelContent { + readonly displayName?: string; + readonly tags?: string[]; + readonly state?: { + readonly validationError?: StatusErrorResponse; + readonly published?: boolean; + }; + readonly tfliteModel?: { + readonly gcsTfliteUri: string; + readonly sizeBytes: number; + }; +} + +export interface ModelResponse extends ModelContent { + readonly name: string; + readonly createTime: string; + readonly updateTime: string; + readonly etag: string; + readonly modelHash?: string; +} + + +/** + * Class that facilitates sending requests to the Firebase ML backend API. + * + * @private + */ +export class MachineLearningApiClient { + private readonly httpClient: HttpClient; + private projectIdPrefix?: string; + + constructor(private readonly app: FirebaseApp) { + if (!validator.isNonNullObject(app) || !('options' in app)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + 'First argument passed to admin.machineLearning() must be a valid ' + + 'Firebase app instance.'); + } + + this.httpClient = new AuthorizedHttpClient(app); + } + + public getModel(modelId: string): Promise { + return Promise.resolve() + .then(() => { + return this.getModelName(modelId); + }) + .then((modelName) => { + return this.getResource(modelName); + }); + } + + /** + * Gets the specified resource from the ML API. Resource names must be the short names without project + * ID prefix (e.g. `models/123456789`). + * + * @param {string} name Full qualified name of the resource to get. + * @returns {Promise} A promise that fulfills with the resource. + */ + private getResource(name: string): Promise { + return this.getUrl() + .then((url) => { + const request: HttpRequestConfig = { + method: 'GET', + url: `${url}/${name}`, + }; + return this.sendRequest(request); + }); + } + + private sendRequest(request: HttpRequestConfig): Promise { + request.headers = FIREBASE_VERSION_HEADER; + return this.httpClient.send(request) + .then((resp) => { + return resp.data as T; + }) + .catch((err) => { + throw this.toFirebaseError(err); + }); + } + + private toFirebaseError(err: HttpError): PrefixedFirebaseError { + if (err instanceof PrefixedFirebaseError) { + return err; + } + + const response = err.response; + if (!response.isJson()) { + return new FirebaseMachineLearningError( + 'unknown-error', + `Unexpected response with status: ${response.status} and body: ${response.text}`); + } + + const error: Error = (response.data as ErrorResponse).error || {}; + let code: MachineLearningErrorCode = 'unknown-error'; + if (error.status && error.status in ERROR_CODE_MAPPING) { + code = ERROR_CODE_MAPPING[error.status]; + } + const message = error.message || `Unknown server error: ${response.text}`; + return new FirebaseMachineLearningError(code, message); + } + + private getUrl(): Promise { + return this.getProjectIdPrefix() + .then((projectIdPrefix) => { + return `${ML_V1BETA1_API}/${this.projectIdPrefix}`; + }); + } + + private getProjectIdPrefix(): Promise { + if (this.projectIdPrefix) { + return Promise.resolve(this.projectIdPrefix); + } + + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + 'Failed to determine project ID. Initialize the SDK with service account credentials, or ' + + 'set project ID as an app option. Alternatively, set the GOOGLE_CLOUD_PROJECT ' + + 'environment variable.'); + } + + this.projectIdPrefix = `projects/${projectId}`; + return this.projectIdPrefix; + }); + } + + private getModelName(modelId: string): string { + if (!validator.isNonEmptyString(modelId)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', 'Model ID must be a non-empty string.'); + } + + if (modelId.indexOf('/') !== -1) { + throw new FirebaseMachineLearningError( + 'invalid-argument', 'Model ID must not contain any "/" characters.'); + } + + return `models/${modelId}`; + } +} + +interface ErrorResponse { + error?: Error; +} + +interface Error { + code?: number; + message?: string; + status?: string; +} + +const ERROR_CODE_MAPPING: {[key: string]: MachineLearningErrorCode} = { + INVALID_ARGUMENT: 'invalid-argument', + NOT_FOUND: 'not-found', + RESOURCE_EXHAUSTED: 'resource-exhausted', + UNAUTHENTICATED: 'authentication-error', + UNKNOWN: 'unknown-error', +}; diff --git a/src/machine-learning/machine-learning-utils.ts b/src/machine-learning/machine-learning-utils.ts new file mode 100644 index 0000000000..0b4f84a64f --- /dev/null +++ b/src/machine-learning/machine-learning-utils.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PrefixedFirebaseError } from '../utils/error'; + +export type MachineLearningErrorCode = + 'already-exists' + | 'authentication-error' + | 'internal-error' + | 'invalid-argument' + | 'invalid-server-response' + | 'not-found' + | 'resource-exhausted' + | 'service-unavailable' + | 'unknown-error'; + +export class FirebaseMachineLearningError extends PrefixedFirebaseError { + constructor(code: MachineLearningErrorCode, message: string) { + super('machine-learning', code, message); + } +} diff --git a/src/machine-learning/machine-learning.ts b/src/machine-learning/machine-learning.ts index 04b648d07b..9e61e42120 100644 --- a/src/machine-learning/machine-learning.ts +++ b/src/machine-learning/machine-learning.ts @@ -17,9 +17,11 @@ import {FirebaseApp} from '../firebase-app'; import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; +import {MachineLearningApiClient, ModelResponse} from './machine-learning-api-client'; import {FirebaseError} from '../utils/error'; import * as validator from '../utils/validator'; +import {FirebaseMachineLearningError} from './machine-learning-utils'; // const ML_HOST = 'mlkit.googleapis.com'; @@ -58,6 +60,7 @@ export interface ListModelsResult { export class MachineLearning implements FirebaseServiceInterface { public readonly INTERNAL = new MachineLearningInternals(); + private readonly client: MachineLearningApiClient; private readonly appInternal: FirebaseApp; /** @@ -68,12 +71,13 @@ export class MachineLearning implements FirebaseServiceInterface { if (!validator.isNonNullObject(app) || !('options' in app)) { throw new FirebaseError({ code: 'machine-learning/invalid-argument', - message: 'First argument passed to admin.MachineLearning() must be a ' + + message: 'First argument passed to admin.machineLearning() must be a ' + 'valid Firebase app instance.', }); } this.appInternal = app; + this.client = new MachineLearningApiClient(app); } /** @@ -138,7 +142,10 @@ export class MachineLearning implements FirebaseServiceInterface { * @return {Promise} A Promise fulfilled with the unpublished model. */ public getModel(modelId: string): Promise { - throw new Error('NotImplemented'); + return this.client.getModel(modelId) + .then((modelResponse) => { + return new Model(modelResponse); + }); } /** @@ -172,14 +179,48 @@ export class Model { public readonly modelId: string; public readonly displayName: string; public readonly tags?: string[]; - public readonly createTime: number; - public readonly updateTime: number; + public readonly createTime: string; + public readonly updateTime: string; public readonly validationError?: string; public readonly published: boolean; public readonly etag: string; - public readonly modelHash: string; + public readonly modelHash?: string; + + public readonly tfliteModel?: TFLiteModel; + + constructor(model: ModelResponse) { + if (!validator.isNonNullObject(model) || + !validator.isNonEmptyString(model.name) || + !validator.isNonEmptyString(model.createTime) || + !validator.isNonEmptyString(model.updateTime) || + !validator.isNonEmptyString(model.displayName) || + !validator.isNonEmptyString(model.etag)) { + throw new FirebaseMachineLearningError( + 'invalid-argument', + `Invalid Model response: ${JSON.stringify(model)}`); + } + + this.modelId = extractModelId(model.name); + this.displayName = model.displayName; + this.tags = model.tags || []; + this.createTime = new Date(model.createTime).toUTCString(); + this.updateTime = new Date(model.updateTime).toUTCString(); + if (model.state?.validationError?.message) { + this.validationError = model.state?.validationError?.message; + } + this.published = model.state?.published || false; + this.etag = model.etag; + if (model.modelHash) { + this.modelHash = model.modelHash; + } + if (model.tfliteModel) { + this.tfliteModel = { + gcsTfliteUri: model.tfliteModel.gcsTfliteUri, + sizeBytes: model.tfliteModel.sizeBytes, + }; + } - public readonly tfLiteModel?: TFLiteModel; + } public get locked(): boolean { // Backend does not currently return locked models. @@ -211,9 +252,13 @@ export class ModelOptions { public displayName?: string; public tags?: string[]; - public tfLiteModel?: { gcsTFLiteUri: string; }; + public tfliteModel?: { gcsTFLiteUri: string; }; protected toJSON(forUpload?: boolean): object { throw new Error('NotImplemented'); } } + +function extractModelId(resourceName: string): string { + return resourceName.split('/').pop()!; +} diff --git a/test/integration/machine-learning.spec.ts b/test/integration/machine-learning.spec.ts new file mode 100644 index 0000000000..5663b0dc62 --- /dev/null +++ b/test/integration/machine-learning.spec.ts @@ -0,0 +1,34 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as admin from '../../lib/index'; + +describe('admin.machineLearning', () => { + describe('getModel()', () => { + it('rejects with not-found when the Model does not exist', () => { + const nonExistingName = '00000000'; + return admin.machineLearning().getModel(nonExistingName) + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/not-found'); + }); + + it('rejects with invalid-argument when the ModelId is invalid', () => { + return admin.machineLearning().getModel('invalid-model-id') + .should.eventually.be.rejected.and.have.property( + 'code', 'machine-learning/invalid-argument'); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 6bd00c36d4..f55a8e9e73 100755 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -45,6 +45,10 @@ import './database/database.spec'; import './messaging/messaging.spec'; import './messaging/batch-requests.spec'; +// Machine Learning +import './machine-learning/machine-learning.spec'; +import './machine-learning/machine-learning-api-client.spec'; + // Storage import './storage/storage.spec'; diff --git a/test/unit/machine-learning/machine-learning-api-client.spec.ts b/test/unit/machine-learning/machine-learning-api-client.spec.ts new file mode 100644 index 0000000000..16875e292d --- /dev/null +++ b/test/unit/machine-learning/machine-learning-api-client.spec.ts @@ -0,0 +1,159 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { MachineLearningApiClient } from '../../../src/machine-learning/machine-learning-api-client'; +import { FirebaseMachineLearningError } from '../../../src/machine-learning/machine-learning-utils'; +import { HttpClient } from '../../../src/utils/api-request'; +import * as utils from '../utils'; +import * as mocks from '../../resources/mocks'; +import { FirebaseAppError } from '../../../src/utils/error'; +import { FirebaseApp } from '../../../src/firebase-app'; + +const expect = chai.expect; + +describe('MachineLearningApiClient', () => { + + const MODEL_ID = '1234567'; + const ERROR_RESPONSE = { + error: { + code: 404, + message: 'Requested entity not found', + status: 'NOT_FOUND', + }, + }; + const EXPECTED_HEADERS = { + 'Authorization': 'Bearer mock-token', + 'X-Firebase-Client': 'fire-admin-node/', + }; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials, or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + + const mockOptions = { + credential: new mocks.MockCredential(), + projectId: 'test-project', + }; + + const clientWithoutProjectId = new MachineLearningApiClient( + mocks.mockCredentialApp()); + + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + let app: FirebaseApp; + let apiClient: MachineLearningApiClient; + + beforeEach(() => { + app = mocks.appWithOptions(mockOptions); + apiClient = new MachineLearningApiClient(app); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + return app.delete(); + }); + + describe('Constructor', () => { + it('should throw when the app is null', () => { + expect(() => new MachineLearningApiClient(null as unknown as FirebaseApp)) + .to.throw('First argument passed to admin.machineLearning() must be a valid Firebase app'); + }); + }); + + describe('getModel', () => { + const INVALID_NAMES: any[] = [null, undefined, '', 1, true, {}, []]; + INVALID_NAMES.forEach((invalidName) => { + it(`should reject when called with: ${JSON.stringify(invalidName)}`, () => { + return apiClient.getModel(invalidName) + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must be a non-empty string.'); + }); + }); + + it(`should reject when called with prefixed name`, () => { + return apiClient.getModel('projects/foo/models/bar') + .should.eventually.be.rejected.and.have.property( + 'message', 'Model ID must not contain any "/" characters.'); + }); + + it(`should reject when project id is not available`, () => { + return clientWithoutProjectId.getModel(MODEL_ID) + .should.eventually.be.rejectedWith(noProjectId); + }); + + it('should resolve with the requested model on success', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .resolves(utils.responseFrom({ name: 'bar' })); + stubs.push(stub); + return apiClient.getModel(MODEL_ID) + .then((resp) => { + expect(resp.name).to.equal('bar'); + expect(stub).to.have.been.calledOnce.and.calledWith({ + method: 'GET', + url: 'https://mlkit.googleapis.com/v1beta1/projects/test-project/models/1234567', + headers: EXPECTED_HEADERS, + }); + }); + }); + + it('should reject when a full platform error response is received', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom(ERROR_RESPONSE, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('not-found', 'Requested entity not found'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error when error code is not present', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom({}, 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError('unknown-error', 'Unknown server error: {}'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject unknown-error for non-json response', () => { + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(utils.errorFrom('not json', 404)); + stubs.push(stub); + const expected = new FirebaseMachineLearningError( + 'unknown-error', 'Unexpected response with status: 404 and body: not json'); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + + it('should reject when rejected with a FirebaseAppError', () => { + const expected = new FirebaseAppError('network-error', 'socket hang up'); + const stub = sinon + .stub(HttpClient.prototype, 'send') + .rejects(expected); + stubs.push(stub); + return apiClient.getModel(MODEL_ID) + .should.eventually.be.rejected.and.deep.equal(expected); + }); + }); +}); diff --git a/test/unit/machine-learning/machine-learning.spec.ts b/test/unit/machine-learning/machine-learning.spec.ts new file mode 100644 index 0000000000..7652aa7944 --- /dev/null +++ b/test/unit/machine-learning/machine-learning.spec.ts @@ -0,0 +1,244 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +import * as _ from 'lodash'; +import * as chai from 'chai'; +import * as sinon from 'sinon'; +import { MachineLearning } from '../../../src/machine-learning/machine-learning'; +import { FirebaseApp } from '../../../src/firebase-app'; +import * as mocks from '../../resources/mocks'; +import { MachineLearningApiClient } from '../../../src/machine-learning/machine-learning-api-client'; +import { FirebaseMachineLearningError } from '../../../src/machine-learning/machine-learning-utils'; +import { deepCopy } from '../../../src/utils/deep-copy'; + +const expect = chai.expect; + +describe('MachineLearning', () => { + + const EXPECTED_ERROR = new FirebaseMachineLearningError('internal-error', 'message'); + const MODEL_RESPONSE: { + name: string; + createTime: string; + updateTime: string; + etag: string; + modelHash: string; + displayName?: string; + tags?: string[]; + state?: { + validationError?: { + code: number; + message: string; + }; + published?: boolean; + }; + tfliteModel?: { + gcsTfliteUri: string; + sizeBytes: number; + }; + } = { + name: 'projects/test-project/models/1234567', + createTime: '2020-02-07T23:45:23.288047Z', + updateTime: '2020-02-08T23:45:23.288047Z', + etag: 'etag123', + modelHash: 'modelHash123', + displayName: 'model_1', + tags: ['tag_1', 'tag_2'], + state: {published: true}, + tfliteModel: { + gcsTfliteUri: 'gs://test-project-bucket/Firebase/ML/Models/model1.tflite', + sizeBytes: 16900988, + }, + }; + + const CREATE_TIME_UTC = 'Fri, 07 Feb 2020 23:45:23 GMT'; + const UPDATE_TIME_UTC = 'Sat, 08 Feb 2020 23:45:23 GMT'; + + let machineLearning: MachineLearning; + let mockApp: FirebaseApp; + let mockCredentialApp: FirebaseApp; + + const stubs: sinon.SinonStub[] = []; + + before(() => { + mockApp = mocks.app(); + mockCredentialApp = mocks.mockCredentialApp(); + machineLearning = new MachineLearning(mockApp); + }); + + after(() => { + return mockApp.delete(); + }); + + afterEach(() => { + _.forEach(stubs, (stub) => stub.restore()); + }); + + describe('Constructor', () => { + const invalidApps = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidApps.forEach((invalidApp) => { + it('should throw given invalid app: ' + JSON.stringify(invalidApp), () => { + expect(() => { + const machineLearningAny: any = MachineLearning; + return new machineLearningAny(invalidApp); + }).to.throw( + 'First argument passed to admin.machineLearning() must be a valid Firebase app ' + + 'instance.'); + }); + }); + + it('should throw given no app', () => { + expect(() => { + const machineLearningAny: any = MachineLearning; + return new machineLearningAny(); + }).to.throw( + 'First argument passed to admin.machineLearning() must be a valid Firebase app ' + + 'instance.'); + }); + + it('should reject when initialized without project ID', () => { + // Project ID not set in the environment. + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.GCLOUD_PROJECT; + const noProjectId = 'Failed to determine project ID. Initialize the SDK with service ' + + 'account credentials, or set project ID as an app option. Alternatively, set the ' + + 'GOOGLE_CLOUD_PROJECT environment variable.'; + const rulesWithoutProjectId = new MachineLearning(mockCredentialApp); + return rulesWithoutProjectId.getModel('test') + .should.eventually.rejectedWith(noProjectId); + }); + + it('should not throw given a valid app', () => { + expect(() => { + return new MachineLearning(mockApp); + }).not.to.throw(); + }); + }); + + describe('app', () => { + it('returns the app from the constructor', () => { + // We expect referential equality here + expect(machineLearning.app).to.equal(mockApp); + }); + }); + + describe('getModel', () => { + it('should propagate API errors', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .rejects(EXPECTED_ERROR); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.deep.equal(EXPECTED_ERROR); + }); + + it('should reject when API response is invalid', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(null); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', 'Invalid Model response: null'); + }); + + it('should reject when API response does not contain a name', () => { + const response = deepCopy(MODEL_RESPONSE); + response.name = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a createTime', () => { + const response = deepCopy(MODEL_RESPONSE); + response.createTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a updateTime', () => { + const response = deepCopy(MODEL_RESPONSE); + response.updateTime = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain a displayName', () => { + const response = deepCopy(MODEL_RESPONSE); + response.displayName = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should reject when API response does not contain an etag', () => { + const response = deepCopy(MODEL_RESPONSE); + response.etag = ''; + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(response); + stubs.push(stub); + return machineLearning.getModel('1234567') + .should.eventually.be.rejected.and.have.property( + 'message', `Invalid Model response: ${JSON.stringify(response)}`); + }); + + it('should resolve with Model on success', () => { + const stub = sinon + .stub(MachineLearningApiClient.prototype, 'getModel') + .resolves(MODEL_RESPONSE); + stubs.push(stub); + + return machineLearning.getModel('1234567') + .then((model) => { + expect(model.modelId).to.equal('1234567'); + expect(model.displayName).to.equal('model_1'); + expect(model.tags).to.deep.equal(['tag_1', 'tag_2']); + expect(model.createTime).to.equal(CREATE_TIME_UTC); + expect(model.updateTime).to.equal(UPDATE_TIME_UTC); + expect(model.validationError).to.be.empty; + expect(model.published).to.be.true; + expect(model.etag).to.equal('etag123'); + expect(model.modelHash).to.equal('modelHash123'); + + const tflite = model.tfliteModel!; + expect(tflite.gcsTfliteUri).to.be.equal( + 'gs://test-project-bucket/Firebase/ML/Models/model1.tflite'); + expect(tflite.sizeBytes).to.be.equal(16900988); + }); + }); + }); +});