-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(usage): Add a FeatureUsageStore and move Identity to the DB
Summary: This is a WIP Depends on D3799 on billing.nylas.com This adds a `FeatureUsageStore` which determines whether a feature can be used or not. It also allows us to record "using" a feature. Feature Usage is ultimately backed by the Nylas Identity and cached locally in the Identity object. Since feature usage is attached to the Nylas Identity, we move the whole Identity object (except for the ID) to the database. This includes a migration (with tests!) to move the Nylas Identity from the config into the Database. We still, however, need the Nylas ID to stay in the config so it can be synchronously accessed by the /browser process on bootup when determining what windows to show. It's also convenient to know what the Nylas ID is by looking at the config. There's logic (with tests!) to make sure these stay in sync. If you delete the Nylas ID from the config, it'll be the same as logging you out. The schema for the feature usage can be found in more detail on D3799. By the time it reaches Nylas Mail, the Nylas ID object has a `feature_usage` attribute that has each feature (keyed by the feature name) and information about the plans attached to it. The schema Nylas Mail sees looks like: ``` "feature_usage": { "snooze": { quota: 10, peroid: 'monthly', used_in_period: 8, feature_limit_name: 'Snooze Group A', }, } ``` See D3799 for more info about how these are generated. One final change that's in here is how Stores are loaded. Most of our core stores are loaded at require time, but now things like the IdentityStore need to do asynchronous things on activation. In reality most of our stores do this and it's a miracle it hasn't caused more problems! Now when stores activate we optionally look for an `activate` method and `await` for it. This was necessary so downstream classes (like the Onboarding Store), see a fully initialized IdentityStore by the time it's time to use them Test Plan: New tests! Reviewers: khamidou, juan, halla Reviewed By: juan Differential Revision: https://phab.nylas.com/D3808
- Loading branch information
Showing
16 changed files
with
499 additions
and
104 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import {TaskQueueStatusStore} from 'nylas-exports' | ||
import FeatureUsageStore from '../../src/flux/stores/feature-usage-store' | ||
import Task from '../../src/flux/tasks/task' | ||
import SendFeatureUsageEventTask from '../../src/flux/tasks/send-feature-usage-event-task' | ||
import IdentityStore from '../../src/flux/stores/identity-store' | ||
|
||
describe("FeatureUsageStore", function featureUsageStoreSpec() { | ||
beforeEach(() => { | ||
this.oldIdent = IdentityStore._identity; | ||
IdentityStore._identity = {id: 'foo'} | ||
IdentityStore._identity.feature_usage = { | ||
"is-usable": { | ||
quota: 10, | ||
peroid: 'monthly', | ||
used_in_period: 8, | ||
feature_limit_name: 'Usable Group A', | ||
}, | ||
"not-usable": { | ||
quota: 10, | ||
peroid: 'monthly', | ||
used_in_period: 10, | ||
feature_limit_name: 'Unusable Group A', | ||
}, | ||
} | ||
}); | ||
|
||
afterEach(() => { | ||
IdentityStore._identity = this.oldIdent | ||
}); | ||
|
||
describe("isUsable", () => { | ||
it("returns true if a feature hasn't met it's quota", () => { | ||
expect(FeatureUsageStore.isUsable("is-usable")).toBe(true) | ||
}); | ||
|
||
it("returns false if a feature is at its quota", () => { | ||
expect(FeatureUsageStore.isUsable("not-usable")).toBe(false) | ||
}); | ||
|
||
it("warns if asking for an unsupported feature", () => { | ||
spyOn(NylasEnv, "reportError") | ||
expect(FeatureUsageStore.isUsable("unsupported")).toBe(false) | ||
expect(NylasEnv.reportError).toHaveBeenCalled() | ||
}); | ||
}); | ||
|
||
describe("useFeature", () => { | ||
beforeEach(() => { | ||
spyOn(SendFeatureUsageEventTask.prototype, "performRemote").andReturn(Promise.resolve(Task.Status.Success)); | ||
spyOn(IdentityStore, "saveIdentity").andCallFake((ident) => { | ||
IdentityStore._identity = ident | ||
}) | ||
spyOn(TaskQueueStatusStore, "waitForPerformLocal").andReturn(Promise.resolve()) | ||
}); | ||
|
||
it("returns the num remaining if successful", async () => { | ||
let numLeft = await FeatureUsageStore.useFeature('is-usable'); | ||
expect(numLeft).toBe(1) | ||
numLeft = await FeatureUsageStore.useFeature('is-usable'); | ||
expect(numLeft).toBe(0) | ||
}); | ||
|
||
it("throws if it was over quota", async () => { | ||
try { | ||
await FeatureUsageStore.useFeature("not-usable"); | ||
throw new Error("This should throw") | ||
} catch (err) { | ||
expect(err.message).toMatch(/not usable/) | ||
} | ||
}); | ||
|
||
it("throws if using an unsupported feature", async () => { | ||
spyOn(NylasEnv, "reportError") | ||
try { | ||
await FeatureUsageStore.useFeature("unsupported"); | ||
throw new Error("This should throw") | ||
} catch (err) { | ||
expect(err.message).toMatch(/supported/) | ||
} | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import {ipcRenderer} from 'electron'; | ||
import {KeyManager, DatabaseTransaction, SendFeatureUsageEventTask} from 'nylas-exports' | ||
import IdentityStore from '../../src/flux/stores/identity-store' | ||
|
||
const TEST_NYLAS_ID = "icihsnqh4pwujyqihlrj70vh" | ||
const TEST_TOKEN = "test-token" | ||
|
||
describe("IdentityStore", function identityStoreSpec() { | ||
beforeEach(() => { | ||
this.identityJSON = { | ||
valid_until: 1500093224, | ||
firstname: "Nylas 050", | ||
lastname: "Test", | ||
free_until: 1500006814, | ||
email: "[email protected]", | ||
id: TEST_NYLAS_ID, | ||
seen_welcome_page: true, | ||
} | ||
}); | ||
|
||
it("logs out of nylas identity properly", async () => { | ||
IdentityStore._identity = this.identityJSON; | ||
spyOn(NylasEnv.config, 'unset') | ||
spyOn(KeyManager, "deletePassword") | ||
spyOn(ipcRenderer, "send") | ||
spyOn(DatabaseTransaction.prototype, "persistJSONBlob").andReturn(Promise.resolve()) | ||
|
||
const promise = IdentityStore._onLogoutNylasIdentity() | ||
IdentityStore._onIdentityChanged(null) | ||
return promise.then(() => { | ||
expect(KeyManager.deletePassword).toHaveBeenCalled() | ||
expect(ipcRenderer.send).toHaveBeenCalled() | ||
expect(ipcRenderer.send.calls[0].args[1]).toBe("application:relaunch-to-initial-windows") | ||
expect(DatabaseTransaction.prototype.persistJSONBlob).toHaveBeenCalled() | ||
const ident = DatabaseTransaction.prototype.persistJSONBlob.calls[0].args[1] | ||
expect(ident).toBe(null) | ||
}) | ||
}); | ||
|
||
it("can log a feature usage event", () => { | ||
spyOn(IdentityStore, "nylasIDRequest"); | ||
spyOn(IdentityStore, "saveIdentity"); | ||
IdentityStore._identity = this.identityJSON | ||
IdentityStore._identity.token = TEST_TOKEN; | ||
IdentityStore._onEnvChanged() | ||
const t = new SendFeatureUsageEventTask("snooze"); | ||
t.performRemote() | ||
const opts = IdentityStore.nylasIDRequest.calls[0].args[0] | ||
expect(opts).toEqual({ | ||
method: "POST", | ||
url: "https://billing.nylas.com/n1/user/feature_usage_event", | ||
body: { | ||
feature_name: 'snooze', | ||
}, | ||
}) | ||
}); | ||
|
||
describe("returning the identity object", () => { | ||
it("returns the identity as null if it looks blank", () => { | ||
IdentityStore._identity = null; | ||
expect(IdentityStore.identity()).toBe(null); | ||
IdentityStore._identity = {}; | ||
expect(IdentityStore.identity()).toBe(null); | ||
IdentityStore._identity = {token: 'bad'}; | ||
expect(IdentityStore.identity()).toBe(null); | ||
}); | ||
|
||
it("returns a proper clone of the identity", () => { | ||
IdentityStore._identity = {id: 'bar', deep: {obj: 'baz'}}; | ||
const ident = IdentityStore.identity(); | ||
IdentityStore._identity.deep.obj = 'changed'; | ||
expect(ident.deep.obj).toBe('baz'); | ||
}); | ||
}); | ||
}); |
Submodule K2
updated
from d85ca0 to ae7815
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
export default class ConfigMigrator { | ||
constructor(config, database) { | ||
this.config = config; | ||
this.database = database; | ||
} | ||
|
||
migrate() { | ||
/** | ||
* In version before 1.0.21 we stored the Nylas ID Identity in the Config. | ||
* After 1.0.21 we moved it into the JSONBlob Database Store. | ||
*/ | ||
const oldIdentity = this.config.get("nylas.identity") || {}; | ||
if (!oldIdentity.id) return; | ||
const key = "NylasID" | ||
const q = `REPLACE INTO JSONBlob (id, data, client_id) VALUES (?,?,?)`; | ||
const jsonBlobData = { | ||
id: key, | ||
clientId: key, | ||
serverId: key, | ||
json: oldIdentity, | ||
} | ||
this.database.database.prepare(q).run([key, JSON.stringify(jsonBlobData), key]) | ||
this.config.set("nylas.identity", null) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import {setupDatabase, databasePath} from '../database-helpers' | ||
|
||
export default class DatabaseReader { | ||
constructor({configDirPath, specMode}) { | ||
this.databasePath = databasePath(configDirPath, specMode) | ||
} | ||
|
||
async open() { | ||
this.database = await setupDatabase(this.databasePath) | ||
} | ||
|
||
getJSONBlob(key) { | ||
const q = `SELECT * FROM JSONBlob WHERE id = '${key}'`; | ||
try { | ||
const row = this.database.prepare(q).get(); | ||
if (!row || !row.data) return null | ||
return (JSON.parse(row.data) || {}).json | ||
} catch (err) { | ||
return null | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.