[ PART 10 ] Create a Twitter clone with GraphQL, Typescript, and React ( comment & retweet )

[ 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

Github repository

Db diagram

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')
  })

Tests results for retweets and comments

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 ;).