Commit 1c3aa03b authored by Robert Knight's avatar Robert Knight

Refactor `SessionService` to a more idiomatic ES class

 - Convert closures into methods

 - Remove the `profileFetchRetryOpts` field that was used only to shorten the
   delay between retries in tests, in favor of mocking
   `retryPromiseOperation` in tests to remove the delay between retries.

 - Change `SessionService` instance creation in tests to allow
   individual tests to run custom setup logic before the service is
   constructed. This was needed due to allow the `serviceConfig` mock to
   take effect when the `SessionService` constructor runs

 - Remove unnecessary custom Sinon sandbox in tests
parent 2376f0f8
...@@ -46,7 +46,7 @@ function setupApi(api, streamer) { ...@@ -46,7 +46,7 @@ function setupApi(api, streamer) {
* route to match the current URL. * route to match the current URL.
* *
* @param {Object} groups * @param {Object} groups
* @param {Object} session * @param {import('./services/session').SessionService} session
* @param {import('./services/router').RouterService} router * @param {import('./services/router').RouterService} router
*/ */
// @inject // @inject
......
import serviceConfig from '../config/service-config'; import serviceConfig from '../config/service-config';
import * as retryUtil from '../util/retry'; import { retryPromiseOperation } from '../util/retry';
import * as sentry from '../util/sentry'; import * as sentry from '../util/sentry';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
...@@ -20,144 +20,127 @@ export class SessionService { ...@@ -20,144 +20,127 @@ export class SessionService {
* @param {import('./toast-messenger').ToastMessengerService} toastMessenger * @param {import('./toast-messenger').ToastMessengerService} toastMessenger
*/ */
constructor(store, api, auth, settings, toastMessenger) { constructor(store, api, auth, settings, toastMessenger) {
// Cache the result of load() this._api = api;
let lastLoad; this._auth = auth;
let lastLoadTime; this._store = store;
this._toastMessenger = toastMessenger;
// Return the authority from the first service defined in the settings.
// Return null if there are no services defined in the settings.
function getAuthority() {
const service = serviceConfig(settings);
if (service === null) {
return null;
}
return service.authority;
}
// Options to pass to `retry.operation` when fetching the user's profile. this._authority = serviceConfig(settings)?.authority ?? null;
const profileFetchRetryOpts = {};
/**
* Fetch the user's profile from the annotation service.
*
* If the profile has been previously fetched within `CACHE_TTL` ms, then this
* method returns a cached profile instead of triggering another fetch.
*
* @return {Promise<Profile>} A promise for the user's profile data.
*/
function load() {
if (!lastLoadTime || Date.now() - lastLoadTime > CACHE_TTL) {
// The load attempt is automatically retried with a backoff.
//
// This serves to make loading the app in the extension cope better with
// flakey connectivity but it also throttles the frequency of calls to
// the /app endpoint.
lastLoadTime = Date.now();
lastLoad = retryUtil
.retryPromiseOperation(function () {
const authority = getAuthority();
const opts = {};
if (authority) {
opts.authority = authority;
}
return api.profile.read(opts);
}, profileFetchRetryOpts)
.then(function (session) {
update(session);
lastLoadTime = Date.now();
return session;
})
.catch(function (err) {
lastLoadTime = null;
throw err;
});
}
return lastLoad;
}
/** /** @type {Promise<Profile>|null} */
* Store the preference server-side that the user dismissed the sidebar this._lastLoad = null;
* tutorial and then update the local profile data.
*/
function dismissSidebarTutorial() {
return api.profile
.update({}, { preferences: { show_sidebar_tutorial: false } })
.then(update);
}
/** /** @type {number|null} */
* Update the local profile data. this._lastLoadTime = null;
*
* This method can be used to update the profile data in the client when new
* data is pushed from the server via the real-time API.
*
* @param {Profile} model
* @return {Profile} The updated profile data
*/
function update(model) {
const prevSession = store.profile();
const userChanged = model.userid !== prevSession.userid;
store.updateProfile(model);
lastLoad = Promise.resolve(model);
lastLoadTime = Date.now();
if (userChanged) {
// Associate error reports with the current user in Sentry.
if (model.userid) {
sentry.setUserInfo({
id: model.userid,
});
} else {
sentry.setUserInfo(null);
}
}
// Return the model // Re-fetch profile when user logs in or out in another tab.
return model; auth.on('oauthTokensChanged', () => this.reload());
} }
/** /**
* Log the user out of the current session. * Fetch the user's profile from the annotation service.
*/ *
function logout() { * If the profile has been previously fetched within `CACHE_TTL` ms, then this
const loggedOut = auth.logout().then(() => { * method returns a cached profile instead of triggering another fetch.
// Re-fetch the logged-out user's profile. *
return reload(); * @return {Promise<Profile>} A promise for the user's profile data.
}); */
load() {
return loggedOut.catch(err => { if (
toastMessenger.error('Log out failed'); !this._lastLoad ||
throw new Error(err); !this._lastLoadTime ||
}); Date.now() - this._lastLoadTime > CACHE_TTL
) {
// The load attempt is automatically retried with a backoff.
//
// This serves to make loading the app in the extension cope better with
// flakey connectivity but it also throttles the frequency of calls to
// the /app endpoint.
this._lastLoadTime = Date.now();
this._lastLoad = retryPromiseOperation(() => {
const opts = this._authority ? { authority: this._authority } : {};
return this._api.profile.read(opts);
})
.then(session => {
this.update(session);
this._lastLoadTime = Date.now();
return session;
})
.catch(err => {
this._lastLoadTime = null;
throw err;
});
} }
return this._lastLoad;
}
/** /**
* Clear the cached profile information and re-fetch it from the server. * Store the preference server-side that the user dismissed the sidebar
* * tutorial and then update the local profile data.
* This can be used to refresh the user's profile state after logging in. */
* async dismissSidebarTutorial() {
* @return {Promise<Profile>} const updatedProfile = await this._api.profile.update(
*/ {},
function reload() { { preferences: { show_sidebar_tutorial: false } }
lastLoad = null; );
lastLoadTime = null; this.update(updatedProfile);
return load(); }
}
auth.on('oauthTokensChanged', () => { /**
reload(); * Update the local profile data.
}); *
* This method can be used to update the profile data in the client when new
* data is pushed from the server via the real-time API.
*
* @param {Profile} profile
* @return {Profile} The updated profile data
*/
update(profile) {
const prevProfile = this._store.profile();
const userChanged = profile.userid !== prevProfile.userid;
this._store.updateProfile(profile);
this._lastLoad = Promise.resolve(profile);
this._lastLoadTime = Date.now();
if (userChanged) {
// Associate error reports with the current user in Sentry.
if (profile.userid) {
sentry.setUserInfo({
id: profile.userid,
});
} else {
sentry.setUserInfo(null);
}
}
this.dismissSidebarTutorial = dismissSidebarTutorial; return profile;
this.load = load; }
this.logout = logout;
this.reload = reload;
// Exposed for use in tests /**
this.profileFetchRetryOpts = profileFetchRetryOpts; * Log the user out of the current session and re-fetch the profile.
*/
async logout() {
try {
await this._auth.logout();
return this.reload();
} catch (err) {
this._toastMessenger.error('Log out failed');
throw new Error(err);
}
}
this.update = update; /**
* Clear the cached profile information and re-fetch it from the server.
*
* This can be used to refresh the user's profile state after logging in.
*
* @return {Promise<Profile>}
*/
reload() {
this._lastLoad = null;
this._lastLoadTime = null;
return this.load();
} }
} }
import EventEmitter from 'tiny-emitter'; import EventEmitter from 'tiny-emitter';
import { SessionService, $imports } from '../session'; import { SessionService, $imports } from '../session';
import { Injector } from '../../../shared/injector';
describe('SessionService', function () { describe('SessionService', () => {
let fakeApi;
let fakeAuth; let fakeAuth;
let fakeSentry; let fakeSentry;
let fakeServiceConfig; let fakeServiceConfig;
let fakeSettings; let fakeSettings;
let fakeStore; let fakeStore;
let fakeToastMessenger; let fakeToastMessenger;
let fakeApi;
let sandbox;
// The instance of the `session` service.
let session;
beforeEach(function () {
sandbox = sinon.createSandbox();
beforeEach(() => {
let currentProfile = { let currentProfile = {
userid: null, userid: null,
}; };
...@@ -30,47 +23,59 @@ describe('SessionService', function () { ...@@ -30,47 +23,59 @@ describe('SessionService', function () {
}), }),
}; };
fakeAuth = Object.assign(new EventEmitter(), { fakeAuth = Object.assign(new EventEmitter(), {
login: sandbox.stub().returns(Promise.resolve()), login: sinon.stub().returns(Promise.resolve()),
logout: sinon.stub().resolves(), logout: sinon.stub().resolves(),
}); });
fakeSentry = { fakeSentry = {
setUserInfo: sandbox.spy(), setUserInfo: sinon.spy(),
}; };
fakeApi = { fakeApi = {
profile: { profile: {
read: sandbox.stub().resolves(), read: sinon.stub().resolves(),
update: sandbox.stub().resolves({}), update: sinon.stub().resolves({}),
}, },
}; };
fakeServiceConfig = sinon.stub().returns(null); fakeServiceConfig = sinon.stub().returns(null);
fakeSettings = { fakeSettings = {
serviceUrl: 'https://test.hypothes.is/root/', serviceUrl: 'https://test.hypothes.is/root/',
}; };
fakeToastMessenger = { error: sandbox.spy() }; fakeToastMessenger = { error: sinon.spy() };
const retryPromiseOperation = async callback => {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await callback();
} catch (err) {
// Try again
}
}
};
$imports.$mock({ $imports.$mock({
'../config/service-config': fakeServiceConfig, '../config/service-config': fakeServiceConfig,
'../util/retry': { retryPromiseOperation },
'../util/sentry': fakeSentry, '../util/sentry': fakeSentry,
}); });
session = new Injector()
.register('store', { value: fakeStore })
.register('api', { value: fakeApi })
.register('auth', { value: fakeAuth })
.register('settings', { value: fakeSettings })
.register('session', SessionService)
.register('toastMessenger', { value: fakeToastMessenger })
.get('session');
}); });
afterEach(function () { afterEach(() => {
$imports.$restore(); $imports.$restore();
sandbox.restore();
}); });
describe('#load', function () { function createService() {
context('when the host page provides an OAuth grant token', function () { return new SessionService(
beforeEach(function () { fakeStore,
fakeApi,
fakeAuth,
fakeSettings,
fakeToastMessenger
);
}
describe('#load', () => {
context('when the host page provides an OAuth grant token', () => {
beforeEach(() => {
fakeServiceConfig.returns({ fakeServiceConfig.returns({
authority: 'publisher.org', authority: 'publisher.org',
grantToken: 'a.jwt.token', grantToken: 'a.jwt.token',
...@@ -82,16 +87,18 @@ describe('SessionService', function () { ...@@ -82,16 +87,18 @@ describe('SessionService', function () {
); );
}); });
it('should pass the "authority" param when fetching the profile', function () { it('should pass the "authority" param when fetching the profile', () => {
return session.load().then(function () { const session = createService();
return session.load().then(() => {
assert.calledWith(fakeApi.profile.read, { assert.calledWith(fakeApi.profile.read, {
authority: 'publisher.org', authority: 'publisher.org',
}); });
}); });
}); });
it('should update the session with the profile data from the API', function () { it('should update the session with the profile data from the API', () => {
return session.load().then(function () { const session = createService();
return session.load().then(() => {
assert.calledWith(fakeStore.updateProfile, { assert.calledWith(fakeStore.updateProfile, {
userid: 'acct:user@publisher.org', userid: 'acct:user@publisher.org',
}); });
...@@ -117,6 +124,7 @@ describe('SessionService', function () { ...@@ -117,6 +124,7 @@ describe('SessionService', function () {
}); });
it('should fetch profile data from the API', () => { it('should fetch profile data from the API', () => {
const session = createService();
return session.load().then(() => { return session.load().then(() => {
assert.calledWith(fakeApi.profile.read); assert.calledWith(fakeApi.profile.read);
}); });
...@@ -133,9 +141,7 @@ describe('SessionService', function () { ...@@ -133,9 +141,7 @@ describe('SessionService', function () {
.returns(Promise.reject(new Error('Server error'))); .returns(Promise.reject(new Error('Server error')));
fakeApi.profile.read.onCall(1).returns(Promise.resolve(fetchedProfile)); fakeApi.profile.read.onCall(1).returns(Promise.resolve(fetchedProfile));
// Shorten the delay before retrying the fetch. const session = createService();
session.profileFetchRetryOpts.minTimeout = 50;
return session.load().then(() => { return session.load().then(() => {
assert.calledOnce(fakeStore.updateProfile); assert.calledOnce(fakeStore.updateProfile);
assert.calledWith(fakeStore.updateProfile, fetchedProfile); assert.calledWith(fakeStore.updateProfile, fetchedProfile);
...@@ -143,7 +149,8 @@ describe('SessionService', function () { ...@@ -143,7 +149,8 @@ describe('SessionService', function () {
}); });
it('should update the session with the profile data from the API', () => { it('should update the session with the profile data from the API', () => {
return session.load().then(function () { const session = createService();
return session.load().then(() => {
assert.calledOnce(fakeStore.updateProfile); assert.calledOnce(fakeStore.updateProfile);
assert.calledWith(fakeStore.updateProfile, { assert.calledWith(fakeStore.updateProfile, {
userid: 'acct:user@hypothes.is', userid: 'acct:user@hypothes.is',
...@@ -152,6 +159,7 @@ describe('SessionService', function () { ...@@ -152,6 +159,7 @@ describe('SessionService', function () {
}); });
it('should cache the returned profile data', () => { it('should cache the returned profile data', () => {
const session = createService();
return session return session
.load() .load()
.then(() => { .then(() => {
...@@ -166,6 +174,7 @@ describe('SessionService', function () { ...@@ -166,6 +174,7 @@ describe('SessionService', function () {
clock = sinon.useFakeTimers(); clock = sinon.useFakeTimers();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const session = createService();
return session return session
.load() .load()
.then(() => { .then(() => {
...@@ -179,8 +188,9 @@ describe('SessionService', function () { ...@@ -179,8 +188,9 @@ describe('SessionService', function () {
}); });
}); });
describe('#update', function () { describe('#update', () => {
it('updates the user ID for Sentry error reports', function () { it('updates the user ID for Sentry error reports', () => {
const session = createService();
session.update({ session.update({
userid: 'anne', userid: 'anne',
}); });
...@@ -190,8 +200,8 @@ describe('SessionService', function () { ...@@ -190,8 +200,8 @@ describe('SessionService', function () {
}); });
}); });
describe('#dismissSidebarTutorial', function () { describe('#dismissSidebarTutorial', () => {
beforeEach(function () { beforeEach(() => {
fakeApi.profile.update.returns( fakeApi.profile.update.returns(
Promise.resolve({ Promise.resolve({
preferences: {}, preferences: {},
...@@ -199,7 +209,8 @@ describe('SessionService', function () { ...@@ -199,7 +209,8 @@ describe('SessionService', function () {
); );
}); });
it('disables the tutorial for the user', function () { it('disables the tutorial for the user', () => {
const session = createService();
session.dismissSidebarTutorial(); session.dismissSidebarTutorial();
assert.calledWith( assert.calledWith(
fakeApi.profile.update, fakeApi.profile.update,
...@@ -208,8 +219,9 @@ describe('SessionService', function () { ...@@ -208,8 +219,9 @@ describe('SessionService', function () {
); );
}); });
it('should update the session with the response from the API', function () { it('should update the session with the response from the API', () => {
return session.dismissSidebarTutorial().then(function () { const session = createService();
return session.dismissSidebarTutorial().then(() => {
assert.calledOnce(fakeStore.updateProfile); assert.calledOnce(fakeStore.updateProfile);
assert.calledWith(fakeStore.updateProfile, { assert.calledWith(fakeStore.updateProfile, {
preferences: {}, preferences: {},
...@@ -226,6 +238,7 @@ describe('SessionService', function () { ...@@ -226,6 +238,7 @@ describe('SessionService', function () {
userid: 'acct:user_a@hypothes.is', userid: 'acct:user_a@hypothes.is',
}) })
); );
const session = createService();
return session.load(); return session.load();
}); });
...@@ -238,6 +251,7 @@ describe('SessionService', function () { ...@@ -238,6 +251,7 @@ describe('SessionService', function () {
fakeStore.updateProfile.resetHistory(); fakeStore.updateProfile.resetHistory();
const session = createService();
return session.reload().then(() => { return session.reload().then(() => {
assert.calledOnce(fakeStore.updateProfile); assert.calledOnce(fakeStore.updateProfile);
assert.calledWith(fakeStore.updateProfile, { assert.calledWith(fakeStore.updateProfile, {
...@@ -247,7 +261,7 @@ describe('SessionService', function () { ...@@ -247,7 +261,7 @@ describe('SessionService', function () {
}); });
}); });
describe('#logout', function () { describe('#logout', () => {
const loggedOutProfile = { const loggedOutProfile = {
userid: null, userid: null,
...@@ -261,12 +275,14 @@ describe('SessionService', function () { ...@@ -261,12 +275,14 @@ describe('SessionService', function () {
}); });
it('logs the user out', () => { it('logs the user out', () => {
const session = createService();
return session.logout().then(() => { return session.logout().then(() => {
assert.called(fakeAuth.logout); assert.called(fakeAuth.logout);
}); });
}); });
it('updates the profile after logging out', () => { it('updates the profile after logging out', () => {
const session = createService();
return session.logout().then(() => { return session.logout().then(() => {
assert.calledOnce(fakeStore.updateProfile); assert.calledOnce(fakeStore.updateProfile);
assert.calledWith(fakeStore.updateProfile, loggedOutProfile); assert.calledWith(fakeStore.updateProfile, loggedOutProfile);
...@@ -275,6 +291,7 @@ describe('SessionService', function () { ...@@ -275,6 +291,7 @@ describe('SessionService', function () {
it('displays an error if logging out fails', async () => { it('displays an error if logging out fails', async () => {
fakeAuth.logout.rejects(new Error('Could not revoke token')); fakeAuth.logout.rejects(new Error('Could not revoke token'));
const session = createService();
try { try {
await session.logout(); await session.logout();
} catch (e) { } catch (e) {
...@@ -292,6 +309,7 @@ describe('SessionService', function () { ...@@ -292,6 +309,7 @@ describe('SessionService', function () {
}) })
); );
const session = createService();
return session return session
.load() .load()
.then(() => { .then(() => {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment