DEV Community

loading...
Cover image for [ PART 7 ] Creating a Twitter clone with GraphQL, Typescript, and React ( add/delete a tweet )

[ PART 7 ] Creating a Twitter clone with GraphQL, Typescript, and React ( add/delete a tweet )

ipscodingchallenge profile image ips-coding-challenge ・4 min read

Github Repository

PS: I've got an error that I don't quite understand. Look at the section Do you have an idea? ;) on the delete tweet part ;).

Adding a tweet

Hi everyone ;). Now let's look at how to insert a tweet.

src/resolvers/TweetResolver

@Mutation(() => Tweet)
@Authorized()
async addTweet(
    @Arg('payload') payload: AddTweetPayload,
    @Ctx() ctx: MyContext
  ) {
    const { db, userId } = ctx

    try {
      const [tweet] = await db('tweets')
        .insert({
          ...payload,
          user_id: userId,
        })
        .returning('*')

      return tweet
    } catch (e) {
      throw new ApolloError(e.message)
    }
  }
Enter fullscreen mode Exit fullscreen mode

Nothing special here, don't forget that only an authenticated user can post a tweet and so we put the annotation @Authorized.

As for the AddTweetPayload class, here it is:

src/dto/AddTweetPayload

import { IsNotEmpty, MinLength } from 'class-validator'
import { Field, InputType, Int } from 'type-graphql'

@InputType()
class AddTweetPayload {
  @Field()
  @IsNotEmpty()
  @MinLength(2)
  body: string

  @Field(() => Int, { nullable: true })
  parent_id?: number

  @Field(() => String, { nullable: true })
  type?: string

  @Field(() => String, { nullable: true })
  visibility?: string
}

export default AddTweetPayload

Enter fullscreen mode Exit fullscreen mode

Only the body field is necessary since we have set default values for the other fields and the user will be retrieved directly via the context.

If I try the mutation, I get this:
Add tweet mutation playground result

The only little problem here is that I'm going to have 3 SQL queries:
Add tweet mutation sql queries

I get back my authenticated user in my authChecker method and then I retrieve him via the userDataloader that we set up in the previous part with the @FieldResolver. We could modify our authChecker function to use the userDataloader as well. On the other hand, we will have to be careful to clean our cache when the user is modified or deleted ( userDataloader.clear(userId) ). I put this as an example as we haven't encountered this problem yet. So my authChecker method would look like this:

src/middlewares/authChecker.ts

export const authChecker: AuthChecker<MyContext, string> = async ({
  root,
  args,
  context,
  info,
}) => {
  const {
    db,
    req,
    dataloaders: { userDataloader }, // Get the dataloader from the context
  } = <MyContext>context

  try {
    const token = extractJwtToken(req)
    const {
      data: { id },
    }: any = jwt.verify(token, JWT_SECRET as string)

    // Modified part
    const user = await userDataloader.load(id)

    if (!user) throw new AuthenticationError('User not found')

    context.userId = user.id
    return true
  } catch (e) {
    throw e
  }
}
Enter fullscreen mode Exit fullscreen mode

We're now going to write some tests to check what we've done ;). Note that I modified the entities Tweet and User to return a number ( for the id field ) instead of the type ID because it returned a String whereas I have integers in my case ;).

src/tests/tweets.test.ts

