Repository: Yoctol/bottender (ts)

Issue:
Loop through sessions
If I understand correctly how the session implementation works, I cannot just loop through all the sessions that were created and send them a message (for example based on session state). I need to react to an event.

Is there a way to create a session object from let's say a mongo collection and then loop through sessions and send the users messages?

Or should I store some part of session away?

My goal is to have a bot that sends one message per week, so I cannot purely react on messages, I need to loop through sessions (and survive bot process restarts). What would be the best approach to do this?

(My suggestion is to have an utility function to do looping through sessions)

Pull request:
implement session.all()
close #316 

Patch:
diff --git a/src/cache/CacheStore.js b/src/cache/CacheStore.js
index 4a36a9ae..2edcbc31 100644
--- a/src/cache/CacheStore.js
+++ b/src/cache/CacheStore.js
@@ -2,6 +2,7 @@
 
 export interface CacheStore {
   get(key: string): Promise<mixed>;
+  all(): Promise<Array<mixed>>;
   put(key: string, value: mixed, minutes: number): Promise<void>;
   forget(key: string): Promise<void>;
   flush(): Promise<void>;
diff --git a/src/cache/MemoryCacheStore.js b/src/cache/MemoryCacheStore.js
index a602587b..5cb06b4f 100644
--- a/src/cache/MemoryCacheStore.js
+++ b/src/cache/MemoryCacheStore.js
@@ -22,6 +22,10 @@ export default class MemoryCacheStore implements CacheStore {
     return value || null;
   }
 
+  async all(): Promise<Array<mixed>> {
+    return this._lru.values();
+  }
+
   async put(key: string, value: mixed, minutes: number): Promise<void> {
     // cloneDeep: To make sure save as writable object
     const val = value && typeof value === 'object' ? cloneDeep(value) : value;
diff --git a/src/cache/RedisCacheStore.js b/src/cache/RedisCacheStore.js
index 8cd6c349..14d2e706 100644
--- a/src/cache/RedisCacheStore.js
+++ b/src/cache/RedisCacheStore.js
@@ -34,6 +34,19 @@ export default class RedisCacheStore implements CacheStore {
     return this._unserialize(val);
   }
 
+  async all(): Promise<Array<mixed>> {
+    let [cursor, keys] = await this._redis.scan('0');
+
+    while (cursor !== '0') {
+      /* eslint-disable no-await-in-loop */
+      const [nextCursor, newkeys] = await this._redis.scan(cursor);
+      cursor = nextCursor;
+      keys = keys.concat(newkeys);
+    }
+
+    return this._redis.mget(keys);
+  }
+
   async put(key: string, value: mixed, minutes: number): Promise<void> {
     await this._redis.setex(
       `${this._prefix}${key}`,
diff --git a/src/session/CacheBasedSessionStore.js b/src/session/CacheBasedSessionStore.js
index d5dc44b7..717f51d2 100644
--- a/src/session/CacheBasedSessionStore.js
+++ b/src/session/CacheBasedSessionStore.js
@@ -27,6 +27,10 @@ export default class CacheBasedSessionStore implements SessionStore {
     return (this._cache.get(key): any);
   }
 
+  async all(): Promise<Array<Session>> {
+    return (this._cache.all(): any);
+  }
+
   async write(key: string, sess: Session): Promise<void> {
     this._cache.put(key, sess, this._expiresIn);
   }
diff --git a/src/session/FileSessionStore.js b/src/session/FileSessionStore.js
index 4bdddad7..25b0c205 100644
--- a/src/session/FileSessionStore.js
+++ b/src/session/FileSessionStore.js
@@ -39,6 +39,10 @@ export default class FileSessionStore implements SessionStore {
     return session;
   }
 
+  all(): Promise<Array<Session>> {
+    return this._jfs.all();
+  }
+
   async write(key: string, sess: Session): Promise<void> {
     sess.lastActivity = Date.now();
 
diff --git a/src/session/MongoSessionStore.js b/src/session/MongoSessionStore.js
index 16847748..f7a19ac4 100644
--- a/src/session/MongoSessionStore.js
+++ b/src/session/MongoSessionStore.js
@@ -1,5 +1,4 @@
 /* @flow */
-
 import isBefore from 'date-fns/is_before';
 import subMinutes from 'date-fns/sub_minutes';
 import { MongoClient } from 'mongodb';
@@ -7,7 +6,12 @@ import { MongoClient } from 'mongodb';
 import { type Session } from './Session';
 import { type SessionStore } from './SessionStore';
 
+type Cursor = {
+  toArray: () => Promise<Array<any>>,
+};
+
 type MongoCollection = {
+  find: () => Cursor,
   findOne: (filter: Object) => Promise<{}>,
   updateOne: (filter: Object, data: Object, options: Object) => Promise<void>,
   remove: (filter: Object) => Promise<void>,
@@ -61,6 +65,10 @@ export default class MongoSessionStore implements SessionStore {
     }
   }
 
+  async all(): Promise<Array<Session>> {
+    return this._sessions.find().toArray();
+  }
+
   async write(key: string, sess: Session): Promise<void> {
     const filter = { id: key };
 
diff --git a/src/session/SessionStore.js b/src/session/SessionStore.js
index 200257c6..2cca2434 100644
--- a/src/session/SessionStore.js
+++ b/src/session/SessionStore.js
@@ -5,6 +5,7 @@ import { type Session } from './Session';
 export interface SessionStore {
   init(): Promise<SessionStore>;
   read(key: string): Promise<Session | null>;
+  all(): Promise<Array<Session>>;
   write(key: string, sess: Session): Promise<void>;
   destroy(key: string): Promise<void>;
 }


Test patch:
diff --git a/src/cache/__tests__/MemoryCacheStore.spec.js b/src/cache/__tests__/MemoryCacheStore.spec.js
index cd6c19c1..836509ec 100644
--- a/src/cache/__tests__/MemoryCacheStore.spec.js
+++ b/src/cache/__tests__/MemoryCacheStore.spec.js
@@ -27,6 +27,27 @@ describe('#get', () => {
   });
 });
 
+describe('#all', () => {
+  it('should get all the values in the sessions', async () => {
+    const store = new MemoryCacheStore(5);
+
+    await store.put('x', { id: 1 }, 5);
+    await store.put('y', { id: 2 }, 5);
+
+    const result = await store.all();
+
+    expect(result).toEqual([{ id: 2 }, { id: 1 }]);
+  });
+
+  it('should return empty array when there is no item in sessions or all of them are expired', async () => {
+    const store = new MemoryCacheStore(5);
+
+    const result = await store.all();
+
+    expect(result).toEqual([]);
+  });
+});
+
 describe('#put', () => {
   it('should store cache item for a given number of minutes', async () => {
     const _now = Date.now;
diff --git a/src/cache/__tests__/RedisCacheStore.spec.js b/src/cache/__tests__/RedisCacheStore.spec.js
index fda236cd..82ec3971 100644
--- a/src/cache/__tests__/RedisCacheStore.spec.js
+++ b/src/cache/__tests__/RedisCacheStore.spec.js
@@ -75,6 +75,35 @@ describe('#get', () => {
   });
 });
 
+describe('#all', () => {
+  it('should get all the values in the sessions', async () => {
+    const store = new RedisCacheStore();
+    const redis = store.getRedis();
+
+    redis.scan.mockResolvedValueOnce(['0', ['key1', 'key2']]);
+    redis.mget.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]);
+
+    const result = await store.all();
+
+    expect(result).toEqual([{ id: 1 }, { id: 2 }]);
+    expect(redis.scan).toBeCalledWith('0');
+  });
+
+  it('should iterate through all the keys in redis', async () => {
+    const store = new RedisCacheStore();
+    const redis = store.getRedis();
+
+    redis.scan
+      .mockResolvedValueOnce(['4', ['key1', 'key2', 'key3', 'key4']])
+      .mockResolvedValueOnce(['0', ['key5', 'key6', 'key7', 'key8']]);
+
+    await store.all();
+
+    expect(redis.scan).toBeCalledWith('0');
+    expect(redis.scan).toBeCalledWith('4');
+  });
+});
+
 describe('#put', () => {
   it('should store cache item for a given number of minutes', async () => {
     const store = new RedisCacheStore();
diff --git a/src/session/__tests__/CacheBasedSessionStore.spec.js b/src/session/__tests__/CacheBasedSessionStore.spec.js
index 5cbfcde7..e4fb23d8 100644
--- a/src/session/__tests__/CacheBasedSessionStore.spec.js
+++ b/src/session/__tests__/CacheBasedSessionStore.spec.js
@@ -5,6 +5,7 @@ const expiresIn = 10;
 function setup() {
   const cache = {
     get: jest.fn(),
+    all: jest.fn(),
     put: jest.fn(),
     forget: jest.fn(),
     flush: jest.fn(),
@@ -38,6 +39,20 @@ describe('#read', () => {
   });
 });
 
+describe('#all', () => {
+  it('should call cache all and return all values', async () => {
+    const { store, cache } = setup();
+    await store.init();
+
+    cache.all.mockResolvedValue([{ id: 2 }, { id: 3 }, { id: 4 }]);
+
+    const result = await store.all();
+
+    expect(result).toEqual([{ id: 2 }, { id: 3 }, { id: 4 }]);
+    expect(cache.all).toBeCalled();
+  });
+});
+
 describe('#write', () => {
   it('should call cache put with key, value, and expiresIn', async () => {
     const { store, cache } = setup();
diff --git a/src/session/__tests__/FileSessionStore.spec.js b/src/session/__tests__/FileSessionStore.spec.js
index 6d484264..73b01050 100644
--- a/src/session/__tests__/FileSessionStore.spec.js
+++ b/src/session/__tests__/FileSessionStore.spec.js
@@ -8,6 +8,7 @@ function setup() {
   const store = new FileSessionStore('.session', expiresIn);
   const jfs = store.getJFS();
   jfs.get = jest.fn();
+  jfs.all = jest.fn();
   jfs.save = jest.fn();
   jfs.delete = jest.fn();
   return {
@@ -57,6 +58,28 @@ describe('#read', () => {
   });
 });
 
+describe('#all', () => {
+  it('should return all values in jfs', async () => {
+    const { store, jfs } = setup();
+    await store.init();
+
+    jfs.all.mockResolvedValue([{ id: 1 }, { id: 2 }]);
+
+    expect(await store.all()).toEqual([{ id: 1 }, { id: 2 }]);
+    expect(jfs.all).toBeCalled();
+  });
+
+  it('should return empty array when there is no item in sessions or all of them are expired', async () => {
+    const { store, jfs } = setup();
+    await store.init();
+
+    jfs.all.mockResolvedValue([]);
+
+    expect(await store.all()).toEqual([]);
+    expect(jfs.all).toBeCalled();
+  });
+});
+
 describe('#write', () => {
   it('should call jfs save with key, value', async () => {
     const { store, jfs } = setup();
diff --git a/src/session/__tests__/MongoSessionStore.spec.js b/src/session/__tests__/MongoSessionStore.spec.js
index 8339a1d1..b16b3284 100644
--- a/src/session/__tests__/MongoSessionStore.spec.js
+++ b/src/session/__tests__/MongoSessionStore.spec.js
@@ -112,6 +112,21 @@ describe('#read', () => {
   });
 });
 
+describe('#all', () => {
+  it('should return all values in mongo session', async () => {
+    const { store, sessions } = setup();
+    const sess = [
+      { id: 1, lastActivity: Date.now() },
+      { id: 2, lastActivity: Date.now() },
+    ];
+    sessions.find = jest.fn(() => ({ toArray: () => sess }));
+
+    await store.init();
+
+    expect(await store.all()).toBe(sess);
+  });
+});
+
 describe('#write', () => {
   it('should call updateOne with platform, id and session using upsert', async () => {
     const { store, sessions } = setup();