[ PART 7 ] Creating a Twitter clone with GraphQL, Typescript, and React ( add/delete a tweet )
ips-coding-challenge
Posted on January 10, 2021
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)
}
}
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 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)
}
`
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')
})
Everything seems to work fine ;). See you in the next part!
Bye and take care! ;)
Posted on January 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
January 10, 2021