Add optInFeature API
This commit is contained in:
@ -25,7 +25,7 @@ export class Feature {
|
||||
grantedAt?: Date | null
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
expiredAt?: Date | null
|
||||
expiresAt?: Date | null
|
||||
|
||||
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
|
||||
createdAt!: Date
|
||||
|
||||
@ -596,6 +596,17 @@ export type DeviceToken = {
|
||||
token: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
__typename?: 'Feature';
|
||||
createdAt: Scalars['Date'];
|
||||
expiresAt?: Maybe<Scalars['Date']>;
|
||||
grantedAt?: Maybe<Scalars['Date']>;
|
||||
id: Scalars['ID'];
|
||||
name: Scalars['String'];
|
||||
token: Scalars['String'];
|
||||
updatedAt: Scalars['Date'];
|
||||
};
|
||||
|
||||
export type FeedArticle = {
|
||||
__typename?: 'FeedArticle';
|
||||
annotationsCount?: Maybe<Scalars['Int']>;
|
||||
@ -971,6 +982,7 @@ export type Mutation = {
|
||||
logOut: LogOutResult;
|
||||
mergeHighlight: MergeHighlightResult;
|
||||
moveLabel: MoveLabelResult;
|
||||
optInFeature: OptInFeatureResult;
|
||||
reportItem: ReportItemResult;
|
||||
revokeApiKey: RevokeApiKeyResult;
|
||||
saveArticleReadingProgress: SaveArticleReadingProgressResult;
|
||||
@ -1113,6 +1125,11 @@ export type MutationMoveLabelArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationOptInFeatureArgs = {
|
||||
input: OptInFeatureInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationReportItemArgs = {
|
||||
input: ReportItemInput;
|
||||
};
|
||||
@ -1281,6 +1298,27 @@ export type NewsletterEmailsSuccess = {
|
||||
newsletterEmails: Array<NewsletterEmail>;
|
||||
};
|
||||
|
||||
export type OptInFeatureError = {
|
||||
__typename?: 'OptInFeatureError';
|
||||
errorCodes: Array<OptInFeatureErrorCode>;
|
||||
};
|
||||
|
||||
export enum OptInFeatureErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
NotFound = 'NOT_FOUND'
|
||||
}
|
||||
|
||||
export type OptInFeatureInput = {
|
||||
name: Scalars['String'];
|
||||
};
|
||||
|
||||
export type OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess;
|
||||
|
||||
export type OptInFeatureSuccess = {
|
||||
__typename?: 'OptInFeatureSuccess';
|
||||
feature: Feature;
|
||||
};
|
||||
|
||||
export type Page = {
|
||||
__typename?: 'Page';
|
||||
author?: Maybe<Scalars['String']>;
|
||||
@ -2684,6 +2722,7 @@ export type ResolversTypes = {
|
||||
DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess'];
|
||||
DeleteWebhookSuccess: ResolverTypeWrapper<DeleteWebhookSuccess>;
|
||||
DeviceToken: ResolverTypeWrapper<DeviceToken>;
|
||||
Feature: ResolverTypeWrapper<Feature>;
|
||||
FeedArticle: ResolverTypeWrapper<FeedArticle>;
|
||||
FeedArticleEdge: ResolverTypeWrapper<FeedArticleEdge>;
|
||||
FeedArticlesError: ResolverTypeWrapper<FeedArticlesError>;
|
||||
@ -2755,6 +2794,11 @@ export type ResolversTypes = {
|
||||
NewsletterEmailsErrorCode: NewsletterEmailsErrorCode;
|
||||
NewsletterEmailsResult: ResolversTypes['NewsletterEmailsError'] | ResolversTypes['NewsletterEmailsSuccess'];
|
||||
NewsletterEmailsSuccess: ResolverTypeWrapper<NewsletterEmailsSuccess>;
|
||||
OptInFeatureError: ResolverTypeWrapper<OptInFeatureError>;
|
||||
OptInFeatureErrorCode: OptInFeatureErrorCode;
|
||||
OptInFeatureInput: OptInFeatureInput;
|
||||
OptInFeatureResult: ResolversTypes['OptInFeatureError'] | ResolversTypes['OptInFeatureSuccess'];
|
||||
OptInFeatureSuccess: ResolverTypeWrapper<OptInFeatureSuccess>;
|
||||
Page: ResolverTypeWrapper<Page>;
|
||||
PageInfo: ResolverTypeWrapper<PageInfo>;
|
||||
PageInfoInput: PageInfoInput;
|
||||
@ -3045,6 +3089,7 @@ export type ResolversParentTypes = {
|
||||
DeleteWebhookResult: ResolversParentTypes['DeleteWebhookError'] | ResolversParentTypes['DeleteWebhookSuccess'];
|
||||
DeleteWebhookSuccess: DeleteWebhookSuccess;
|
||||
DeviceToken: DeviceToken;
|
||||
Feature: Feature;
|
||||
FeedArticle: FeedArticle;
|
||||
FeedArticleEdge: FeedArticleEdge;
|
||||
FeedArticlesError: FeedArticlesError;
|
||||
@ -3103,6 +3148,10 @@ export type ResolversParentTypes = {
|
||||
NewsletterEmailsError: NewsletterEmailsError;
|
||||
NewsletterEmailsResult: ResolversParentTypes['NewsletterEmailsError'] | ResolversParentTypes['NewsletterEmailsSuccess'];
|
||||
NewsletterEmailsSuccess: NewsletterEmailsSuccess;
|
||||
OptInFeatureError: OptInFeatureError;
|
||||
OptInFeatureInput: OptInFeatureInput;
|
||||
OptInFeatureResult: ResolversParentTypes['OptInFeatureError'] | ResolversParentTypes['OptInFeatureSuccess'];
|
||||
OptInFeatureSuccess: OptInFeatureSuccess;
|
||||
Page: Page;
|
||||
PageInfo: PageInfo;
|
||||
PageInfoInput: PageInfoInput;
|
||||
@ -3677,6 +3726,17 @@ export type DeviceTokenResolvers<ContextType = ResolverContext, ParentType exten
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type FeatureResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Feature'] = ResolversParentTypes['Feature']> = {
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
expiresAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
grantedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
token?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
updatedAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type FeedArticleResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['FeedArticle'] = ResolversParentTypes['FeedArticle']> = {
|
||||
annotationsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
article?: Resolver<ResolversTypes['Article'], ParentType, ContextType>;
|
||||
@ -3971,6 +4031,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
||||
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
|
||||
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
|
||||
moveLabel?: Resolver<ResolversTypes['MoveLabelResult'], ParentType, ContextType, RequireFields<MutationMoveLabelArgs, 'input'>>;
|
||||
optInFeature?: Resolver<ResolversTypes['OptInFeatureResult'], ParentType, ContextType, RequireFields<MutationOptInFeatureArgs, 'input'>>;
|
||||
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
|
||||
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
|
||||
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
|
||||
@ -4023,6 +4084,20 @@ export type NewsletterEmailsSuccessResolvers<ContextType = ResolverContext, Pare
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type OptInFeatureErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureError'] = ResolversParentTypes['OptInFeatureError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['OptInFeatureErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type OptInFeatureResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureResult'] = ResolversParentTypes['OptInFeatureResult']> = {
|
||||
__resolveType: TypeResolveFn<'OptInFeatureError' | 'OptInFeatureSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type OptInFeatureSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['OptInFeatureSuccess'] = ResolversParentTypes['OptInFeatureSuccess']> = {
|
||||
feature?: Resolver<ResolversTypes['Feature'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type PageResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Page'] = ResolversParentTypes['Page']> = {
|
||||
author?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
@ -4835,6 +4910,7 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
DeleteWebhookResult?: DeleteWebhookResultResolvers<ContextType>;
|
||||
DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers<ContextType>;
|
||||
DeviceToken?: DeviceTokenResolvers<ContextType>;
|
||||
Feature?: FeatureResolvers<ContextType>;
|
||||
FeedArticle?: FeedArticleResolvers<ContextType>;
|
||||
FeedArticleEdge?: FeedArticleEdgeResolvers<ContextType>;
|
||||
FeedArticlesError?: FeedArticlesErrorResolvers<ContextType>;
|
||||
@ -4885,6 +4961,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
NewsletterEmailsError?: NewsletterEmailsErrorResolvers<ContextType>;
|
||||
NewsletterEmailsResult?: NewsletterEmailsResultResolvers<ContextType>;
|
||||
NewsletterEmailsSuccess?: NewsletterEmailsSuccessResolvers<ContextType>;
|
||||
OptInFeatureError?: OptInFeatureErrorResolvers<ContextType>;
|
||||
OptInFeatureResult?: OptInFeatureResultResolvers<ContextType>;
|
||||
OptInFeatureSuccess?: OptInFeatureSuccessResolvers<ContextType>;
|
||||
Page?: PageResolvers<ContextType>;
|
||||
PageInfo?: PageInfoResolvers<ContextType>;
|
||||
Profile?: ProfileResolvers<ContextType>;
|
||||
|
||||
@ -524,6 +524,16 @@ type DeviceToken {
|
||||
token: String!
|
||||
}
|
||||
|
||||
type Feature {
|
||||
createdAt: Date!
|
||||
expiresAt: Date
|
||||
grantedAt: Date
|
||||
id: ID!
|
||||
name: String!
|
||||
token: String!
|
||||
updatedAt: Date!
|
||||
}
|
||||
|
||||
type FeedArticle {
|
||||
annotationsCount: Int
|
||||
article: Article!
|
||||
@ -865,6 +875,7 @@ type Mutation {
|
||||
logOut: LogOutResult!
|
||||
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
|
||||
moveLabel(input: MoveLabelInput!): MoveLabelResult!
|
||||
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
|
||||
reportItem(input: ReportItemInput!): ReportItemResult!
|
||||
revokeApiKey(id: ID!): RevokeApiKeyResult!
|
||||
saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult!
|
||||
@ -917,6 +928,25 @@ type NewsletterEmailsSuccess {
|
||||
newsletterEmails: [NewsletterEmail!]!
|
||||
}
|
||||
|
||||
type OptInFeatureError {
|
||||
errorCodes: [OptInFeatureErrorCode!]!
|
||||
}
|
||||
|
||||
enum OptInFeatureErrorCode {
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
input OptInFeatureInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
union OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess
|
||||
|
||||
type OptInFeatureSuccess {
|
||||
feature: Feature!
|
||||
}
|
||||
|
||||
type Page {
|
||||
author: String
|
||||
createdAt: Date!
|
||||
|
||||
60
packages/api/src/resolvers/features/index.ts
Normal file
60
packages/api/src/resolvers/features/index.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import {
|
||||
MutationOptInFeatureArgs,
|
||||
OptInFeatureError,
|
||||
OptInFeatureErrorCode,
|
||||
OptInFeatureSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import {
|
||||
getFeatureName,
|
||||
optInFeature,
|
||||
signFeatureToken,
|
||||
} from '../../services/features'
|
||||
|
||||
export const optInFeatureResolver = authorized<
|
||||
OptInFeatureSuccess,
|
||||
OptInFeatureError,
|
||||
MutationOptInFeatureArgs
|
||||
>(async (_, { input: { name } }, { claims, log }) => {
|
||||
log.info('Opting in to a feature', {
|
||||
feature: name,
|
||||
labels: {
|
||||
source: 'resolver',
|
||||
resolver: 'optInFeatureResolver',
|
||||
uid: claims.uid,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const featureName = getFeatureName(name)
|
||||
if (!featureName) {
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const optIn = await optInFeature(featureName, claims.uid)
|
||||
if (!optIn) {
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
const token = signFeatureToken(optIn)
|
||||
|
||||
return {
|
||||
feature: {
|
||||
...optIn,
|
||||
token,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Error opting in to a feature', {
|
||||
error: e,
|
||||
})
|
||||
|
||||
return {
|
||||
errorCodes: [OptInFeatureErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -5,8 +5,8 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { createReactionResolver, deleteReactionResolver } from './reaction'
|
||||
import { Claims, WithDataSourcesContext } from './types'
|
||||
import { createImageProxyUrl } from './../utils/imageproxy'
|
||||
import { userDataToUser, validatedDate } from './../utils/helpers'
|
||||
import { createImageProxyUrl } from '../utils/imageproxy'
|
||||
import { userDataToUser, validatedDate } from '../utils/helpers'
|
||||
|
||||
import {
|
||||
Article,
|
||||
@ -18,7 +18,7 @@ import {
|
||||
Reaction,
|
||||
SearchItem,
|
||||
User,
|
||||
} from './../generated/graphql'
|
||||
} from '../generated/graphql'
|
||||
|
||||
import {
|
||||
addPopularReadResolver,
|
||||
@ -101,6 +101,7 @@ import {
|
||||
} from '../utils/uploads'
|
||||
import { getPageByParam } from '../elastic/pages'
|
||||
import { recentSearchesResolver } from './recent_searches'
|
||||
import { optInFeatureResolver } from './features'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
@ -171,6 +172,7 @@ export const functionResolvers = {
|
||||
moveLabel: moveLabelResolver,
|
||||
setIntegration: setIntegrationResolver,
|
||||
deleteIntegration: deleteIntegrationResolver,
|
||||
optInFeature: optInFeatureResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
@ -607,4 +609,5 @@ export const functionResolvers = {
|
||||
...resultResolveTypeResolver('Integrations'),
|
||||
...resultResolveTypeResolver('DeleteIntegration'),
|
||||
...resultResolveTypeResolver('RecentSearches'),
|
||||
...resultResolveTypeResolver('OptInFeature'),
|
||||
}
|
||||
|
||||
@ -1912,6 +1912,35 @@ const schema = gql`
|
||||
BAD_REQUEST
|
||||
}
|
||||
|
||||
input OptInFeatureInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
union OptInFeatureResult = OptInFeatureSuccess | OptInFeatureError
|
||||
|
||||
type OptInFeatureSuccess {
|
||||
feature: Feature!
|
||||
}
|
||||
|
||||
type Feature {
|
||||
id: ID!
|
||||
name: String!
|
||||
token: String!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
grantedAt: Date
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
type OptInFeatureError {
|
||||
errorCodes: [OptInFeatureErrorCode!]!
|
||||
}
|
||||
|
||||
enum OptInFeatureErrorCode {
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
@ -1983,6 +2012,7 @@ const schema = gql`
|
||||
moveLabel(input: MoveLabelInput!): MoveLabelResult!
|
||||
setIntegration(input: SetIntegrationInput!): SetIntegrationResult!
|
||||
deleteIntegration(id: ID!): DeleteIntegrationResult!
|
||||
optInFeature(input: OptInFeatureInput!): OptInFeatureResult!
|
||||
}
|
||||
|
||||
# FIXME: remove sort from feedArticles after all cached tabs are closed
|
||||
|
||||
67
packages/api/src/services/features.ts
Normal file
67
packages/api/src/services/features.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Feature } from '../entity/feature'
|
||||
import { getRepository } from '../entity/utils'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { env } from '../env'
|
||||
import { IsNull, Not } from 'typeorm'
|
||||
|
||||
enum FeatureName {
|
||||
UltraRealisticVoice = 'ultra-realistic-voice',
|
||||
}
|
||||
|
||||
export const getFeatureName = (name: string): FeatureName | undefined => {
|
||||
return Object.values(FeatureName).find((v) => v === name)
|
||||
}
|
||||
|
||||
export const optInFeature = async (
|
||||
name: FeatureName,
|
||||
uid: string
|
||||
): Promise<Feature | undefined> => {
|
||||
if (name === FeatureName.UltraRealisticVoice) {
|
||||
return optInUltraRealisticVoice(uid)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const optInUltraRealisticVoice = async (uid: string): Promise<Feature> => {
|
||||
const feature = await getRepository(Feature).findOneBy({
|
||||
user: { id: uid },
|
||||
name: FeatureName.UltraRealisticVoice,
|
||||
})
|
||||
if (feature) {
|
||||
// already opted in
|
||||
console.log('already opted in')
|
||||
return feature
|
||||
}
|
||||
|
||||
// opt in to feature for the first 1000 users
|
||||
const count = await getRepository(Feature).countBy({
|
||||
name: FeatureName.UltraRealisticVoice,
|
||||
grantedAt: Not(IsNull()),
|
||||
})
|
||||
|
||||
let grantedAt: Date | null = new Date()
|
||||
if (count >= 1000) {
|
||||
console.log('feature limit reached')
|
||||
grantedAt = null
|
||||
}
|
||||
|
||||
return getRepository(Feature).save({
|
||||
user: { id: uid },
|
||||
name: FeatureName.UltraRealisticVoice,
|
||||
grantedAt,
|
||||
})
|
||||
}
|
||||
|
||||
export const signFeatureToken = (feature: Feature): string => {
|
||||
return jwt.sign(
|
||||
{
|
||||
userid: feature.user.id,
|
||||
feature_name: feature.name,
|
||||
createdat: feature.createdAt.getTime(),
|
||||
expiresat: feature.expiresAt?.getTime(),
|
||||
grantedat: feature.grantedAt?.getTime(),
|
||||
},
|
||||
env.server.jwtSecret
|
||||
)
|
||||
}
|
||||
196
packages/api/test/resolvers/features.test.ts
Normal file
196
packages/api/test/resolvers/features.test.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { User } from '../../src/entity/user'
|
||||
import { createTestUser, deleteTestUser } from '../db'
|
||||
import { graphqlRequest, request } from '../util'
|
||||
import { getRepository } from '../../src/entity/utils'
|
||||
import { Feature } from '../../src/entity/feature'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import sinon, { SinonFakeTimers } from 'sinon'
|
||||
import { env } from '../../src/env'
|
||||
import { Like } from 'typeorm'
|
||||
|
||||
describe('features resolvers', () => {
|
||||
let loginUser: User
|
||||
let authToken: string
|
||||
|
||||
before(async () => {
|
||||
// create test user and login
|
||||
loginUser = await createTestUser('loginUser')
|
||||
const res = await request
|
||||
.post('/local/debug/fake-user-login')
|
||||
.send({ fakeEmail: loginUser.email })
|
||||
|
||||
authToken = res.body.authToken
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await deleteTestUser(loginUser.name)
|
||||
})
|
||||
|
||||
describe('optInFeature API', () => {
|
||||
const feature = 'ultra-realistic-voice'
|
||||
const now = new Date()
|
||||
let clock: SinonFakeTimers
|
||||
|
||||
const query = (name: string) => `
|
||||
mutation {
|
||||
optInFeature(input: {
|
||||
name: "${name}"
|
||||
}) {
|
||||
... on OptInFeatureSuccess {
|
||||
feature {
|
||||
name
|
||||
grantedAt
|
||||
token
|
||||
}
|
||||
}
|
||||
... on OptInFeatureError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
beforeEach(() => {
|
||||
// mock date
|
||||
clock = sinon.useFakeTimers(now.getTime())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
context('when user is the first 1000 users', () => {
|
||||
after(async () => {
|
||||
// reset feature
|
||||
await getRepository(Feature).delete({
|
||||
user: { id: loginUser.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('opts in to the feature', async () => {
|
||||
const res = await graphqlRequest(query(feature), authToken).expect(200)
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userid: loginUser.id,
|
||||
feature_name: feature,
|
||||
createdat: now.getTime(),
|
||||
grantedat: now.getTime(),
|
||||
},
|
||||
env.server.jwtSecret
|
||||
)
|
||||
|
||||
expect(res.body.data.optInFeature).to.eql({
|
||||
feature: {
|
||||
name: feature,
|
||||
// set milliseconds to 000
|
||||
grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'),
|
||||
token,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('when user is not the first 1000 users', () => {
|
||||
before(async () => {
|
||||
// create 1000 opt-in users
|
||||
const usersToSave = Array.from(Array(1000).keys()).map((i) => {
|
||||
return {
|
||||
name: `user${i}`,
|
||||
source: 'GOOGLE',
|
||||
sourceUserId: `fake-user-id-user${i}`,
|
||||
email: `user${i}@omnivore.app`,
|
||||
username: `user${i}`,
|
||||
bio: `i am user${i}`,
|
||||
}
|
||||
})
|
||||
|
||||
const users = await getRepository(User).save(usersToSave)
|
||||
|
||||
const features = users.map((user) => {
|
||||
return {
|
||||
user: { id: user.id },
|
||||
name: feature,
|
||||
grantedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await getRepository(Feature).save(features)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// reset opt-in users
|
||||
await getRepository(User).delete({
|
||||
name: Like(`user%`),
|
||||
})
|
||||
await getRepository(Feature).delete({
|
||||
name: feature,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not opt in to the feature', async () => {
|
||||
const res = await graphqlRequest(query(feature), authToken).expect(200)
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userid: loginUser.id,
|
||||
feature_name: feature,
|
||||
createdat: now.getTime(),
|
||||
grantedat: now.getTime(),
|
||||
},
|
||||
env.server.jwtSecret
|
||||
)
|
||||
|
||||
expect(res.body.data.optInFeature).to.eql({
|
||||
feature: {
|
||||
name: feature,
|
||||
grantedAt: null,
|
||||
token,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('when user is already opted in', () => {
|
||||
before(async () => {
|
||||
// opt in
|
||||
await getRepository(Feature).save({
|
||||
user: { id: loginUser.id },
|
||||
name: feature,
|
||||
grantedAt: new Date(),
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
// reset feature
|
||||
await getRepository(Feature).delete({
|
||||
user: { id: loginUser.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the feature', async () => {
|
||||
const res = await graphqlRequest(query(feature), authToken).expect(200)
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userid: loginUser.id,
|
||||
feature_name: feature,
|
||||
createdat: now.getTime(),
|
||||
grantedat: now.getTime(),
|
||||
},
|
||||
env.server.jwtSecret
|
||||
)
|
||||
|
||||
expect(res.body.data.optInFeature).to.eql({
|
||||
feature: {
|
||||
name: feature,
|
||||
grantedAt: now.toISOString().replace(/\.\d{3}Z$/, '.000Z'),
|
||||
token,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS omnivore.features (
|
||||
user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
granted_at timestamptz,
|
||||
expired_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
UNIQUE (user_id, name)
|
||||
|
||||
Reference in New Issue
Block a user