add setLabels api and tests

This commit is contained in:
Hongbo Wu 2022-02-22 17:00:15 +08:00
parent 138c9682cd
commit d33b213092
10 changed files with 333 additions and 28 deletions

View file

@ -4,13 +4,10 @@ import {
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { User } from './user'
import { Link } from './link'
@Entity({ name: 'labels' })
export class Label extends BaseEntity {
@ -24,10 +21,6 @@ export class Label extends BaseEntity {
@JoinColumn({ name: 'user_id' })
user!: User
@ManyToMany(() => Link, (link) => link.labels)
@JoinTable({ name: 'link_labels' })
link?: Link
@Column('text')
color!: string

View file

@ -63,7 +63,11 @@ export class Link extends BaseEntity {
@UpdateDateColumn()
updatedAt?: Date
@ManyToMany(() => Label, (label) => label.link)
@JoinTable({ name: 'link_labels' })
@ManyToMany(() => Label)
@JoinTable({
name: 'link_labels',
joinColumn: { name: 'link_id' },
inverseJoinColumn: { name: 'label_id' },
})
labels?: Label[]
}

View file

@ -0,0 +1,27 @@
import {
BaseEntity,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Link } from './link'
import { Label } from './label'
@Entity({ name: 'link_labels' })
export class LinkLabel extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => Link)
@JoinColumn({ name: 'link_id' })
link!: Link
@ManyToOne(() => Label)
@JoinColumn({ name: 'label_id' })
label!: Label
@CreateDateColumn()
createdAt!: Date
}

View file

@ -778,6 +778,7 @@ export type Mutation = {
setBookmarkArticle: SetBookmarkArticleResult;
setDeviceToken: SetDeviceTokenResult;
setFollow: SetFollowResult;
setLabels: SetLabelsResult;
setLinkArchived: ArchiveLinkResult;
setShareArticle: SetShareArticleResult;
setShareHighlight: SetShareHighlightResult;
@ -919,6 +920,11 @@ export type MutationSetFollowArgs = {
};
export type MutationSetLabelsArgs = {
input: SetLabelsInput;
};
export type MutationSetLinkArchivedArgs = {
input: ArchiveLinkInput;
};
@ -1350,6 +1356,29 @@ export type SetFollowSuccess = {
updatedUser: User;
};
export type SetLabelsError = {
__typename?: 'SetLabelsError';
errorCodes: Array<SetLabelsErrorCode>;
};
export enum SetLabelsErrorCode {
BadRequest = 'BAD_REQUEST',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED'
}
export type SetLabelsInput = {
labelIds: Array<Scalars['ID']>;
linkId: Scalars['ID'];
};
export type SetLabelsResult = SetLabelsError | SetLabelsSuccess;
export type SetLabelsSuccess = {
__typename?: 'SetLabelsSuccess';
labels: Array<Label>;
};
export type SetShareArticleError = {
__typename?: 'SetShareArticleError';
errorCodes: Array<SetShareArticleErrorCode>;
@ -2014,6 +2043,11 @@ export type ResolversTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversTypes['SetFollowError'] | ResolversTypes['SetFollowSuccess'];
SetFollowSuccess: ResolverTypeWrapper<SetFollowSuccess>;
SetLabelsError: ResolverTypeWrapper<SetLabelsError>;
SetLabelsErrorCode: SetLabelsErrorCode;
SetLabelsInput: SetLabelsInput;
SetLabelsResult: ResolversTypes['SetLabelsError'] | ResolversTypes['SetLabelsSuccess'];
SetLabelsSuccess: ResolverTypeWrapper<SetLabelsSuccess>;
SetShareArticleError: ResolverTypeWrapper<SetShareArticleError>;
SetShareArticleErrorCode: SetShareArticleErrorCode;
SetShareArticleInput: SetShareArticleInput;
@ -2250,6 +2284,10 @@ export type ResolversParentTypes = {
SetFollowInput: SetFollowInput;
SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess'];
SetFollowSuccess: SetFollowSuccess;
SetLabelsError: SetLabelsError;
SetLabelsInput: SetLabelsInput;
SetLabelsResult: ResolversParentTypes['SetLabelsError'] | ResolversParentTypes['SetLabelsSuccess'];
SetLabelsSuccess: SetLabelsSuccess;
SetShareArticleError: SetShareArticleError;
SetShareArticleInput: SetShareArticleInput;
SetShareArticleResult: ResolversParentTypes['SetShareArticleError'] | ResolversParentTypes['SetShareArticleSuccess'];
@ -2878,6 +2916,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setBookmarkArticle?: Resolver<ResolversTypes['SetBookmarkArticleResult'], ParentType, ContextType, RequireFields<MutationSetBookmarkArticleArgs, 'input'>>;
setDeviceToken?: Resolver<ResolversTypes['SetDeviceTokenResult'], ParentType, ContextType, RequireFields<MutationSetDeviceTokenArgs, 'input'>>;
setFollow?: Resolver<ResolversTypes['SetFollowResult'], ParentType, ContextType, RequireFields<MutationSetFollowArgs, 'input'>>;
setLabels?: Resolver<ResolversTypes['SetLabelsResult'], ParentType, ContextType, RequireFields<MutationSetLabelsArgs, 'input'>>;
setLinkArchived?: Resolver<ResolversTypes['ArchiveLinkResult'], ParentType, ContextType, RequireFields<MutationSetLinkArchivedArgs, 'input'>>;
setShareArticle?: Resolver<ResolversTypes['SetShareArticleResult'], ParentType, ContextType, RequireFields<MutationSetShareArticleArgs, 'input'>>;
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
@ -3084,6 +3123,20 @@ export type SetFollowSuccessResolvers<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetLabelsErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsError'] = ResolversParentTypes['SetLabelsError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetLabelsErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetLabelsResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsResult'] = ResolversParentTypes['SetLabelsResult']> = {
__resolveType: TypeResolveFn<'SetLabelsError' | 'SetLabelsSuccess', ParentType, ContextType>;
};
export type SetLabelsSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetLabelsSuccess'] = ResolversParentTypes['SetLabelsSuccess']> = {
labels?: Resolver<Array<ResolversTypes['Label']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SetShareArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SetShareArticleError'] = ResolversParentTypes['SetShareArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SetShareArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3460,6 +3513,9 @@ export type Resolvers<ContextType = ResolverContext> = {
SetFollowError?: SetFollowErrorResolvers<ContextType>;
SetFollowResult?: SetFollowResultResolvers<ContextType>;
SetFollowSuccess?: SetFollowSuccessResolvers<ContextType>;
SetLabelsError?: SetLabelsErrorResolvers<ContextType>;
SetLabelsResult?: SetLabelsResultResolvers<ContextType>;
SetLabelsSuccess?: SetLabelsSuccessResolvers<ContextType>;
SetShareArticleError?: SetShareArticleErrorResolvers<ContextType>;
SetShareArticleResult?: SetShareArticleResultResolvers<ContextType>;
SetShareArticleSuccess?: SetShareArticleSuccessResolvers<ContextType>;

View file

@ -691,6 +691,7 @@ type Mutation {
setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult!
setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult!
setFollow(input: SetFollowInput!): SetFollowResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult!
setShareArticle(input: SetShareArticleInput!): SetShareArticleResult!
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
@ -989,6 +990,27 @@ type SetFollowSuccess {
updatedUser: User!
}
type SetLabelsError {
errorCodes: [SetLabelsErrorCode!]!
}
enum SetLabelsErrorCode {
BAD_REQUEST
NOT_FOUND
UNAUTHORIZED
}
input SetLabelsInput {
labelIds: [ID!]!
linkId: ID!
}
union SetLabelsResult = SetLabelsError | SetLabelsSuccess
type SetLabelsSuccess {
labels: [Label!]!
}
type SetShareArticleError {
errorCodes: [SetShareArticleErrorCode!]!
}

View file

@ -59,6 +59,7 @@ import {
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFollowResolver,
setLabelsResolver,
setLinkArchivedResolver,
setShareArticleResolver,
setShareHighlightResolver,
@ -135,6 +136,7 @@ export const functionResolvers = {
deleteLabel: deleteLabelResolver,
login: loginResolver,
signup: signupResolver,
setLabels: setLabelsResolver,
},
Query: {
me: getMeUserResolver,
@ -525,4 +527,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('DeleteLabel'),
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('Signup'),
...resultResolveTypeResolver('SetLabels'),
}

View file

@ -11,6 +11,10 @@ import {
LabelsSuccess,
MutationCreateLabelArgs,
MutationDeleteLabelArgs,
MutationSetLabelsArgs,
SetLabelsError,
SetLabelsErrorCode,
SetLabelsSuccess,
} from '../../generated/graphql'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
@ -18,6 +22,8 @@ import { User } from '../../entity/user'
import { Label } from '../../entity/label'
import { getManager, getRepository } from 'typeorm'
import { setClaims } from '../../entity/utils'
import { Link } from '../../entity/link'
import { LinkLabel } from '../../entity/link_label'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { claims: { uid }, log }) => {
@ -172,3 +178,70 @@ export const deleteLabelResolver = authorized<
}
}
})
export const setLabelsResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsArgs
>(async (_, { input }, { claims: { uid }, log }) => {
log.info('setLabelsResolver')
const { linkId, labelIds } = input
try {
const user = await getRepository(User).findOne(uid)
if (!user) {
return {
errorCodes: [SetLabelsErrorCode.Unauthorized],
}
}
const link = await getRepository(Link).findOne(linkId)
if (!link) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
const labels = await getRepository(Label).findByIds(labelIds, {
where: {
user,
},
relations: ['user'],
})
if (labels.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
// delete all existing labels of the link
await getManager().transaction(async (t) => {
await t.getRepository(LinkLabel).delete({ link })
// add new labels
await t
.getRepository(LinkLabel)
.save(labels.map((label) => ({ link, label })))
})
analytics.track({
userId: uid,
event: 'setLabels',
properties: {
linkId,
labelIds,
env: env.server.apiEnv,
},
})
return {
labels,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
})

View file

@ -1326,6 +1326,27 @@ const schema = gql`
union SignupResult = SignupSuccess | SignupError
input SetLabelsInput {
linkId: ID!
labelIds: [ID!]!
}
union SetLabelsResult = SetLabelsSuccess | SetLabelsError
type SetLabelsSuccess {
labels: [Label!]!
}
type SetLabelsError {
errorCodes: [SetLabelsErrorCode!]!
}
enum SetLabelsErrorCode {
UNAUTHORIZED
BAD_REQUEST
NOT_FOUND
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -1384,6 +1405,7 @@ const schema = gql`
deleteLabel(id: ID!): DeleteLabelResult!
login(input: LoginInput!): LoginResult!
signup(input: SignupInput!): SignupResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
}
# FIXME: remove sort from feedArticles after all cahced tabs are closed

View file

@ -14,6 +14,7 @@ import { Link } from '../src/entity/link'
import { Reminder } from '../src/entity/reminder'
import { NewsletterEmail } from '../src/entity/newsletter_email'
import { UserDeviceToken } from '../src/entity/user_device_tokens'
import { Label } from '../src/entity/label'
const runMigrations = async () => {
const migrationDirectory = __dirname + '/../../db/migrations'
@ -200,3 +201,17 @@ export const getUser = async (id: string): Promise<User | undefined> => {
export const getLink = async (id: string): Promise<Link | undefined> => {
return getRepository(Link).findOne(id)
}
export const createTestLabel = async (
user: User,
name: string,
color: string
): Promise<Label> => {
return getRepository(Label)
.create({
user: user,
name: name,
color: color,
})
.save()
}

View file

@ -1,4 +1,5 @@
import {
createTestLabel,
createTestLink,
createTestPage,
createTestUser,
@ -11,10 +12,13 @@ import { expect } from 'chai'
import { Page } from '../../src/entity/page'
import { getRepository } from 'typeorm'
import 'mocha'
import { LinkLabel } from '../../src/entity/link_label'
import { User } from '../../src/entity/user'
describe('Labels API', () => {
const username = 'fakeUser'
let user: User
let authToken: string
let page: Page
let link: Link
@ -22,31 +26,30 @@ describe('Labels API', () => {
before(async () => {
// create test user and login
const user = await createTestUser(username)
user = await createTestUser(username)
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
authToken = res.body.authToken
// create test label
// create testing labels
const label1 = await createTestLabel(user, 'label_1', '#ffffff')
const label2 = await createTestLabel(user, 'label_2', '#eeeeee')
labels = [label1, label2]
page = await createTestPage()
link = await createTestLink(user, page)
const label1 = await getRepository(Label)
.create({
name: 'label1',
user: user,
color: '#ffffff',
})
.save()
const label2 = await getRepository(Label)
.create({
name: 'label2',
user: user,
color: '#eeeeee',
})
.save()
labels = [label1, label2]
const existingLabelOfLink = await createTestLabel(
user,
'different_label',
'#dddddd'
)
// set another label to link
await getRepository(LinkLabel).save({
link,
label: existingLabelOfLink,
})
})
after(async () => {
@ -81,6 +84,7 @@ describe('Labels API', () => {
it('should return labels', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
const labels = await getRepository(Label).find({ where: { user } })
expect(res.body.data.labels.labels).to.eql(
labels.map((label) => ({
id: label.id,
@ -200,8 +204,9 @@ describe('Labels API', () => {
})
context('when label exists', () => {
before(() => {
labelId = labels[0].id
before(async () => {
const toDeleteLabel = await createTestLabel(user, 'label4', '#ffffff')
labelId = toDeleteLabel.id
})
it('should delete label', async () => {
@ -237,4 +242,89 @@ describe('Labels API', () => {
return graphqlRequest(query, invalidAuthToken).expect(500)
})
})
describe('Set labels', () => {
let query: string
let linkId: string
let labelIds: string[] = []
beforeEach(() => {
query = `
mutation {
setLabels(
input: {
linkId: "${linkId}",
labelIds: [
"${labelIds[0]}",
"${labelIds[1]}"
]
}
) {
... on SetLabelsSuccess {
labels {
id
name
}
}
... on SetLabelsError {
errorCodes
}
}
}
`
})
context('when labels exists', () => {
before(() => {
linkId = link.id
labelIds = [labels[0].id, labels[1].id]
})
it('should set labels', async () => {
await graphqlRequest(query, authToken).expect(200)
const link = await getRepository(Link).findOne(linkId, {
relations: ['labels'],
})
expect(link?.labels?.map((l) => l.id)).to.eql(labelIds)
})
})
context('when labels not exist', () => {
before(() => {
linkId = link.id
labelIds = [generateFakeUuid(), generateFakeUuid()]
})
it('should return error code NOT_FOUND', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setLabels.errorCodes).to.eql(['NOT_FOUND'])
})
})
context('when link not exist', () => {
before(() => {
linkId = generateFakeUuid()
labelIds = [labels[0].id, labels[1].id]
})
it('should return error code NOT_FOUND', async () => {
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setLabels.errorCodes).to.eql(['NOT_FOUND'])
})
})
it('responds status code 400 when invalid query', async () => {
const invalidQuery = `
mutation {
setLabels {}
}
`
return graphqlRequest(invalidQuery, authToken).expect(400)
})
it('responds status code 500 when invalid user', async () => {
const invalidAuthToken = 'Fake token'
return graphqlRequest(query, invalidAuthToken).expect(500)
})
})
})