diff --git a/src/sessionManager.ts b/src/sessionManager.ts index fafbde63..9b5fc59f 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -33,31 +33,24 @@ export default function SessionManager( this.initialize = function (): void { if (mpInstance._Store.sessionId) { - const sessionTimeoutInMilliseconds: number = - mpInstance._Store.SDKConfig.sessionTimeout * 60000; - - if ( - new Date() > - new Date( - mpInstance._Store.dateLastEventSent.getTime() + - sessionTimeoutInMilliseconds - ) - ) { + const { dateLastEventSent, SDKConfig } = mpInstance._Store; + const { sessionTimeout } = SDKConfig; + + if (hasSessionTimedOut(dateLastEventSent?.getTime(), sessionTimeout)) { self.endSession(); self.startNewSession(); } else { // https://go.mparticle.com/work/SQDSDKS-6045 // https://go.mparticle.com/work/SQDSDKS-6323 const currentUser = mpInstance.Identity.getCurrentUser(); - const sdkIdentityRequest = - mpInstance._Store.SDKConfig.identifyRequest; + const sdkIdentityRequest = SDKConfig.identifyRequest; if ( hasIdentityRequestChanged(currentUser, sdkIdentityRequest) ) { mpInstance.Identity.identify( - mpInstance._Store.SDKConfig.identifyRequest, - mpInstance._Store.SDKConfig.identityCallback + sdkIdentityRequest, + SDKConfig.identityCallback ); mpInstance._Store.identifyCalled = true; mpInstance._Store.SDKConfig.identityCallback = null; @@ -134,12 +127,7 @@ export default function SessionManager( ); if (override) { - mpInstance._Events.logEvent({ - messageType: Types.MessageType.SessionEnd, - }); - - mpInstance._Store.nullifySession(); - mpInstance._timeOnSiteTimer?.resetTimer(); + performSessionEnd(); return; } @@ -152,13 +140,9 @@ export default function SessionManager( Messages.InformationMessages.AbandonEndSession ); mpInstance._timeOnSiteTimer?.resetTimer(); - return; } - let sessionTimeoutInMilliseconds: number; - let timeSinceLastEventSent: number; - const cookies: IPersistenceMinified = mpInstance._Persistence.getPersistence(); @@ -167,7 +151,6 @@ export default function SessionManager( Messages.InformationMessages.NoSessionToEnd ); mpInstance._timeOnSiteTimer?.resetTimer(); - return; } @@ -177,24 +160,15 @@ export default function SessionManager( } if (cookies?.gs?.les) { - sessionTimeoutInMilliseconds = - mpInstance._Store.SDKConfig.sessionTimeout * 60000; - const newDate: number = new Date().getTime(); - timeSinceLastEventSent = newDate - cookies.gs.les; - - if (timeSinceLastEventSent < sessionTimeoutInMilliseconds) { - self.setSessionTimer(); + const sessionTimeout = mpInstance._Store.SDKConfig.sessionTimeout; + + if (hasSessionTimedOut(cookies.gs.les, sessionTimeout)) { + performSessionEnd(); } else { - mpInstance._Events.logEvent({ - messageType: Types.MessageType.SessionEnd, - }); - - mpInstance._Store.sessionStartDate = null; - mpInstance._Store.nullifySession(); + self.setSessionTimer(); + mpInstance._timeOnSiteTimer?.resetTimer(); } } - - mpInstance._timeOnSiteTimer?.resetTimer(); }; this.setSessionTimer = function (): void { @@ -234,4 +208,35 @@ export default function SessionManager( } } }; + + /** + * Checks if the session has expired based on the last event timestamp + * @param lastEventTimestamp - Unix timestamp in milliseconds of the last event + * @param sessionTimeout - Session timeout in minutes + * @returns true if the session has expired, false otherwise + */ + function hasSessionTimedOut(lastEventTimestamp: number, sessionTimeout: number): boolean { + if (!lastEventTimestamp || !sessionTimeout || sessionTimeout <= 0) { + return false; + } + + const sessionTimeoutInMilliseconds: number = sessionTimeout * 60000; + const timeSinceLastEvent: number = Date.now() - lastEventTimestamp; + + return timeSinceLastEvent >= sessionTimeoutInMilliseconds; + } + + /** + * Performs session end operations: + * - Logs a SessionEnd event + * - Nullifies the session ID and related data + * - Resets the time-on-site timer + */ + function performSessionEnd(): void { + mpInstance._Events.logEvent({ + messageType: Types.MessageType.SessionEnd, + }); + mpInstance._Store.nullifySession(); + mpInstance._timeOnSiteTimer?.resetTimer(); + } } diff --git a/src/store.ts b/src/store.ts index 64d560dc..329f8e24 100644 --- a/src/store.ts +++ b/src/store.ts @@ -683,6 +683,7 @@ export default function Store( this.nullifySession = (): void => { this.sessionId = null; this.dateLastEventSent = null; + this.sessionStartDate = null; this.sessionAttributes = {}; this.localSessionAttributes = {}; mpInstance._Persistence.update(); diff --git a/test/src/tests-session-manager.ts b/test/src/tests-session-manager.ts index 340cb6fc..6bb3f25d 100644 --- a/test/src/tests-session-manager.ts +++ b/test/src/tests-session-manager.ts @@ -293,6 +293,198 @@ describe('SessionManager', () => { }); }); + describe('#hasSessionTimedOut', () => { + beforeEach(() => { + clock = sinon.useFakeTimers(now.getTime()); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should end the session when elapsed time exceeds session timeout', () => { + const timePassed = 35 * (MILLIS_IN_ONE_SEC * 60); // 35 minutes + + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + mpInstance._Store.sessionId = 'OLD-ID'; + const timeLastEventSent = mpInstance._Store.dateLastEventSent.getTime(); + mpInstance._Store.dateLastEventSent = new Date(timeLastEventSent - timePassed); + + // initialize() uses hasSessionTimedOut internally + mpInstance._SessionManager.initialize(); + + // Should have created a new session because timeout was exceeded + expect(mpInstance._Store.sessionId).to.not.equal('OLD-ID'); + }); + + it('should preserve the session when elapsed time is within session timeout', () => { + const timePassed = 15 * (MILLIS_IN_ONE_SEC * 60); // 15 minutes + + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + mpInstance._Store.sessionId = 'OLD-ID'; + const timeLastEventSent = mpInstance._Store.dateLastEventSent.getTime(); + mpInstance._Store.dateLastEventSent = new Date(timeLastEventSent - timePassed); + + // initialize() uses hasSessionTimedOut internally + mpInstance._SessionManager.initialize(); + + // Should have kept the old session because timeout was not exceeded + expect(mpInstance._Store.sessionId).to.equal('OLD-ID'); + }); + + it('should work consistently with both in-memory and persisted timestamps', () => { + const thirtyOneMinutesAgo = new Date(now.getTime() - (31 * MILLIS_IN_ONE_SEC * 60)); + + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + // Test with in-memory store (via initialize) + mpInstance._Store.sessionId = 'TEST-ID'; + mpInstance._Store.dateLastEventSent = thirtyOneMinutesAgo; + mpInstance._SessionManager.initialize(); + + // Session should have expired (default timeout is 30 minutes) + expect(mpInstance._Store.sessionId).to.not.equal('TEST-ID'); + + // Test with persistence (via endSession) + const newSessionId = mpInstance._Store.sessionId; + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + gs: { + les: thirtyOneMinutesAgo.getTime(), + sid: newSessionId, + }, + }); + + mpInstance._SessionManager.endSession(); + + // Session should have ended (same timeout logic) + expect(mpInstance._Store.sessionId).to.equal(null); + + // Clean up stub + getPersistenceStub.restore(); + }); + + it('should end the session when elapsed time equals session timeout exactly', () => { + const exactlyThirtyMinutesAgo = new Date(now.getTime() - (30 * MILLIS_IN_ONE_SEC * 60)); + + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + gs: { + les: exactlyThirtyMinutesAgo.getTime(), + sid: 'TEST-ID', + }, + }); + + mpInstance._SessionManager.endSession(); + + // At exactly 30 minutes, session should be expired + expect(mpInstance._Store.sessionId).to.equal(null); + + // Clean up stub + getPersistenceStub.restore(); + }); + }); + + describe('#performSessionEnd', () => { + it('should log a SessionEnd event', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + const eventSpy = sinon.spy(mpInstance._Events, 'logEvent'); + + mpInstance._SessionManager.endSession(true); + + // Find the SessionEnd event call + const sessionEndCall = eventSpy.getCalls().find(call => + call.args[0]?.messageType === MessageType.SessionEnd + ); + + expect(sessionEndCall).to.not.be.undefined; + expect(sessionEndCall.args[0]).to.eql({ + messageType: MessageType.SessionEnd, + }); + }); + + it('should clear sessionStartDate', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + const sessionStartDate = mpInstance._Store.sessionStartDate; + expect(sessionStartDate).to.not.be.null; + + mpInstance._SessionManager.endSession(true); + + expect(mpInstance._Store.sessionStartDate).to.equal(null); + }); + + it('should nullify session ID and session attributes', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + // Set up session data + mpInstance._Store.sessionAttributes = { testAttr: 'value' }; + mpInstance._Store.localSessionAttributes = { localAttr: 'value' }; + + expect(mpInstance._Store.sessionId).to.not.be.null; + + mpInstance._SessionManager.endSession(true); + + expect(mpInstance._Store.sessionId).to.equal(null); + expect(mpInstance._Store.sessionAttributes).to.eql({}); + expect(mpInstance._Store.localSessionAttributes).to.eql({}); + }); + + it('should reset timeOnSiteTimer if it exists', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + // Timer should exist since workspaceToken is present in config + expect(mpInstance._timeOnSiteTimer).to.exist; + + const resetTimerSpy = sinon.spy(mpInstance._timeOnSiteTimer, 'resetTimer'); + + mpInstance._SessionManager.endSession(true); + + expect(resetTimerSpy.called).to.equal(true); + }); + + it('should handle missing timeOnSiteTimer gracefully', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + // Explicitly remove the timer to test the optional chaining behavior + mpInstance._timeOnSiteTimer = undefined; + + expect(() => { + mpInstance._SessionManager.endSession(true); + }).to.not.throw(); + + // Session should still end properly + expect(mpInstance._Store.sessionId).to.equal(null); + }); + + it('should perform all session end operations', () => { + mParticle.init(apiKey, window.mParticle.config); + const mpInstance = mParticle.getInstance(); + + const eventSpy = sinon.spy(mpInstance._Events, 'logEvent'); + const persistenceSpy = sinon.spy(mpInstance._Persistence, 'update'); + + mpInstance._SessionManager.endSession(true); + + // Verify all operations happened + expect(eventSpy.called).to.equal(true); + expect(mpInstance._Store.sessionStartDate).to.equal(null); + expect(mpInstance._Store.sessionId).to.equal(null); + expect(persistenceSpy.called).to.equal(true); + }); + }); + describe('#endSession', () => { beforeEach(() => { clock = sinon.useFakeTimers(now.getTime()); @@ -340,7 +532,7 @@ describe('SessionManager', () => { // Session Manager relies on persistence to determine last event sent (LES) time // Also requires sid to verify session exists - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { les: twentyMinutesAgo, sid: 'fake-session-id', @@ -357,6 +549,9 @@ describe('SessionManager', () => { // When session is not timed out, setSessionTimer is called to keep track // of current session timeout expect(timerSpy.getCalls().length).to.equal(1); + + // Clean up stub + getPersistenceStub.restore(); }); it('should force a session end when override is used', () => { @@ -512,7 +707,7 @@ describe('SessionManager', () => { mParticle.init(apiKey, window.mParticle.config); const mpInstance = mParticle.getInstance(); - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { sid: 'cookie-session-id', }, @@ -525,6 +720,9 @@ describe('SessionManager', () => { expect(mpInstance._Store.sessionId).to.equal( 'cookie-session-id' ); + + // Clean up stub + getPersistenceStub.restore(); }); it('should end session if the session timeout limit has been reached', () => { @@ -744,7 +942,7 @@ describe('SessionManager', () => { const mpInstance = mParticle.getInstance(); // Session Manager relies on persistence check sid (Session ID) - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { sid: null, }, @@ -759,6 +957,9 @@ describe('SessionManager', () => { mpInstance._SessionManager.startNewSessionIfNeeded(); expect(startNewSessionSpy.called).to.equal(true); + + // Clean up stub + getPersistenceStub.restore(); }); it('should NOT call startNewSession if sessionId is undefined and Persistence is undefined', () => { @@ -788,7 +989,7 @@ describe('SessionManager', () => { const mpInstance = mParticle.getInstance(); // Session Manager relies on persistence check sid (Session ID) - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { sid: 'sid-from-persistence', }, @@ -799,6 +1000,9 @@ describe('SessionManager', () => { mpInstance._SessionManager.startNewSessionIfNeeded(); mpInstance._Store.sessionId = 'sid-from-persistence'; + + // Clean up stub + getPersistenceStub.restore(); }); it('should set sessionId from Persistence if Store.sessionId is undefined', () => { @@ -806,7 +1010,7 @@ describe('SessionManager', () => { const mpInstance = mParticle.getInstance(); // Session Manager relies on persistence check sid (Session ID) - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { sid: 'sid-from-persistence', }, @@ -824,6 +1028,9 @@ describe('SessionManager', () => { mpInstance._Store.sessionId = 'sid-from-persistence'; expect(startNewSessionSpy.called).to.equal(true); + + // Clean up stub + getPersistenceStub.restore(); }); it('should NOT call startNewSession if Store.sessionId and Persistence are null', () => { @@ -893,7 +1100,7 @@ describe('SessionManager', () => { // Session Manager relies on persistence to determine last time seen (LES) // Also requires sid to verify session exists - sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ + const getPersistenceStub = sinon.stub(mpInstance._Persistence, 'getPersistence').returns({ gs: { les: now, sid: 'fake-session-id', @@ -911,6 +1118,9 @@ describe('SessionManager', () => { // Persistence isn't necessary for this feature, but we should test // to see that it is called in case this ever needs to be refactored expect(persistenceSpy.called).to.equal(true); + + // Clean up stub + getPersistenceStub.restore(); }); it('should call identify when SDKConfig.identifyRequest differs from getCurrentUser().userIdentities on page refresh', async () => {