test('it should insert a tweet', async () => {
    const user = await createUser()

    const { mutate } = await testClient({
      req: {
        headers: { authorization: 'Bearer ' + generateToken(user) },
      },
    })

    const res = await mutate({
      mutation: ADD_TWEET,
      variables: {
        payload: { body: 'First tweet' },
      },
    })

    const newTweet = await db('tweets')

    expect(newTweet.length).toEqual(1)

    expect(res.data.addTweet).not.toBeNull()
    expect(res.data.addTweet.body).toEqual('First tweet')
    expect(res.data.addTweet.user.id).toEqual(user.id)
  })

  test('it should not insert if the user is not authenticated', async () => {
    const { mutate } = await testClient()

    const res = await mutate({
      mutation: ADD_TWEET,
      variables: {
        payload: { body: 'First tweet' },
      },
    })

    const newTweet = await db('tweets')

    expect(newTweet.length).toEqual(0)

    expect(res.data).toBeNull()
    expect(res.errors![0].message).toEqual('Unauthorized')
  })

  test('it should not insert a tweet if the body is empty', async () => {
    const user = await createUser()

    const { mutate } = await testClient({
      req: {
        headers: { authorization: 'Bearer ' + generateToken(user) },
      },
    })

    const res = await mutate({
      mutation: ADD_TWEET,
      variables: {
        payload: { body: '' },
      },
    })

    const newTweet = await db('tweets')

    expect(newTweet.length).toEqual(0)

    expect(res.errors).not.toBeNull()
    expect(res.errors![0].message).toEqual('Argument Validation Error')
  })
Enter fullscreen mode Exit fullscreen mode

Add tweet tests result

Deleting a tweet

src/resolvers/TweetResolver

@Mutation(() => Int)
@Authorized()
async deleteTweet(@Arg('id') id: number, @Ctx() ctx: MyContext) {
    const { db, userId } = ctx

    try {
        const [tweet] = await db('tweets').where({
            id,
            user_id: userId,
        })

        if (!tweet) {
            throw new ApolloError('Tweet not found')
        }

        // Return the number of affected rows
        return await db('tweets').where({ id, user_id: userId }).del()
    } catch (e) {
        throw new ApolloError(e.message)
    }
}
Enter fullscreen mode Exit fullscreen mode

I retrieve the tweet with the id AND with the connected user's id to be sure that only the author of the tweet can delete his tweets ;). I decided to return the number of rows affected by the deletion here.

Here are some tests to verify that the deletion is working properly:

Do you have an idea? ;)

I have a GraphQL error that I haven't resolved yet. 'Variable "$id" of type "Int!" used in position expecting type "Float!".' The mutation wants me to pass a type Float! when I do have an Int! a priori. When I do a tweet.id typeof I have a type number. I will continue my investigations, but if you have an idea of how and why don't hesitate to enlighten me ;).

Here is the mutation in the tests which is problematic:

src/tests/queries/tweets.queries.ts

export const DELETE_TWEET = gql`
  mutation($id: Int!) { // I need to set the type to Float! to make it work
    deleteTweet(id: $id)
  }
`
Enter fullscreen mode Exit fullscreen mode

Otherwise, here are the tests for deleting a tweet:

src/tests/tweets.test.ts

it('should delete a user s tweet', async () => {
    const user = await createUser()
    const tweet = await createTweet(user, 'First tweet')

    const { mutate } = await testClient({
      req: {
        headers: { authorization: 'Bearer ' + generateToken(user) },
      },
    })

    const res = await mutate({
      mutation: DELETE_TWEET,
      variables: {
        id: tweet.id,
      },
    })

    const [deletedTweet] = await db('tweets').where({
      id: tweet.id,
      user_id: user.id,
    })

    expect(deletedTweet).toBeUndefined()

    expect(res.data.deleteTweet).toEqual(1)
  })

  it('should not delete a tweet that doesnt belong to the connected user', async () => {
    const user = await createUser()
    const another = await createUser('another', 'another@test.fr')
    const tweet = await createTweet(user, 'First tweet')

    const { mutate } = await testClient({
      req: {
        headers: { authorization: 'Bearer ' + generateToken(another) },
      },
    })

    const res = await mutate({
      mutation: DELETE_TWEET,
      variables: {
        id: tweet.id,
      },
    })

    const [deletedTweet] = await db('tweets').where({
      id: tweet.id,
      user_id: user.id,
    })

    expect(deletedTweet).not.toBeUndefined()
    expect(res.errors).not.toBeNull()
    expect(res.errors![0].message).toEqual('Tweet not found')
  })
Enter fullscreen mode Exit fullscreen mode

Everything seems to work fine ;). See you in the next part!

Bye and take care! ;)

Discussion (0)

pic
Editor guide