feat: add subscription manager to handle subscription sharing (#1517)
* feat: add subscription manager to handle subscription sharing * fix(services): add missing methods to the interface * fix(services): add subscription manager specs * feat(snjs): add subscriptions e2e tests * fix(snjs): add wait in subscription cancelling test * fix(snjs): checking for canceled invitations in tests * fix(snjs): add e2e test for restored limit of subscription invitations * chore(lint): fix linter issues
This commit is contained in:
@@ -1,13 +1,7 @@
|
||||
import { SNLog } from './../Log'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import {
|
||||
AlertService,
|
||||
DeviceInterface,
|
||||
Environment,
|
||||
namespacedKey,
|
||||
Platform,
|
||||
RawStorageKey,
|
||||
} from '@standardnotes/services'
|
||||
import { AlertService, DeviceInterface, namespacedKey, RawStorageKey } from '@standardnotes/services'
|
||||
import { Environment, Platform } from '@standardnotes/models'
|
||||
import { SNApplication } from './Application'
|
||||
|
||||
describe('application', () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import {
|
||||
HttpService,
|
||||
HttpServiceInterface,
|
||||
SubscriptionApiService,
|
||||
SubscriptionApiServiceInterface,
|
||||
SubscriptionServer,
|
||||
SubscriptionServerInterface,
|
||||
UserApiService,
|
||||
UserApiServiceInterface,
|
||||
UserRegistrationResponseBody,
|
||||
@@ -23,9 +27,7 @@ import {
|
||||
ChallengeValidation,
|
||||
ComponentManagerInterface,
|
||||
DiagnosticInfo,
|
||||
Environment,
|
||||
isDesktopDevice,
|
||||
Platform,
|
||||
ChallengeValue,
|
||||
StorageKey,
|
||||
ChallengeReason,
|
||||
@@ -37,11 +39,20 @@ import {
|
||||
EncryptionServiceEvent,
|
||||
FilesBackupService,
|
||||
FileService,
|
||||
SubscriptionClientInterface,
|
||||
SubscriptionManager,
|
||||
} from '@standardnotes/services'
|
||||
import { FilesClientInterface } from '@standardnotes/files'
|
||||
import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption'
|
||||
import { useBoolean } from '@standardnotes/utils'
|
||||
import { BackupFile, DecryptedItemInterface, EncryptedItemInterface, ItemStream } from '@standardnotes/models'
|
||||
import {
|
||||
BackupFile,
|
||||
DecryptedItemInterface,
|
||||
EncryptedItemInterface,
|
||||
Environment,
|
||||
ItemStream,
|
||||
Platform,
|
||||
} from '@standardnotes/models'
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
|
||||
import { SnjsVersion } from './../Version'
|
||||
@@ -92,6 +103,9 @@ export class SNApplication
|
||||
private apiService!: InternalServices.SNApiService
|
||||
private declare userApiService: UserApiServiceInterface
|
||||
private declare userServer: UserServerInterface
|
||||
private declare subscriptionApiService: SubscriptionApiServiceInterface
|
||||
private declare subscriptionServer: SubscriptionServerInterface
|
||||
private declare subscriptionManager: SubscriptionClientInterface
|
||||
private sessionManager!: InternalServices.SNSessionManager
|
||||
private syncService!: InternalServices.SNSyncService
|
||||
private challengeService!: InternalServices.ChallengeService
|
||||
@@ -187,6 +201,10 @@ export class SNApplication
|
||||
this.defineInternalEventHandlers()
|
||||
}
|
||||
|
||||
get subscriptions(): ExternalServices.SubscriptionClientInterface {
|
||||
return this.subscriptionManager
|
||||
}
|
||||
|
||||
public get files(): FilesClientInterface {
|
||||
return this.fileService
|
||||
}
|
||||
@@ -1031,6 +1049,9 @@ export class SNApplication
|
||||
this.createHttpService()
|
||||
this.createUserServer()
|
||||
this.createUserApiService()
|
||||
this.createSubscriptionServer()
|
||||
this.createSubscriptionApiService()
|
||||
this.createSubscriptionManager()
|
||||
this.createWebSocketsService()
|
||||
this.createSessionManager()
|
||||
this.createHistoryManager()
|
||||
@@ -1069,6 +1090,9 @@ export class SNApplication
|
||||
;(this.apiService as unknown) = undefined
|
||||
;(this.userApiService as unknown) = undefined
|
||||
;(this.userServer as unknown) = undefined
|
||||
;(this.subscriptionApiService as unknown) = undefined
|
||||
;(this.subscriptionServer as unknown) = undefined
|
||||
;(this.subscriptionManager as unknown) = undefined
|
||||
;(this.sessionManager as unknown) = undefined
|
||||
;(this.syncService as unknown) = undefined
|
||||
;(this.challengeService as unknown) = undefined
|
||||
@@ -1262,6 +1286,18 @@ export class SNApplication
|
||||
this.userServer = new UserServer(this.httpService)
|
||||
}
|
||||
|
||||
private createSubscriptionApiService() {
|
||||
this.subscriptionApiService = new SubscriptionApiService(this.subscriptionServer)
|
||||
}
|
||||
|
||||
private createSubscriptionServer() {
|
||||
this.subscriptionServer = new SubscriptionServer(this.httpService)
|
||||
}
|
||||
|
||||
private createSubscriptionManager() {
|
||||
this.subscriptionManager = new SubscriptionManager(this.subscriptionApiService, this.internalEventBus)
|
||||
}
|
||||
|
||||
private createItemManager() {
|
||||
this.itemManager = new InternalServices.ItemManager(this.payloadManager, this.options, this.internalEventBus)
|
||||
this.services.push(this.itemManager)
|
||||
@@ -1377,6 +1413,7 @@ export class SNApplication
|
||||
this.protocolService,
|
||||
this.challengeService,
|
||||
this.webSocketsService,
|
||||
this.httpService,
|
||||
this.internalEventBus,
|
||||
)
|
||||
this.serviceObservers.push(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Environment, Platform } from '@standardnotes/models'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { AlertService, DeviceInterface, Environment, Platform } from '@standardnotes/services'
|
||||
import { AlertService, DeviceInterface } from '@standardnotes/services'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
|
||||
export interface RequiredApplicationOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Environment, Platform } from '@standardnotes/services'
|
||||
import { Environment, Platform } from '@standardnotes/models'
|
||||
|
||||
export function platformFromString(string: string) {
|
||||
const map: Record<string, Platform> = {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Environment } from '@standardnotes/models'
|
||||
import { DeviceInterface, InternalEventBusInterface, EncryptionService } from '@standardnotes/services'
|
||||
|
||||
import { SNSessionManager } from '../Services/Session/SessionManager'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { DeviceInterface, InternalEventBusInterface, Environment, EncryptionService } from '@standardnotes/services'
|
||||
import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services'
|
||||
|
||||
export type MigrationServices = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { Environment } from '@standardnotes/models'
|
||||
import { compareSemVersions, isRightVersionGreaterThanLeft } from '@Lib/Version'
|
||||
import { DeviceInterface, Environment } from '@standardnotes/services'
|
||||
import { DeviceInterface } from '@standardnotes/services'
|
||||
import { StorageReader } from './Reader'
|
||||
import * as ReaderClasses from './Versions'
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Environment } from '@standardnotes/models'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { DeviceInterface, Environment } from '@standardnotes/services'
|
||||
import { DeviceInterface } from '@standardnotes/services'
|
||||
|
||||
/**
|
||||
* A storage reader reads storage via a device interface
|
||||
|
||||
@@ -2,7 +2,8 @@ import { API_MESSAGE_RATE_LIMITED, UNKNOWN_ERROR } from './Messages'
|
||||
import { HttpResponse, StatusCode } from '@standardnotes/responses'
|
||||
import { isString } from '@standardnotes/utils'
|
||||
import { SnjsVersion } from '@Lib/Version'
|
||||
import { AbstractService, InternalEventBusInterface, Environment } from '@standardnotes/services'
|
||||
import { AbstractService, InternalEventBusInterface } from '@standardnotes/services'
|
||||
import { Environment } from '@standardnotes/models'
|
||||
|
||||
export enum HttpVerb {
|
||||
Get = 'GET',
|
||||
|
||||
@@ -11,14 +11,8 @@ import {
|
||||
FeatureIdentifier,
|
||||
} from '@standardnotes/features'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { GenericItem, SNComponent } from '@standardnotes/models'
|
||||
import {
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
Environment,
|
||||
Platform,
|
||||
AlertService,
|
||||
} from '@standardnotes/services'
|
||||
import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models'
|
||||
import { DesktopManagerInterface, InternalEventBusInterface, AlertService } from '@standardnotes/services'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
|
||||
import { SNComponentManager } from './ComponentManager'
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
ComponentMutator,
|
||||
PayloadEmitSource,
|
||||
PermissionDialog,
|
||||
Environment,
|
||||
Platform,
|
||||
} from '@standardnotes/models'
|
||||
import { SNSyncService } from '@Lib/Services/Sync/SyncService'
|
||||
import find from 'lodash/find'
|
||||
@@ -26,8 +28,6 @@ import {
|
||||
ComponentViewerInterface,
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
Environment,
|
||||
Platform,
|
||||
AlertService,
|
||||
} from '@standardnotes/services'
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ import { SNPreferencesService } from '../Preferences/PreferencesService'
|
||||
import {
|
||||
ComponentViewerInterface,
|
||||
ComponentViewerError,
|
||||
Environment,
|
||||
FeatureStatus,
|
||||
FeaturesEvent,
|
||||
Platform,
|
||||
AlertService,
|
||||
} from '@standardnotes/services'
|
||||
import { SNFeaturesService } from '@Lib/Services'
|
||||
@@ -34,6 +32,8 @@ import {
|
||||
PayloadTimestampDefaults,
|
||||
IncomingComponentItemPayload,
|
||||
MessageData,
|
||||
Environment,
|
||||
Platform,
|
||||
} from '@standardnotes/models'
|
||||
import find from 'lodash/find'
|
||||
import uniq from 'lodash/uniq'
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
ApiCallError,
|
||||
ErrorMessage,
|
||||
HttpErrorResponseBody,
|
||||
HttpServiceInterface,
|
||||
UserApiServiceInterface,
|
||||
UserRegistrationResponseBody,
|
||||
} from '@standardnotes/api'
|
||||
@@ -77,6 +78,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
||||
private protocolService: EncryptionService,
|
||||
private challengeService: ChallengeService,
|
||||
private webSocketsService: SNWebSocketsService,
|
||||
private httpService: HttpServiceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
@@ -617,6 +619,10 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
|
||||
|
||||
void this.apiService.setHost(host)
|
||||
|
||||
this.httpService.setHost(host)
|
||||
|
||||
this.httpService.setAuthorizationToken(session.authorizationValue)
|
||||
|
||||
await this.setSession(session)
|
||||
|
||||
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SNLog } from '../../Log'
|
||||
import { isErrorDecryptingParameters, SNRootKey } from '@standardnotes/encryption'
|
||||
import * as Encryption from '@standardnotes/encryption'
|
||||
import * as Services from '@standardnotes/services'
|
||||
import { DiagnosticInfo, Environment } from '@standardnotes/services'
|
||||
import { DiagnosticInfo } from '@standardnotes/services'
|
||||
import {
|
||||
CreateDecryptedLocalStorageContextPayload,
|
||||
CreateDeletedLocalStorageContextPayload,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DeletedPayloadInterface,
|
||||
PayloadTimestampDefaults,
|
||||
LocalStorageEncryptedContextualPayload,
|
||||
Environment,
|
||||
} from '@standardnotes/models'
|
||||
|
||||
/**
|
||||
|
||||
110
packages/snjs/mocha/subscriptions.test.js
Normal file
110
packages/snjs/mocha/subscriptions.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as Factory from './lib/factory.js'
|
||||
chai.use(chaiAsPromised)
|
||||
const expect = chai.expect
|
||||
|
||||
describe('subscriptions', function () {
|
||||
this.timeout(Factory.TwentySecondTimeout)
|
||||
|
||||
let application
|
||||
let context
|
||||
let subscriptionManager
|
||||
|
||||
afterEach(async function () {
|
||||
await Factory.safeDeinit(application)
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
localStorage.clear()
|
||||
|
||||
context = await Factory.createAppContextWithFakeCrypto()
|
||||
|
||||
await context.launch()
|
||||
|
||||
application = context.application
|
||||
subscriptionManager = context.application.subscriptionManager
|
||||
|
||||
const result = await Factory.registerUserToApplication({
|
||||
application: application,
|
||||
email: context.email,
|
||||
password: context.password,
|
||||
})
|
||||
|
||||
await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', {
|
||||
userEmail: context.email,
|
||||
subscriptionId: 1,
|
||||
subscriptionName: 'PRO_PLAN',
|
||||
subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000,
|
||||
timestamp: Date.now(),
|
||||
offline: false,
|
||||
})
|
||||
await Factory.sleep(0.25)
|
||||
})
|
||||
|
||||
it('should invite a user by email to a shared subscription', async () => {
|
||||
await subscriptionManager.inviteToSubscription('test@test.te')
|
||||
|
||||
const existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
const newlyCreatedInvite = existingInvites.find(invite => invite.inviteeIdentifier === 'test@test.te')
|
||||
|
||||
expect(newlyCreatedInvite.status).to.equal('sent')
|
||||
})
|
||||
|
||||
it('should not invite a user by email if the limit of shared subscription is breached', async () => {
|
||||
await subscriptionManager.inviteToSubscription('test1@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test2@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test3@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test4@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test5@test.te')
|
||||
|
||||
let existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect(existingInvites.length).to.equal(5)
|
||||
|
||||
expect(await subscriptionManager.inviteToSubscription('test6@test.te')).to.equal(false)
|
||||
|
||||
existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect(existingInvites.length).to.equal(5)
|
||||
})
|
||||
|
||||
it('should cancel a user invitation to a shared subscription', async () => {
|
||||
await subscriptionManager.inviteToSubscription('test@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test2@test.te')
|
||||
|
||||
let existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect (existingInvites.length).to.equal(2)
|
||||
|
||||
const newlyCreatedInvite = existingInvites.find(invite => invite.inviteeIdentifier === 'test@test.te')
|
||||
|
||||
await subscriptionManager.cancelInvitation(newlyCreatedInvite.uuid)
|
||||
|
||||
existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect (existingInvites.length).to.equal(2)
|
||||
|
||||
expect(existingInvites.filter(invite => invite.status === 'canceled').length).to.equal(1)
|
||||
})
|
||||
|
||||
it('should invite a user by email if the limit of shared subscription is restored', async () => {
|
||||
await subscriptionManager.inviteToSubscription('test1@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test2@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test3@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test4@test.te')
|
||||
await subscriptionManager.inviteToSubscription('test5@test.te')
|
||||
|
||||
let existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect(existingInvites.length).to.equal(5)
|
||||
|
||||
await subscriptionManager.cancelInvitation(existingInvites[0].uuid)
|
||||
|
||||
expect(await subscriptionManager.inviteToSubscription('test6@test.te')).to.equal(true)
|
||||
|
||||
existingInvites = await subscriptionManager.listSubscriptionInvitations()
|
||||
|
||||
expect(existingInvites.find(invite => invite.inviteeIdentifier === 'test6@test.te')).not.to.equal(undefined)
|
||||
})
|
||||
})
|
||||
@@ -91,6 +91,7 @@
|
||||
<script type="module" src="session-sharing.test.js"></script>
|
||||
<script type="module" src="files.test.js"></script>
|
||||
<script type="module" src="session.test.js"></script>
|
||||
<script type="module" src="subscriptions.test.js"></script>
|
||||
<script type="module">
|
||||
mocha.run();
|
||||
</script>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"tsc": "tsc --project lib/tsconfig.json && tscpaths -p lib/tsconfig.json -s lib -o dist/@types",
|
||||
"lint": "yarn lint:tsc && yarn lint:eslint",
|
||||
"lint:eslint": "eslint --ext .ts lib/",
|
||||
"lint:fix": "eslint --fix --ext .ts lib/",
|
||||
"lint:tsc": "tsc --noEmit --emitDeclarationOnly false --project lib/tsconfig.json",
|
||||
"test": "jest spec --coverage",
|
||||
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand"
|
||||
@@ -67,7 +68,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@standardnotes/api": "workspace:*",
|
||||
"@standardnotes/common": "^1.25.0",
|
||||
"@standardnotes/common": "^1.32.0",
|
||||
"@standardnotes/domain-events": "^2.39.0",
|
||||
"@standardnotes/encryption": "workspace:*",
|
||||
"@standardnotes/features": "workspace:*",
|
||||
|
||||
Reference in New Issue
Block a user