[ PART 7 ] Create a Twitter clone with GraphQL, Typescript, and React ( add/delete a tweet )
Table of contents
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)
}
}
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
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:
The only little problem here is that I'm going to have 3 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
}
}
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')
})
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)
}
}
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 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')
})
Everything seems to work fine ;). See you in the next part!
Bye and take care! ;)