Files
standardnotes-app-web/packages/snjs/lib/Services/Features/FeaturesService.spec.ts

537 lines
20 KiB
TypeScript

import { ItemInterface, SNFeatureRepo } from '@standardnotes/models'
import { SyncService } from '../Sync/SyncService'
import { SettingName } from '@standardnotes/settings'
import { FeaturesService } from '@Lib/Services/Features'
import { RoleName, ContentType, Uuid } from '@standardnotes/domain-core'
import { NativeFeatureIdentifier, GetFeatures } from '@standardnotes/features'
import { WebSocketsService } from '../Api/WebsocketsService'
import { SettingsService } from '../Settings'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import {
AlertService,
LegacyApiServiceInterface,
FeaturesEvent,
FeatureStatus,
InternalEventBusInterface,
ItemManagerInterface,
MutatorClientInterface,
SessionsClientInterface,
StorageKey,
StorageServiceInterface,
SubscriptionManagerInterface,
SyncServiceInterface,
UserClientInterface,
UserService,
} from '@standardnotes/services'
import { LegacyApiService, SessionManager } from '../Api'
import { ItemManager } from '../Items'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { SettingsClientInterface } from '../Settings/SettingsClientInterface'
describe('FeaturesService', () => {
let storageService: StorageServiceInterface
let itemManager: ItemManagerInterface
let mutator: MutatorClientInterface
let subscriptions: SubscriptionManagerInterface
let apiService: LegacyApiServiceInterface
let webSocketsService: WebSocketsService
let settingsService: SettingsClientInterface
let userService: UserClientInterface
let syncService: SyncServiceInterface
let alertService: AlertService
let sessionManager: SessionsClientInterface
let crypto: PureCryptoInterface
let roles: string[]
let items: ItemInterface[]
let internalEventBus: InternalEventBusInterface
let featureService: FeaturesService
const createService = () => {
return new FeaturesService(
storageService,
itemManager,
mutator,
subscriptions,
apiService,
webSocketsService,
settingsService,
userService,
syncService,
alertService,
sessionManager,
crypto,
internalEventBus,
)
}
beforeEach(() => {
roles = [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser]
items = [] as jest.Mocked<ItemInterface[]>
storageService = {} as jest.Mocked<DiskStorageService>
storageService.setValue = jest.fn()
storageService.getValue = jest.fn()
apiService = {} as jest.Mocked<LegacyApiService>
apiService.addEventObserver = jest.fn()
apiService.isThirdPartyHostUsed = jest.fn().mockReturnValue(false)
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue(items)
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.addObserver = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
mutator.setItemsToBeDeleted = jest.fn()
mutator.changeItem = jest.fn()
mutator.changeFeatureRepo = jest.fn()
subscriptions = {} as jest.Mocked<SubscriptionManagerInterface>
subscriptions.getOnlineSubscription = jest.fn()
subscriptions.addEventObserver = jest.fn()
webSocketsService = {} as jest.Mocked<WebSocketsService>
webSocketsService.addEventObserver = jest.fn()
settingsService = {} as jest.Mocked<SettingsService>
settingsService.updateSetting = jest.fn()
userService = {} as jest.Mocked<UserService>
userService.addEventObserver = jest.fn()
syncService = {} as jest.Mocked<SyncService>
syncService.sync = jest.fn()
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn().mockReturnValue(true)
alertService.alert = jest.fn()
sessionManager = {} as jest.Mocked<SessionManager>
sessionManager.isSignedIntoFirstPartyServer = jest.fn()
sessionManager.getUser = jest.fn()
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.base64Decode = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
internalEventBus.addEventHandler = jest.fn()
featureService = new FeaturesService(
storageService,
itemManager,
mutator,
subscriptions,
apiService,
webSocketsService,
settingsService,
userService,
syncService,
alertService,
sessionManager,
crypto,
internalEventBus,
)
})
describe('experimental features', () => {
it('enables/disables an experimental feature', async () => {
storageService.getValue = jest.fn().mockReturnValue(GetFeatures())
featureService.getExperimentalFeatures = jest.fn().mockReturnValue([NativeFeatureIdentifier.TYPES.PlusEditor])
featureService.initializeFromDisk()
featureService.enableExperimentalFeature(NativeFeatureIdentifier.TYPES.PlusEditor)
expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.PlusEditor)).toEqual(true)
featureService.disableExperimentalFeature(NativeFeatureIdentifier.TYPES.PlusEditor)
expect(featureService.isExperimentalFeatureEnabled(NativeFeatureIdentifier.TYPES.PlusEditor)).toEqual(false)
})
})
describe('hasFirstPartyOnlineSubscription', () => {
it('should be true if signed into first party server and has online subscription', () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
expect(featureService.hasFirstPartyOnlineSubscription()).toEqual(true)
})
it('should not be true if not signed into first party server', () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
expect(featureService.hasFirstPartyOnlineSubscription()).toEqual(false)
})
it('should not be true if no online subscription', () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(false)
expect(featureService.hasFirstPartyOnlineSubscription()).toEqual(false)
})
})
describe('hasPaidAnyPartyOnlineOrOfflineSubscription', () => {
it('should return true if onlineRolesIncludePaidSubscription', () => {
featureService.onlineRolesIncludePaidSubscription = jest.fn().mockReturnValue(true)
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
})
it('should return true if hasOfflineRepo', () => {
featureService.hasOfflineRepo = jest.fn().mockReturnValue(true)
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
})
it('should return true if hasFirstPartyOnlineSubscription', () => {
featureService.hasFirstPartyOnlineSubscription = jest.fn().mockReturnValue(true)
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
})
})
describe('loadUserRoles()', () => {
it('retrieves user roles and features from storage', async () => {
createService().initializeFromDisk()
expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserRoles, undefined, [])
})
})
describe('updateRoles()', () => {
it('setRoles should notify event if roles changed', async () => {
storageService.getValue = jest.fn().mockReturnValue(roles)
featureService.initializeFromDisk()
const mock = (featureService['notifyEvent'] = jest.fn())
const newRoles = [...roles, RoleName.NAMES.PlusUser]
featureService.setOnlineRoles(newRoles)
expect(mock.mock.calls[0][0]).toEqual(FeaturesEvent.UserRolesChanged)
})
it('should notify of subscription purchase', async () => {
storageService.getValue = jest.fn().mockReturnValue(roles)
featureService.initializeFromDisk()
const spy = jest.spyOn(featureService, 'notifyEvent' as never)
const newRoles = [...roles, RoleName.NAMES.ProUser]
await featureService.updateOnlineRolesWithNewValues(newRoles)
expect(spy.mock.calls[1][0]).toEqual(FeaturesEvent.DidPurchaseSubscription)
})
it('should not notify of subscription purchase on initial roles load after sign in', async () => {
storageService.getValue = jest.fn().mockReturnValue(roles)
featureService.initializeFromDisk()
featureService['onlineRoles'] = []
const spy = jest.spyOn(featureService, 'notifyEvent' as never)
const newRoles = [...roles, RoleName.NAMES.ProUser]
await featureService.updateOnlineRolesWithNewValues(newRoles)
const triggeredEvents = spy.mock.calls.map((call) => call[0])
expect(triggeredEvents).not.toContain(FeaturesEvent.DidPurchaseSubscription)
})
it('saves new roles to storage if a role has been added', async () => {
storageService.getValue = jest.fn().mockReturnValue(roles)
featureService.initializeFromDisk()
const newRoles = [...roles, RoleName.NAMES.ProUser]
await featureService.updateOnlineRolesWithNewValues(newRoles)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
})
it('saves new roles to storage if a role has been removed', async () => {
const newRoles = [RoleName.NAMES.CoreUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
featureService.initializeFromDisk()
await featureService.updateOnlineRolesWithNewValues(newRoles)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
})
it('role-based feature status', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.MidnightTheme).getValue(),
),
).toBe(FeatureStatus.Entitled)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.SuperEditor).getValue(),
),
).toBe(FeatureStatus.Entitled)
})
it('feature status with no paid role', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(false)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.MidnightTheme).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.PlusEditor).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.SheetsEditor).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
})
it('role-based features while not signed into first party server', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser])
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.SuperEditor).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
})
it('third party feature status', async () => {
itemManager.getDisplayableComponents = jest
.fn()
.mockReturnValue([
{ uuid: '00000000-0000-0000-0000-000000000001' },
{ uuid: '00000000-0000-0000-0000-000000000002', isExpired: true },
])
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
expect(featureService.getFeatureStatus(Uuid.create('00000000-0000-0000-0000-000000000001').getValue())).toBe(
FeatureStatus.Entitled,
)
expect(featureService.getFeatureStatus(Uuid.create('00000000-0000-0000-0000-000000000002').getValue())).toBe(
FeatureStatus.InCurrentPlanButExpired,
)
expect(featureService.getFeatureStatus(Uuid.create('00000000-0000-0000-0000-000000000003').getValue())).toBe(
FeatureStatus.NoUserSubscription,
)
})
it('feature status should be not entitled if no account or offline repo', async () => {
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.MidnightTheme).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.TokenVaultEditor).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
})
it('feature status for offline subscription', async () => {
featureService.hasFirstPartyOfflineSubscription = jest.fn().mockReturnValue(true)
featureService.setOfflineRoles([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.MidnightTheme).getValue(),
),
).toBe(FeatureStatus.Entitled)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.TokenVaultEditor).getValue(),
),
).toBe(FeatureStatus.Entitled)
})
it('feature status for deprecated feature and no subscription', async () => {
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(false)
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.DeprecatedFileSafe).getValue(),
),
).toBe(FeatureStatus.NoUserSubscription)
})
it('feature status for deprecated feature with subscription', async () => {
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
expect(
featureService.getFeatureStatus(
NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.DeprecatedFileSafe).getValue(),
),
).toBe(FeatureStatus.Entitled)
})
it('has paid subscription', async () => {
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toBeFalsy
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser])
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
})
it('has paid subscription should be true if offline repo and signed into third party server', async () => {
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
featureService.hasOfflineRepo = jest.fn().mockReturnValue(true)
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
expect(featureService.hasPaidAnyPartyOnlineOrOfflineSubscription()).toEqual(true)
})
})
describe('migrateFeatureRepoToUserSetting', () => {
it('should extract key from extension repo url and update user setting', async () => {
const extensionKey = '129b029707e3470c94a8477a437f9394'
const extensionRepoItem = new SNFeatureRepo({
uuid: '456',
content_type: ContentType.TYPES.ExtensionRepo,
content: {
url: `https://extensions.standardnotes.org/${extensionKey}`,
},
} as never)
await featureService.migrateFeatureRepoToUserSetting([extensionRepoItem])
expect(settingsService.updateSetting).toHaveBeenCalledWith(
SettingName.create(SettingName.NAMES.ExtensionKey).getValue(),
extensionKey,
true,
)
})
})
describe('downloadRemoteThirdPartyFeature', () => {
it('should not allow if identifier matches native identifier', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.standardnotes.bold-editor',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const result = await featureService.downloadRemoteThirdPartyFeature(installUrl)
expect(result).toBeUndefined()
})
it('should not allow if url matches native url', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.foo.bar',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const result = await featureService.downloadRemoteThirdPartyFeature(installUrl)
expect(result).toBeUndefined()
})
})
describe('sortRolesByHierarchy', () => {
it('should sort given roles according to role hierarchy', () => {
const sortedRoles = featureService.rolesBySorting([
RoleName.NAMES.ProUser,
RoleName.NAMES.CoreUser,
RoleName.NAMES.PlusUser,
])
expect(sortedRoles).toStrictEqual([RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser])
})
})
describe('hasMinimumRole', () => {
it('should be false if core user checks for plus role', async () => {
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.CoreUser])
const hasPlusUserRole = featureService.hasMinimumRole(RoleName.NAMES.PlusUser)
expect(hasPlusUserRole).toBe(false)
})
it('should be false if plus user checks for pro role', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.PlusUser, RoleName.NAMES.CoreUser])
const hasProUserRole = featureService.hasMinimumRole(RoleName.NAMES.ProUser)
expect(hasProUserRole).toBe(false)
})
it('should be true if pro user checks for core user', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser])
const hasCoreUserRole = featureService.hasMinimumRole(RoleName.NAMES.CoreUser)
expect(hasCoreUserRole).toBe(true)
})
it('should be true if pro user checks for pro user', async () => {
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
subscriptions.hasOnlineSubscription = jest.fn().mockReturnValue(true)
await featureService.updateOnlineRolesWithNewValues([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser])
const hasProUserRole = featureService.hasMinimumRole(RoleName.NAMES.ProUser)
expect(hasProUserRole).toBe(true)
})
})
})