[ PART 10 ] Create a Twitter clone with GraphQL, Typescript, and React ( comment & retweet )
Hi everyone ;).
As a reminder, I'm doing this challenge ;): Tweeter challenge
We already can add comment and retweets as we have a parent_id field in our tweets table as well as a type field of "tweet | retweet | comment". However, I just noticed ( from using Twitter :D ) that we should not have the possibility to retweet multiple times the same tweet :D. So maybe it will be a good idea to check that in our addTweet method:
First of all, I added two enum Classes to match what I've done in the database schema:
src/entities/Tweet.ts
export enum TweetTypeEnum {
TWEET = 'tweet',
RETWEET = 'retweet',
COMMENT = 'comment',
}
export enum TweetVisibilityEnum {
PUBLIC = 'public',
FOLLOWERS = 'followers',
}
Then I will complete the validation rules when we add a tweet. After some struggle with the @ValidateIf() validator, I found out that was because, with Typegraphql, the option skipMissingProperties is set to false. For now, let's change it to true to make my validation rules work.
src/server.ts
export const schema = async () => {
return await buildSchema({
resolvers: [AuthResolver, TweetResolver, LikeResolver],
authChecker: authChecker,
validate: {
skipMissingProperties: false, // set false instead of true
},
})
}
I could do it differently, for example, by forcing to have the parent_id and the type always present in the AddTweetPayload. But for now, let's do it this way. I can change it later if I have some issues. I will write some tests anyway to help to refactor if necessary ;).
So, let's take a look at the AddTweetPayload now:
src/dto/AddTweetPayload.ts
import { IsIn, IsNotEmpty, MinLength, ValidateIf } from 'class-validator'
import { Field, InputType, Int } from 'type-graphql'
import { TweetTypeEnum } from '../entities/Tweet'
@InputType()
class AddTweetPayload {
@Field()
@IsNotEmpty()
@MinLength(2)
body: string
@Field(() => Int, { nullable: true })
@ValidateIf((o) => o.type !== undefined)
@IsNotEmpty()
parent_id?: number
@Field(() => String, { nullable: true })
@ValidateIf((o) => o.parent_id !== undefined)
@IsIn([TweetTypeEnum.COMMENT, TweetTypeEnum.RETWEET])
type?: TweetTypeEnum
@Field(() => String, { nullable: true })
visibility?: string
}
export default AddTweetPayload
If a type is sent, it should have a parent_id meaning that it's a retweet or a comment. In the same way, if I have a parent_id in the payload, the type should be either "comment" or "retweet". And to avoid retweeting a tweet that we have already retweeted, I will check that directly in the resolver. We can also check that the Tweet with the id of parent_id exists.
src/resolvers/TweetResolver.ts
@Mutation(() => Tweet)
@Authorized()
async addTweet(
@Arg('payload') payload: AddTweetPayload,
@Ctx() ctx: MyContext
) {
const { db, userId } = ctx
// Maybe I should add a mutation to handle the retweet?
// For the comment, we can comment as much as we want so I could
// still add the comment here.
// Feel free to share your opinion ;)
if (payload.type === TweetTypeEnum.RETWEET && payload.parent_id) {
const [alreadyRetweeted] = await db('tweets').where({
parent_id: payload.parent_id,
type: TweetTypeEnum.RETWEET,
user_id: userId,
})
if (alreadyRetweeted) {
throw new ApolloError('You already retweeted that tweet')
}
}
try {
const [tweet] = await db('tweets')
.insert({
...payload,
user_id: userId,
})
.returning('*')
return tweet
} catch (e) {
throw new ApolloError(e.message)
}
}
Let's write some tests to see if I didn't break anything ;). As it was my first time using the class-validator library, I decided to write more tests to verify the different scenarios that could go wrong ;).
src/tests/tweets.test.ts
it('should insert a comment', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
type: 'comment',
parent_id: tweet.id,
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(2)
expect(res.data.addTweet.body).toEqual('Bouh')
expect(res.data.addTweet.type).toEqual('comment')
expect(res.data.addTweet.parent_id).toEqual(tweet.id)
expect(res.errors).toBeUndefined()
})
it('should insert a retweet', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
type: 'retweet',
parent_id: tweet.id,
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(2)
expect(res.data.addTweet.body).toEqual('Bouh')
expect(res.data.addTweet.type).toEqual('retweet')
expect(res.data.addTweet.parent_id).toEqual(tweet.id)
expect(res.errors).toBeUndefined()
})
it('should not insert a comment if the type is provided but the parent_id is not provided', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
type: 'comment',
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(1)
expect(res.errors).not.toBeUndefined()
const {
extensions: {
exception: { validationErrors },
},
}: any = res.errors![0]
expect((validationErrors[0] as ValidationError).constraints).toEqual({
isNotEmpty: 'parent_id should not be empty',
})
})
it('should not insert a comment if the parent_id is provided but the type is not provided', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
parent_id: tweet.id,
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(1)
expect(res.errors).not.toBeUndefined()
const {
extensions: {
exception: { validationErrors },
},
}: any = res.errors![0]
expect((validationErrors[0] as ValidationError).constraints).toEqual({
isIn: 'type must be one of the following values: comment,retweet',
})
})
it('should not insert a retweet if the type is provided but not the parent_id', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
type: 'retweet',
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(1)
expect(res.errors).not.toBeUndefined()
const {
extensions: {
exception: { validationErrors },
},
}: any = res.errors![0]
expect((validationErrors[0] as ValidationError).constraints).toEqual({
isNotEmpty: 'parent_id should not be empty',
})
})
it('should not insert a retweet if the parent_id is provided but not the type', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
parent_id: tweet.id,
},
},
})
const tweets = await db('tweets')
expect(tweets.length).toEqual(1)
expect(res.errors).not.toBeUndefined()
const {
extensions: {
exception: { validationErrors },
},
}: any = res.errors![0]
expect((validationErrors[0] as ValidationError).constraints).toEqual({
isIn: 'type must be one of the following values: comment,retweet',
})
})
it('should not insert a retweet if the user already retweeted the tweet', async () => {
const user = await createUser()
const tweet = await createTweet(user)
const retweet = await createTweet(
user,
'test',
'retweet',
'public',
tweet.id
)
const { mutate } = await testClient({
req: {
headers: {
authorization: 'Bearer ' + generateToken(user),
},
},
})
const res = await mutate({
mutation: ADD_TWEET,
variables: {
payload: {
body: 'Bouh',
type: 'retweet',
parent_id: tweet.id,
},
},
})
expect(res.errors).not.toBeUndefined()
expect(res.errors![0].message).toEqual('You already retweeted that tweet')
})
Everything is green ;). Let's move to the next part. We should talk a little about Github Workflows.
See you in the next part ;).
Take care ;).