diff --git a/src/SessionManager.ts b/src/SessionManager.ts index a796078..7ea4c35 100644 --- a/src/SessionManager.ts +++ b/src/SessionManager.ts @@ -14,12 +14,12 @@ export class SessionManager { private contextName: string, private requestClient: RequestClient ) { - console.log(`🤖[SessionManager constructor]🤖`) if (serverUrl) isUrl(serverUrl) } private sessions: Session[] = [] private currentContext: Context | null = null + private settingContext: boolean = false private _debug: boolean = false private printedSessionState = { printed: false, @@ -34,164 +34,206 @@ export class SessionManager { this._debug = value } - async getSession(accessToken?: string) { - console.log(`🤖[]🤖`) - console.log(`🤖[---- SessionManager getSession start]🤖`) - console.log( - `🤖[this.sessions]🤖`, - this.sessions.map((session: any) => session.id) + private isSessionValid(session: Session) { + if (!session) return false + + const secondsSinceSessionCreation = + (new Date().getTime() - new Date(session.creationTimeStamp).getTime()) / + 1000 + + if ( + !session!.attributes || + secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout + ) { + return false + } else { + return true + } + } + + private removeSessionFromPull(session: Session) { + this.sessions = this.sessions.filter((ses) => ses.id !== session.id) + } + + private removeExpiredSessions() { + this.sessions = this.sessions.filter((session) => + this.isSessionValid(session) ) + } + + private throwErrors(errors: (Error | string)[], prefix?: string) { + throw prefix + ? prefixMessage(new Error(errors.join('. ')), prefix) + : new Error( + errors + .map((err) => + (err as Error).message ? (err as Error).message : err + ) + .join('. ') + ) + } + + async getSession(accessToken?: string) { + const errors: (Error | string)[] = [] + let isErrorThrown = false + + const throwIfError = () => { + if (errors.length && !isErrorThrown) { + isErrorThrown = true + + this.throwErrors(errors) + } + } + + this.removeExpiredSessions() if (this.sessions.length) { const session = this.sessions[0] - this.createSessions(accessToken) - this.createAndWaitForSession(accessToken) + this.removeSessionFromPull(session) - // TODO: check secondsSinceSessionCreation + this.createSessions(accessToken).catch((err) => { + errors.push(err) + }) + + this.createAndWaitForSession(accessToken).catch((err) => { + errors.push(err) + }) + + throwIfError() return session } else { - await this.createSessions(accessToken) - console.log( - `🤖[ 45 this.sessions]🤖`, - this.sessions.map((session: any) => session.id) - ) - await this.createAndWaitForSession(accessToken) - console.log( - `🤖[ 50 this.sessions]🤖`, - this.sessions.map((session: any) => session.id) - ) + this.createSessions(accessToken).catch((err) => { + errors.push(err) + }) - const session = this.sessions.pop() - console.log(`🤖[session]🤖`, session!.id) + await this.createAndWaitForSession(accessToken).catch((err) => { + errors.push(err) + }) - console.log( - `🤖[59 this.sessions]🤖`, - this.sessions.map((session: any) => session.id) - ) + this.removeExpiredSessions() - const secondsSinceSessionCreation = - (new Date().getTime() - - new Date(session!.creationTimeStamp).getTime()) / - 1000 - console.log( - `🤖[secondsSinceSessionCreation]🤖`, - secondsSinceSessionCreation - ) + const session = this.sessions.pop()! + + this.removeSessionFromPull(session) + + throwIfError() - if ( - !session!.attributes || - secondsSinceSessionCreation >= - session!.attributes.sessionInactiveTimeout - ) { - console.log(`🤖[54]🤖`, 54) - await this.createSessions(accessToken) - const freshSession = this.sessions.pop() - console.log(`🤖[freshSession]🤖`, freshSession!.id) - return freshSession - } - console.log(`🤖[60]🤖`, 60) - console.log(`🤖[---- SessionManager getSession end]🤖`) - console.log(`🤖[]🤖`) return session } } - async clearSession(id: string, accessToken?: string) { - console.log( - `🤖[clearSession this.sessions]🤖`, - this.sessions.map((session: any) => session.id) + private getErrorMessage( + err: any, + url: string, + method: 'GET' | 'POST' | 'DELETE' + ) { + return ( + `${method} request to ${url} failed with status code ${ + err?.response?.status || 'unknown' + }. ` + err?.response?.data?.message || '' ) - console.log(`🤖[SessionManager clearSession id]🤖`, id) + } + + async clearSession(id: string, accessToken?: string) { + const url = `/compute/sessions/${id}` return await this.requestClient - .delete(`/compute/sessions/${id}`, accessToken) + .delete(url, accessToken) .then(() => { this.sessions = this.sessions.filter((s) => s.id !== id) }) .catch((err) => { - throw prefixMessage(err, 'Error while deleting session. ') + throw prefixMessage( + this.getErrorMessage(err, url, 'DELETE'), + 'Error while deleting session. ' + ) }) } private async createSessions(accessToken?: string) { - console.log(`🤖[SessionManager createSessions]🤖`) + const errors: (Error | string)[] = [] if (!this.sessions.length) { - if (!this.currentContext) { - await this.setCurrentContext(accessToken).catch((err) => { - throw err - }) - } - - console.log( - `🤖[createSessions start this.sessions]🤖`, - this.sessions.map((session: any) => session.id) - ) - await asyncForEach(new Array(MAX_SESSION_COUNT), async () => { - const createdSession = await this.createAndWaitForSession( - accessToken - ).catch((err) => { - throw err + await this.createAndWaitForSession(accessToken).catch((err) => { + errors.push(err) }) - - // console.log(`🤖[createSessions new session id]🤖`, createdSession.id) - - // this.sessions.push(createdSession) - }).catch((err) => { - throw err }) + } - console.log( - `🤖[createSessions end this.sessions]🤖`, - this.sessions.map((session: any) => session.id) - ) + if (errors.length) { + this.throwErrors(errors, 'Error while creating session. ') } } + private async waitForCurrentContext(): Promise { + return new Promise((resolve) => { + const timer = setInterval(() => { + if (this.currentContext) { + this.settingContext = false + + clearInterval(timer) + + resolve() + } + }, 100) + }) + } + private async createAndWaitForSession(accessToken?: string) { - console.log(`🤖[SessionManager createAndWaitForSession]🤖`) + if (!this.currentContext) { + if (!this.settingContext) { + await this.setCurrentContext(accessToken) + } else { + await this.waitForCurrentContext() + } + } + + const url = `${this.serverUrl}/compute/contexts/${ + this.currentContext!.id + }/sessions` const { result: createdSession, etag } = await this.requestClient - .post( - `${this.serverUrl}/compute/contexts/${ - this.currentContext!.id - }/sessions`, - {}, - accessToken - ) + .post(url, {}, accessToken) .catch((err) => { - throw err + throw prefixMessage( + err, + `Error while creating session. ${this.getErrorMessage( + err, + url, + 'POST' + )}` + ) }) await this.waitForSession(createdSession, etag, accessToken) - console.log( - `🤖[createAndWaitForSession this.sessions.map((session: any) => session.id)]🤖`, - this.sessions.map((session: any) => session.id) - ) - console.log( - `🤖[createAndWaitForSession adding createdSession.id]🤖`, - createdSession.id - ) - this.sessions.push(createdSession) return createdSession } private async setCurrentContext(accessToken?: string) { - console.log(`🤖[SessionManager setCurrentContext]🤖`) - if (!this.currentContext) { + const url = `${this.serverUrl}/compute/contexts?limit=10000` + + this.settingContext = true + const { result: contexts } = await this.requestClient .get<{ items: Context[] - }>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken) + }>(url, accessToken) .catch((err) => { - throw err + throw prefixMessage( + err, + `Error while getting list of contexts. ${this.getErrorMessage( + err, + url, + 'GET' + )}` + ) }) const contextsList = @@ -215,26 +257,11 @@ export class SessionManager { } } - // DEPRECATE - private getHeaders(accessToken?: string) { - const headers: any = { - 'Content-Type': 'application/json' - } - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - return headers - } - private async waitForSession( session: Session, etag: string | null, accessToken?: string ): Promise { - console.log(`🤖[SessionManager waitForSession]🤖`) - const logger = process.logger || console let sessionState = session.state @@ -253,13 +280,14 @@ export class SessionManager { this.printedSessionState.printed = true } + const url = `${this.serverUrl}${stateLink.href}?wait=30` + const { result: state, responseStatus: responseStatus } = - await this.getSessionState( - `${this.serverUrl}${stateLink.href}?wait=30`, - etag!, - accessToken - ).catch((err) => { - throw prefixMessage(err, 'Error while getting session state.') + await this.getSessionState(url, etag!, accessToken).catch((err) => { + throw prefixMessage( + this.getErrorMessage(err, url, 'GET'), + 'Error while getting session state. ' + ) }) sessionState = state.trim() @@ -296,7 +324,7 @@ export class SessionManager { return sessionState } else { - throw 'Error while getting session state link.' + throw 'Error while getting session state link. ' } } else { this.loggedErrors = [] @@ -310,8 +338,6 @@ export class SessionManager { etag: string, accessToken?: string ) { - console.log(`🤖[SessionManager getSessionState]🤖`) - return await this.requestClient .get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) .then((res) => ({ @@ -319,13 +345,14 @@ export class SessionManager { responseStatus: res.status })) .catch((err) => { - throw err + throw prefixMessage( + this.getErrorMessage(err, url, 'GET'), + 'Error while getting session state. ' + ) }) } async getVariable(sessionId: string, variable: string, accessToken?: string) { - console.log(`🤖[SessionManager getVariable]🤖`) - return await this.requestClient .get( `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`, diff --git a/src/test/SessionManager.spec.ts b/src/test/SessionManager.spec.ts index f40205a..18f2c35 100644 --- a/src/test/SessionManager.spec.ts +++ b/src/test/SessionManager.spec.ts @@ -3,10 +3,12 @@ import { RequestClient } from '../request/RequestClient' import * as dotenv from 'dotenv' import axios from 'axios' import { Logger, LogLevel } from '@sasjs/utils' -import { Session } from '../types' +import { prefixMessage } from '@sasjs/utils/error' +import { Session, Context } from '../types' jest.mock('axios') const mockedAxios = axios as jest.Mocked +const requestClient = new (>RequestClient)() describe('SessionManager', () => { dotenv.config() @@ -14,9 +16,23 @@ describe('SessionManager', () => { const sessionManager = new SessionManager( process.env.SERVER_URL as string, process.env.DEFAULT_COMPUTE_CONTEXT as string, - new RequestClient('https://sample.server.com') + requestClient ) + const getMockSession = () => ({ + id: ['id', new Date().getTime(), Math.random()].join('-'), + state: '', + links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], + attributes: { + sessionInactiveTimeout: 900 + }, + creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}` + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + describe('getVariable', () => { it('should fetch session variable', async () => { const sampleResponse = { @@ -45,6 +61,30 @@ describe('SessionManager', () => { ) ).resolves.toEqual(expectedResponse) }) + + it('should throw an error if GET request failed', async () => { + const responseStatus = 500 + const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}` + const response = { + status: responseStatus, + data: { + message: responseErrorMessage + } + } + const testVariable = 'testVariable' + + jest.spyOn(requestClient, 'get').mockImplementation(() => + Promise.reject({ + response + }) + ) + + const expectedError = `Error while fetching session variable '${testVariable}'.` + + await expect( + sessionManager.getVariable('testId', testVariable) + ).rejects.toEqual(prefixMessage({ response } as any, expectedError)) + }) }) describe('waitForSession', () => { @@ -135,4 +175,243 @@ describe('SessionManager', () => { ).resolves.toEqual(customSession.state) }) }) + + describe('isSessionValid', () => { + const session: Session = getMockSession() + + it('should return false if not a session provided', () => { + expect(sessionManager['isSessionValid'](undefined as any)).toEqual(false) + }) + + it('should return true if session is not expired', () => { + expect(sessionManager['isSessionValid'](session)).toEqual(true) + }) + + it('should return false if session is expired', () => { + session.creationTimeStamp = `${new Date( + new Date().getTime() - + (session.attributes.sessionInactiveTimeout * 1000 + 1000) + ).toISOString()}` + expect(sessionManager['isSessionValid'](session)).toEqual(false) + }) + }) + + describe('removeSessionFromPull', () => { + it('should remove session from the pull of sessions', () => { + const session: Session = getMockSession() + const sessions: Session[] = [getMockSession(), session] + + sessionManager['sessions'] = sessions + sessionManager['removeSessionFromPull'](session) + + expect(sessionManager['sessions'].length).toEqual(1) + }) + }) + + describe('getSession', () => { + it('should return session if there is a valid session and create new session', async () => { + jest + .spyOn(sessionManager as any, 'createAndWaitForSession') + .mockImplementation(async () => Promise.resolve(getMockSession())) + + const session = getMockSession() + sessionManager['sessions'] = [session] + + await expect(sessionManager.getSession()).resolves.toEqual(session) + expect(sessionManager['createAndWaitForSession']).toHaveBeenCalled() + }) + + it('should return a session and keep one session if there is no sessions available', async () => { + jest + .spyOn(sessionManager as any, 'createAndWaitForSession') + .mockImplementation(async () => { + const session = getMockSession() + sessionManager['sessions'].push(session) + + return Promise.resolve(session) + }) + + const session = await sessionManager.getSession() + + expect(Object.keys(session)).toEqual(Object.keys(getMockSession())) + expect(sessionManager['createAndWaitForSession']).toHaveBeenCalledTimes(2) + expect(sessionManager['sessions'].length).toEqual(1) + }) + + it.concurrent( + 'should throw an error if session creation request returned 500', + async () => { + const sessionCreationStatus = 500 + const sessionCreationError = `The process initialization for the Compute server with the ID 'ed40398a-ec8a-422b-867a-61493ee8a57f' timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}` + + jest.spyOn(requestClient, 'post').mockImplementation(() => + Promise.reject({ + response: { + status: sessionCreationStatus, + data: { + message: sessionCreationError + } + } + }) + ) + + const contextId = 'testContextId' + const context: Context = { + name: 'testContext', + id: contextId, + createdBy: 'createdBy', + version: 1 + } + + sessionManager['currentContext'] = context + + const expectedError = new Error( + `Error while creating session. POST request to ${process.env.SERVER_URL}/compute/contexts/${contextId}/sessions failed with status code ${sessionCreationStatus}. ${sessionCreationError}` + ) + + await expect(sessionManager.getSession()).rejects.toEqual(expectedError) + } + ) + }) + + describe('clearSession', () => { + it('should clear session', async () => { + jest + .spyOn(requestClient, 'delete') + .mockImplementation(() => + Promise.resolve({ result: '', etag: '', status: 200 }) + ) + + const sessionToBeCleared = getMockSession() + const sessionToStay = getMockSession() + + sessionManager['sessions'] = [sessionToBeCleared, sessionToStay] + + await sessionManager.clearSession(sessionToBeCleared.id) + + expect(sessionManager['sessions']).toEqual([sessionToStay]) + }) + + it('should throw error if DELETE request failed', async () => { + const sessionCreationStatus = 500 + const sessionDeleteError = `The process timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}` + + jest.spyOn(requestClient, 'delete').mockImplementation(() => + Promise.reject({ + response: { + status: sessionCreationStatus, + data: { + message: sessionDeleteError + } + } + }) + ) + + const session = getMockSession() + + sessionManager['sessions'] = [session] + + const expectedError = `Error while deleting session. DELETE request to /compute/sessions/${session.id} failed with status code ${sessionCreationStatus}. ${sessionDeleteError}` + + await expect(sessionManager.clearSession(session.id)).rejects.toEqual( + expectedError + ) + }) + }) + + describe('waitForCurrentContext', () => { + it('should resolve when current context is ready', async () => { + sessionManager['settingContext'] = true + sessionManager['contextName'] = 'test context' + + await expect(sessionManager['waitForCurrentContext']()).toResolve() + expect(sessionManager['settingContext']).toEqual(false) + }) + }) + + describe('setCurrentContext', () => { + it('should set current context', async () => { + const contextName = 'test context' + const testContext: Context = { + name: contextName, + id: 'string', + createdBy: 'string', + version: 1 + } + + jest.spyOn(requestClient, 'get').mockImplementation(() => { + return Promise.resolve({ + result: { + items: [testContext] + }, + etag: '', + status: 200 + }) + }) + + sessionManager['currentContext'] = null + sessionManager['contextName'] = contextName + sessionManager['settingContext'] = false + + await expect(sessionManager['setCurrentContext']()).toResolve() + expect(sessionManager['currentContext']).toEqual(testContext) + }) + + it('should throw error if GET request failed', async () => { + const responseStatus = 500 + const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}` + const response = { + status: responseStatus, + data: { + message: responseErrorMessage + } + } + + jest.spyOn(requestClient, 'get').mockImplementation(() => + Promise.reject({ + response + }) + ) + + const expectedError = `Error while getting list of contexts. GET request to https://4gl.io/compute/contexts?limit=10000 failed with status code ${responseStatus}. ${responseErrorMessage}` + + sessionManager['currentContext'] = null + + await expect(sessionManager['setCurrentContext']()).rejects.toEqual( + prefixMessage({ response } as any, expectedError) + ) + }) + + it('should throw an error if current context is not in the list of contexts', async () => { + const contextName = 'test context' + const testContext: Context = { + name: `${contextName} does not exist`, + id: 'string', + createdBy: 'string', + version: 1 + } + + jest.spyOn(requestClient, 'get').mockImplementation(() => { + return Promise.resolve({ + result: { + items: [testContext] + }, + etag: '', + status: 200 + }) + }) + + sessionManager['currentContext'] = null + sessionManager['contextName'] = contextName + sessionManager['settingContext'] = false + + const expectedError = new Error( + `The context '${contextName}' was not found on the server https://4gl.io.` + ) + + await expect(sessionManager['setCurrentContext']()).rejects.toEqual( + expectedError + ) + }) + }) })