├── .artillery ├── generate-test-users.js └── test.yml ├── .github └── workflows │ └── dev.yml ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── data │ ├── delete-follower.json │ ├── delete-tweet.json │ ├── logo.png │ ├── new-follower.json │ └── new-tweet.json ├── lib │ └── graphql.js ├── steps │ ├── given.js │ ├── then.js │ └── when.js └── test_cases │ ├── e2e │ ├── confirm-user-signup.tests.js │ ├── directMessages.tests.js │ ├── following.js │ ├── get-hash-tag.js │ ├── notifications.js │ ├── reply.js │ ├── search.js │ ├── tweeting.js │ └── user-profile.js │ ├── integration │ ├── confirm-user-signup.tests.js │ ├── distribute-tweets-to-follower.tests.js │ ├── distribute-tweets.tests.js │ ├── reply.tests.js │ ├── retweet.tests.js │ ├── send-direct-message.tests.js │ ├── tweet.tests.js │ └── unretweet.tests.js │ └── unit │ ├── Mutation.editMyProfile.request.js │ ├── Query.getMyProfile.request.js │ ├── Query.getTweets.request.js │ ├── Reply.inReplyToUsers.request.js │ ├── Tweet.profile.request.js │ ├── Tweet.profile.response.js │ ├── UnhydratedTweetsPage.tweets.request.js │ ├── get-upload-url.tests.js │ ├── hydrateFollowers.request.js │ └── hydrateFollowing.request.js ├── functions ├── confirm-user-signup.js ├── distribute-tweets-to-follower.js ├── distribute-tweets.js ├── firehose-transformer.js ├── get-hash-tag.js ├── get-tweet-creator.js ├── get-upload-url.js ├── notify-dmed.js ├── notify-liked.js ├── notify.js ├── reply.js ├── retweet.js ├── search.js ├── send-direct-message.js ├── set-resolver-log-level.js ├── sync-tweets-to-algolia.js ├── sync-users-to-algolia.js ├── tweet.js └── unretweet.js ├── jest.config.js ├── jsconfig.json ├── lib ├── algolia.js ├── constants.js ├── graphql.js ├── tweets.js └── users.js ├── mapping-templates ├── Conversation.otherUser.request.vtl ├── Conversation.otherUser.response.vtl ├── Message.from.request.vtl ├── Message.from.response.vtl ├── Mutation.editMyProfile.request.vtl ├── Mutation.editMyProfile.response.vtl ├── Mutation.follow.request.vtl ├── Mutation.follow.response.vtl ├── Mutation.like.request.vtl ├── Mutation.like.response.vtl ├── Mutation.notifyDMed.request.vtl ├── Mutation.notifyDMed.response.vtl ├── Mutation.notifyLiked.request.vtl ├── Mutation.notifyLiked.response.vtl ├── Mutation.notifyMentioned.request.vtl ├── Mutation.notifyMentioned.response.vtl ├── Mutation.notifyReplied.request.vtl ├── Mutation.notifyReplied.response.vtl ├── Mutation.notifyRetweeted.request.vtl ├── Mutation.notifyRetweeted.response.vtl ├── Mutation.unfollow.request.vtl ├── Mutation.unfollow.response.vtl ├── Mutation.unlike.request.vtl ├── Mutation.unlike.response.vtl ├── MyProfile.tweets.request.vtl ├── MyProfile.tweets.response.vtl ├── OtherProfile.followedBy.request.vtl ├── OtherProfile.followedBy.response.vtl ├── OtherProfile.following.request.vtl ├── OtherProfile.following.response.vtl ├── Query.getAnalyticsConfig.request.vtl ├── Query.getAnalyticsConfig.response.vtl ├── Query.getDirectMessages.request.vtl ├── Query.getDirectMessages.response.vtl ├── Query.getLikes.request.vtl ├── Query.getLikes.response.vtl ├── Query.getMyProfile.request.vtl ├── Query.getMyProfile.response.vtl ├── Query.getMyTimeline.request.vtl ├── Query.getMyTimeline.response.vtl ├── Query.getProfile.request.vtl ├── Query.getProfile.response.vtl ├── Query.getTweets.request.vtl ├── Query.getTweets.response.vtl ├── Query.listConversations.request.vtl ├── Query.listConversations.response.vtl ├── Reply.inReplyToTweet.request.vtl ├── Reply.inReplyToTweet.response.vtl ├── Reply.inReplyToUsers.request.vtl ├── Reply.inReplyToUsers.response.vtl ├── Retweet.retweetOf.request.vtl ├── Retweet.retweetOf.response.vtl ├── Subscription.onNotified.request.vtl ├── Subscription.onNotified.response.vtl ├── Tweet.liked.request.vtl ├── Tweet.liked.response.vtl ├── Tweet.profile.batchInvoke.request.vtl ├── Tweet.profile.batchInvoke.response.vtl ├── Tweet.profile.request.vtl ├── Tweet.profile.response.vtl ├── Tweet.retweeted.request.vtl ├── Tweet.retweeted.response.vtl ├── UnhydratedTweetsPage.tweets.request.vtl ├── UnhydratedTweetsPage.tweets.response.vtl ├── getFollowers.request.vtl ├── getFollowers.response.vtl ├── getFollowing.request.vtl ├── getFollowing.response.vtl ├── hydrateFollowers.request.vtl ├── hydrateFollowers.response.vtl ├── hydrateFollowing.request.vtl ├── hydrateFollowing.response.vtl ├── simplePipeline.request.vtl └── simplePipeline.response.vtl ├── package-lock.json ├── package.json ├── packages-to-install.txt ├── schema.api.graphql ├── scripts ├── dummy-update-tweets.js └── dummy-update-users.js ├── serverless.appsync-api.yml ├── serverless.yml └── testCognitoIdentity.js /.artillery/generate-test-users.js: -------------------------------------------------------------------------------- 1 | const yargs = require('yargs/yargs') 2 | const { hideBin } = require('yargs/helpers') 3 | const argv = yargs(hideBin(process.argv)).argv 4 | const given = require('../__tests__/steps/given') 5 | const fs = require('fs') 6 | 7 | if (!argv.count) { 8 | throw new Error('must specify "count", e.g. --count=100') 9 | } 10 | 11 | if (argv.count < 1) { 12 | throw new Error('"count" must be at least 1, e.g. --count=100') 13 | } 14 | 15 | const run = async () => { 16 | const users = [] 17 | for (let i = 0; i < argv.count; i++) { 18 | const user = await given.an_authenticated_user() 19 | users.push(user) 20 | } 21 | 22 | const csv = users.map(x => x.idToken).join('\n') 23 | fs.writeFileSync('./.artillery/users.csv', csv) 24 | } 25 | 26 | run().then(_ => console.log('all done')) -------------------------------------------------------------------------------- /.artillery/test.yml: -------------------------------------------------------------------------------- 1 | config: 2 | target: "{{ $processEnvironment.API_URL }}" 3 | phases: 4 | - duration: 300 5 | arrivalRate: 1 6 | maxVusers: 1 7 | http: 8 | timeout: 30 9 | payload: 10 | path: users.csv 11 | fields: 12 | - idToken 13 | scenarios: 14 | - name: post tweet to timeline 15 | flow: 16 | - log: get profile 17 | - post: 18 | url: / 19 | headers: 20 | Authorization: "Bearer {{ idToken }}" 21 | json: 22 | query: "query getMyProfile { 23 | getMyProfile { 24 | id 25 | name 26 | screenName 27 | bio 28 | createdAt 29 | birthdate 30 | } 31 | }" 32 | expect: 33 | - statusCode: 200 34 | 35 | - loop: 36 | - think: 1 # wait 1 second between iterations 37 | - log: post tweet 38 | - post: 39 | url: / 40 | headers: 41 | Authorization: "Bearer {{ idToken }}" 42 | json: 43 | query: "mutation tweet($text: String!) { 44 | tweet(text: $text) { 45 | createdAt 46 | id 47 | liked 48 | likes 49 | profile { 50 | id 51 | name 52 | screenName 53 | } 54 | replies 55 | retweeted 56 | retweets 57 | text 58 | } 59 | }" 60 | variables: 61 | text: this is a tweet 62 | expect: 63 | - statusCode: 200 64 | - hasProperty: data.tweet.id 65 | 66 | - log: load timeline 67 | - post: 68 | url: / 69 | headers: 70 | Authorization: "Bearer {{ idToken }}" 71 | json: 72 | query: "query getMyTimeline { 73 | getMyTimeline(limit:25) { 74 | nextToken 75 | tweets { 76 | id 77 | profile { 78 | id 79 | name 80 | screenName 81 | } 82 | ... on Tweet { 83 | text 84 | likes 85 | replies 86 | retweets 87 | } 88 | ... on Retweet { 89 | retweetOf { 90 | id 91 | profile { 92 | id 93 | name 94 | screenName 95 | } 96 | ... on Tweet { 97 | id 98 | text 99 | } 100 | } 101 | } 102 | ... on Reply { 103 | text 104 | inReplyToUsers { 105 | id 106 | name 107 | screenName 108 | } 109 | inReplyToTweet { 110 | id 111 | profile { 112 | id 113 | name 114 | screenName 115 | } 116 | ... on Tweet { 117 | id 118 | text 119 | } 120 | } 121 | } 122 | } 123 | } 124 | }" 125 | expect: 126 | - statusCode: 200 127 | - hasProperty: data.tweet.id 128 | 129 | count: 10 -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: deploy dev 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | # The type of runner that the job will run on 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 14 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 15 | AWS_DEFAULT_REGION: eu-west-1 16 | 17 | steps: 18 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.x' 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: '12' 26 | 27 | - name: npm ci 28 | run: npm ci 29 | 30 | - name: install AWS CLI 31 | run: | 32 | curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" 33 | unzip awscliv2.zip 34 | sudo ./aws/install 35 | 36 | rm awscliv2.zip 37 | rm -r ./aws 38 | - name: unit tests 39 | run: | 40 | CREDS=`aws sts assume-role --role-arn arn:aws:iam::${{ secrets.DEV_ACCOUNT_ID }}:role/ci-role --role-session-name=ci_user` 41 | export AWS_ACCESS_KEY_ID=`echo $CREDS | jq -r '.Credentials.AccessKeyId'` 42 | export AWS_SECRET_ACCESS_KEY=`echo $CREDS | jq -r '.Credentials.SecretAccessKey'` 43 | export AWS_SESSION_TOKEN=`echo $CREDS | jq -r '.Credentials.SessionToken'` 44 | npm run exportEnv 45 | 46 | npm run test 47 | - name: integration tests 48 | run: | 49 | CREDS=`aws sts assume-role --role-arn arn:aws:iam::${{ secrets.DEV_ACCOUNT_ID }}:role/ci-role --role-session-name=ci_user` 50 | export AWS_ACCESS_KEY_ID=`echo $CREDS | jq -r '.Credentials.AccessKeyId'` 51 | export AWS_SECRET_ACCESS_KEY=`echo $CREDS | jq -r '.Credentials.SecretAccessKey'` 52 | export AWS_SESSION_TOKEN=`echo $CREDS | jq -r '.Credentials.SessionToken'` 53 | npm run exportEnv 54 | 55 | npm run integration-test 56 | - name: deploy to dev 57 | run: | 58 | CREDS=`aws sts assume-role --role-arn arn:aws:iam::${{ secrets.DEV_ACCOUNT_ID }}:role/ci-role --role-session-name=ci_user` 59 | export AWS_ACCESS_KEY_ID=`echo $CREDS | jq -r '.Credentials.AccessKeyId'` 60 | export AWS_SECRET_ACCESS_KEY=`echo $CREDS | jq -r '.Credentials.SecretAccessKey'` 61 | export AWS_SESSION_TOKEN=`echo $CREDS | jq -r '.Credentials.SessionToken'` 62 | npm run sls -- deploy 63 | - name: e2e tests 64 | run: | 65 | CREDS=`aws sts assume-role --role-arn arn:aws:iam::${{ secrets.DEV_ACCOUNT_ID }}:role/ci-role --role-session-name=ci_user` 66 | export AWS_ACCESS_KEY_ID=`echo $CREDS | jq -r '.Credentials.AccessKeyId'` 67 | export AWS_SECRET_ACCESS_KEY=`echo $CREDS | jq -r '.Credentials.SecretAccessKey'` 68 | export AWS_SESSION_TOKEN=`echo $CREDS | jq -r '.Credentials.SessionToken'` 69 | npm run exportEnv 70 | 71 | npm run e2e-test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless 7 | .env 8 | # Ignore serverless manifest files 9 | serverless.manifest.json 10 | .serverless/manifest.json 11 | .DS_Store 12 | .artillery/users.csv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yan Cui 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # appsyncmasterclass-backend 2 | Backend for the AppSync Masterclass demo app 3 | -------------------------------------------------------------------------------- /__tests__/data/delete-follower.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "48aa667b0c5f9a5d9a8b5e71fba8451f", 5 | "eventName": "REMOVE", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "us-east-1", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1606944127, 11 | "Keys": "****", 12 | "OldImage": { 13 | "createdAt": { "S": "2020-12-02T21:22:07.137Z" }, 14 | "sk": { "S": "FOLLOWS_9cac0f28-579a-466f-9f5a-f8a2374f0214" }, 15 | "otherUserId": { "S": "9cac0f28-579a-466f-9f5a-f8a2374f0214" }, 16 | "userId": { "S": "659103c5-04a6-48d1-8652-884b30259f92" } 17 | }, 18 | "SequenceNumber": "182390800000000004758093833", 19 | "SizeBytes": 256, 20 | "StreamViewType": "NEW_AND_OLD_IMAGES" 21 | }, 22 | "eventSourceARN": "arn:aws:dynamodb:us-east-1:374852340823:table/appsyncmasterclass-backend-dev-RelationshipsTable-1RQPCXQI994AM/stream/2020-10-25T09:50:45.626" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /__tests__/data/delete-tweet.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "3fc6c9f458d82bb81191518e862c3fbc", 5 | "eventName": "REMOVE", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "us-east-1", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1606055595, 11 | "Keys": "****", 12 | "OldImage": { 13 | "createdAt": { "S": "2020-11-22T14:33:15.894Z" }, 14 | "creator": { "S": "a0eb810f-6c2b-4b0c-a95d-d0743dc2757b" }, 15 | "replies": { "N": "0" }, 16 | "__typename": { "S": "Tweet" }, 17 | "id": { "S": "01EQR5Z3VP4ZHK6WEGWZ3DPGTC" }, 18 | "text": { "S": "vMzU#O)noj%)J$tT" }, 19 | "retweets": { "N": "0" }, 20 | "likes": { "N": "0" } 21 | }, 22 | "SequenceNumber": "133988500000000076177987786", 23 | "SizeBytes": 190, 24 | "StreamViewType": "NEW_AND_OLD_IMAGES" 25 | }, 26 | "eventSourceARN": "arn:aws:dynamodb:us-east-1:374852340823:table/appsyncmasterclass-backend-dev-TweetsTable-1OXHGXWO4SXHI/stream/2020-10-25T09:50:45.452" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /__tests__/data/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theburningmonk/appsyncmasterclass-backend/ed4d41268ec42562636a86d7ac9ba164165aac73/__tests__/data/logo.png -------------------------------------------------------------------------------- /__tests__/data/new-follower.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "48aa667b0c5f9a5d9a8b5e71fba8451f", 5 | "eventName": "INSERT", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "us-east-1", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1606944127, 11 | "Keys": "****", 12 | "NewImage": { 13 | "createdAt": { "S": "2020-12-02T21:22:07.137Z" }, 14 | "sk": { "S": "FOLLOWS_9cac0f28-579a-466f-9f5a-f8a2374f0214" }, 15 | "otherUserId": { "S": "9cac0f28-579a-466f-9f5a-f8a2374f0214" }, 16 | "userId": { "S": "659103c5-04a6-48d1-8652-884b30259f92" } 17 | }, 18 | "SequenceNumber": "182390800000000004758093833", 19 | "SizeBytes": 256, 20 | "StreamViewType": "NEW_AND_OLD_IMAGES" 21 | }, 22 | "eventSourceARN": "arn:aws:dynamodb:us-east-1:374852340823:table/appsyncmasterclass-backend-dev-RelationshipsTable-1RQPCXQI994AM/stream/2020-10-25T09:50:45.626" 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /__tests__/data/new-tweet.json: -------------------------------------------------------------------------------- 1 | { 2 | "Records": [ 3 | { 4 | "eventID": "3fc6c9f458d82bb81191518e862c3fbc", 5 | "eventName": "INSERT", 6 | "eventVersion": "1.1", 7 | "eventSource": "aws:dynamodb", 8 | "awsRegion": "us-east-1", 9 | "dynamodb": { 10 | "ApproximateCreationDateTime": 1606055595, 11 | "Keys": "****", 12 | "NewImage": { 13 | "createdAt": { "S": "2020-11-22T14:33:15.894Z" }, 14 | "creator": { "S": "a0eb810f-6c2b-4b0c-a95d-d0743dc2757b" }, 15 | "replies": { "N": "0" }, 16 | "__typename": { "S": "Tweet" }, 17 | "id": { "S": "01EQR5Z3VP4ZHK6WEGWZ3DPGTC" }, 18 | "text": { "S": "vMzU#O)noj%)J$tT" }, 19 | "retweets": { "N": "0" }, 20 | "likes": { "N": "0" } 21 | }, 22 | "SequenceNumber": "133988500000000076177987786", 23 | "SizeBytes": 190, 24 | "StreamViewType": "NEW_AND_OLD_IMAGES" 25 | }, 26 | "eventSourceARN": "arn:aws:dynamodb:us-east-1:374852340823:table/appsyncmasterclass-backend-dev-TweetsTable-1OXHGXWO4SXHI/stream/2020-10-25T09:50:45.452" 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /__tests__/lib/graphql.js: -------------------------------------------------------------------------------- 1 | const http = require('axios') 2 | const _ = require('lodash') 3 | 4 | const fragments = {} 5 | const registerFragment = (name, fragment) => fragments[name] = fragment 6 | 7 | const throwOnErrors = ({query, variables, errors}) => { 8 | if (errors) { 9 | const errorMessage = ` 10 | query: ${query.substr(0, 100)} 11 | 12 | variales: ${JSON.stringify(variables, null, 2)} 13 | 14 | error: ${JSON.stringify(errors, null, 2)} 15 | ` 16 | throw new Error(errorMessage) 17 | } 18 | } 19 | 20 | function* findUsedFragments (query, usedFragments = new Set()) { 21 | for (const name of Object.keys(fragments)) { 22 | if (query.includes(name) && !usedFragments.has(name)) { 23 | usedFragments.add(name) 24 | yield name 25 | 26 | const fragment = fragments[name] 27 | const nestedFragments = findUsedFragments(fragment, usedFragments) 28 | 29 | for (const nestedName of Array.from(nestedFragments)) { 30 | yield nestedName 31 | } 32 | } 33 | } 34 | } 35 | 36 | module.exports.registerFragment = registerFragment 37 | module.exports.GraphQL = async (url, query, variables = {}, auth) => { 38 | const headers = {} 39 | if (auth) { 40 | headers.Authorization = auth 41 | } 42 | 43 | const usedFragments = Array 44 | .from(findUsedFragments(query)) 45 | .map(name => fragments[name]) 46 | 47 | try { 48 | const resp = await http({ 49 | method: 'post', 50 | url, 51 | headers, 52 | data: { 53 | query: [query, ...usedFragments].join('\n'), 54 | variables: JSON.stringify(variables) 55 | } 56 | }) 57 | 58 | const { data, errors } = resp.data 59 | throwOnErrors({query, variables, errors}) 60 | return data 61 | } catch (error) { 62 | const errors = _.get(error, 'response.data.errors') 63 | throwOnErrors({query, variables, errors}) 64 | throw error 65 | } 66 | } -------------------------------------------------------------------------------- /__tests__/steps/given.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const AWS = require('aws-sdk') 3 | const DocumentClient = new AWS.DynamoDB.DocumentClient() 4 | const chance = require('chance').Chance() 5 | const velocityUtil = require('amplify-appsync-simulator/lib/velocity/util') 6 | 7 | const { RELATIONSHIPS_TABLE } = process.env 8 | 9 | const a_random_user = () => { 10 | const firstName = chance.first({ nationality: 'en' }) 11 | const lastName = chance.first({ nationality: 'en' }) 12 | const suffix = chance.string({ length: 4, pool: 'abcdefghijklmnopqrstuvwxyz' }) 13 | const name = `${firstName} ${lastName} ${suffix}` 14 | const password = chance.string({ length: 8 }) 15 | const email = `${firstName}-${lastName}-${suffix}@appsyncmasterclass.com` 16 | 17 | return { 18 | name, 19 | password, 20 | email 21 | } 22 | } 23 | 24 | const an_appsync_context = (identity, args, result, source, info, prev) => { 25 | const util = velocityUtil.create([], new Date(), Object()) 26 | const context = { 27 | identity, 28 | args, 29 | arguments: args, 30 | result, 31 | source, 32 | info, 33 | prev 34 | } 35 | return { 36 | context, 37 | ctx: context, 38 | util, 39 | utils: util 40 | } 41 | } 42 | 43 | const an_authenticated_user = async () => { 44 | const { name, email, password } = a_random_user() 45 | 46 | const cognito = new AWS.CognitoIdentityServiceProvider() 47 | 48 | const userPoolId = process.env.COGNITO_USER_POOL_ID 49 | const clientId = process.env.WEB_COGNITO_USER_POOL_CLIENT_ID 50 | 51 | const signUpResp = await cognito.signUp({ 52 | ClientId: clientId, 53 | Username: email, 54 | Password: password, 55 | UserAttributes: [ 56 | { Name: 'name', Value: name } 57 | ] 58 | }).promise() 59 | 60 | const username = signUpResp.UserSub 61 | console.log(`[${email}] - user has signed up [${username}]`) 62 | 63 | await cognito.adminConfirmSignUp({ 64 | UserPoolId: userPoolId, 65 | Username: username 66 | }).promise() 67 | 68 | console.log(`[${email}] - confirmed sign up`) 69 | 70 | const auth = await cognito.initiateAuth({ 71 | AuthFlow: 'USER_PASSWORD_AUTH', 72 | ClientId: clientId, 73 | AuthParameters: { 74 | USERNAME: username, 75 | PASSWORD: password 76 | } 77 | }).promise() 78 | 79 | console.log(`[${email}] - signed in`) 80 | 81 | return { 82 | username, 83 | name, 84 | email, 85 | idToken: auth.AuthenticationResult.IdToken, 86 | accessToken: auth.AuthenticationResult.AccessToken 87 | } 88 | } 89 | 90 | const a_user_follows_another = async (userId, otherUserId) => { 91 | await DocumentClient.put({ 92 | TableName: RELATIONSHIPS_TABLE, 93 | Item: { 94 | userId, 95 | sk: `FOLLOWS_${otherUserId}`, 96 | otherUserId, 97 | createdAt: new Date().toJSON() 98 | } 99 | }).promise() 100 | } 101 | 102 | module.exports = { 103 | a_random_user, 104 | an_appsync_context, 105 | an_authenticated_user, 106 | a_user_follows_another, 107 | } -------------------------------------------------------------------------------- /__tests__/steps/then.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const _ = require('lodash') 3 | const AWS = require('aws-sdk') 4 | const http = require('axios') 5 | const fs = require('fs') 6 | 7 | const user_exists_in_UsersTable = async (id) => { 8 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 9 | 10 | console.log(`looking for user [${id}] in table [${process.env.USERS_TABLE}]`) 11 | const resp = await DynamoDB.get({ 12 | TableName: process.env.USERS_TABLE, 13 | Key: { 14 | id 15 | } 16 | }).promise() 17 | 18 | expect(resp.Item).toBeTruthy() 19 | 20 | return resp.Item 21 | } 22 | 23 | const tweetsCount_is_updated_in_UsersTable = async (id, newCount) => { 24 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 25 | 26 | console.log(`looking for user [${id}] in table [${process.env.USERS_TABLE}]`) 27 | const resp = await DynamoDB.get({ 28 | TableName: process.env.USERS_TABLE, 29 | Key: { 30 | id 31 | } 32 | }).promise() 33 | 34 | expect(resp.Item).toBeTruthy() 35 | expect(resp.Item.tweetsCount).toEqual(newCount) 36 | 37 | return resp.Item 38 | } 39 | 40 | const tweet_exists_in_TweetsTable = async (id) => { 41 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 42 | 43 | console.log(`looking for tweet [${id}] in table [${process.env.TWEETS_TABLE}]`) 44 | const resp = await DynamoDB.get({ 45 | TableName: process.env.TWEETS_TABLE, 46 | Key: { 47 | id 48 | } 49 | }).promise() 50 | 51 | expect(resp.Item).toBeTruthy() 52 | 53 | return resp.Item 54 | } 55 | 56 | const reply_exists_in_TweetsTable = async (userId, tweetId) => { 57 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 58 | 59 | console.log(`looking for reply by [${userId}] to [${tweetId}] in table [${process.env.TWEETS_TABLE}]`) 60 | const resp = await DynamoDB.query({ 61 | TableName: process.env.TWEETS_TABLE, 62 | IndexName: 'repliesForTweet', 63 | KeyConditionExpression: 'inReplyToTweetId = :tweetId', 64 | ExpressionAttributeValues: { 65 | ':userId': userId, 66 | ':tweetId': tweetId 67 | }, 68 | FilterExpression: 'creator = :userId' 69 | }).promise() 70 | 71 | const reply = _.get(resp, 'Items.0') 72 | 73 | expect(reply).toBeTruthy() 74 | 75 | return reply 76 | } 77 | 78 | const retweet_exists_in_TweetsTable = async (userId, tweetId) => { 79 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 80 | 81 | console.log(`looking for retweet of [${tweetId}] in table [${process.env.TWEETS_TABLE}]`) 82 | const resp = await DynamoDB.query({ 83 | TableName: process.env.TWEETS_TABLE, 84 | IndexName: 'retweetsByCreator', 85 | KeyConditionExpression: 'creator = :creator AND retweetOf = :tweetId', 86 | ExpressionAttributeValues: { 87 | ':creator': userId, 88 | ':tweetId': tweetId 89 | }, 90 | Limit: 1 91 | }).promise() 92 | 93 | const retweet = _.get(resp, 'Items.0') 94 | 95 | expect(retweet).toBeTruthy() 96 | 97 | return retweet 98 | } 99 | 100 | const retweet_does_not_exist_in_TweetsTable = async (userId, tweetId) => { 101 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 102 | 103 | console.log(`looking for retweet of [${tweetId}] in table [${process.env.TWEETS_TABLE}]`) 104 | const resp = await DynamoDB.query({ 105 | TableName: process.env.TWEETS_TABLE, 106 | IndexName: 'retweetsByCreator', 107 | KeyConditionExpression: 'creator = :creator AND retweetOf = :tweetId', 108 | ExpressionAttributeValues: { 109 | ':creator': userId, 110 | ':tweetId': tweetId 111 | }, 112 | Limit: 1 113 | }).promise() 114 | 115 | expect(resp.Items).toHaveLength(0) 116 | 117 | return null 118 | } 119 | 120 | const retweet_exists_in_RetweetsTable = async (userId, tweetId) => { 121 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 122 | 123 | console.log(`looking for retweet of [${tweetId}] for user [${userId}] in table [${process.env.RETWEETS_TABLE}]`) 124 | const resp = await DynamoDB.get({ 125 | TableName: process.env.RETWEETS_TABLE, 126 | Key: { 127 | userId, 128 | tweetId 129 | } 130 | }).promise() 131 | 132 | expect(resp.Item).toBeTruthy() 133 | 134 | return resp.Item 135 | } 136 | 137 | const retweet_does_not_exist_in_RetweetsTable = async (userId, tweetId) => { 138 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 139 | 140 | console.log(`looking for retweet of [${tweetId}] for user [${userId}] in table [${process.env.RETWEETS_TABLE}]`) 141 | const resp = await DynamoDB.get({ 142 | TableName: process.env.RETWEETS_TABLE, 143 | Key: { 144 | userId, 145 | tweetId 146 | } 147 | }).promise() 148 | 149 | expect(resp.Item).not.toBeTruthy() 150 | 151 | return resp.Item 152 | } 153 | 154 | const tweet_exists_in_TimelinesTable = async (userId, tweetId) => { 155 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 156 | 157 | console.log(`looking for tweet [${tweetId}] for user [${userId}] in table [${process.env.TIMELINES_TABLE}]`) 158 | const resp = await DynamoDB.get({ 159 | TableName: process.env.TIMELINES_TABLE, 160 | Key: { 161 | userId, 162 | tweetId 163 | } 164 | }).promise() 165 | 166 | expect(resp.Item).toBeTruthy() 167 | 168 | return resp.Item 169 | } 170 | 171 | const tweet_does_not_exist_in_TimelinesTable = async (userId, tweetId) => { 172 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 173 | 174 | console.log(`looking for tweet [${tweetId}] for user [${userId}] in table [${process.env.TIMELINES_TABLE}]`) 175 | const resp = await DynamoDB.get({ 176 | TableName: process.env.TIMELINES_TABLE, 177 | Key: { 178 | userId, 179 | tweetId 180 | } 181 | }).promise() 182 | 183 | expect(resp.Item).not.toBeTruthy() 184 | 185 | return resp.Item 186 | } 187 | 188 | const there_are_N_tweets_in_TimelinesTable = async (userId, n) => { 189 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 190 | 191 | console.log(`looking for [${n}] tweets for user [${userId}] in table [${process.env.TIMELINES_TABLE}]`) 192 | const resp = await DynamoDB.query({ 193 | TableName: process.env.TIMELINES_TABLE, 194 | KeyConditionExpression: 'userId = :userId', 195 | ExpressionAttributeValues: { 196 | ':userId': userId 197 | }, 198 | ScanIndexForward: false 199 | }).promise() 200 | 201 | expect(resp.Items).toHaveLength(n) 202 | 203 | return resp.Items 204 | } 205 | 206 | const there_are_N_messages_in_DirectMessagesTable = async (conversationId, n) => { 207 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 208 | 209 | console.log(`looking for direct messages for [conversation: ${conversationId}] in table ${process.env.DIRECT_MESSAGES_TABLE}...`) 210 | const resp = await DynamoDB.query({ 211 | TableName: process.env.DIRECT_MESSAGES_TABLE, 212 | KeyConditionExpression: 'conversationId = :conversationId', 213 | ExpressionAttributeValues: { 214 | ':conversationId': conversationId 215 | } 216 | }).promise() 217 | 218 | expect(resp.Items).toHaveLength(n) 219 | 220 | return resp.Items 221 | } 222 | 223 | const conversation_exists_in_ConversationsTable = async (userId, otherUserId) => { 224 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 225 | 226 | console.log(`looking for conversation between [${userId}] and [${otherUserId}] in table ${process.env.CONVERSATIONS_TABLE}...`) 227 | const resp = await DynamoDB.get({ 228 | TableName: process.env.CONVERSATIONS_TABLE, 229 | Key: { 230 | userId, 231 | otherUserId 232 | } 233 | }).promise() 234 | 235 | expect(resp.Item).toBeTruthy() 236 | 237 | return resp.Item 238 | } 239 | 240 | const user_can_upload_image_to_url = async (url, filepath, contentType) => { 241 | const data = fs.readFileSync(filepath) 242 | await http({ 243 | method: 'put', 244 | url, 245 | headers: { 246 | 'Content-Type': contentType 247 | }, 248 | data 249 | }) 250 | 251 | console.log('uploaded image to', url) 252 | } 253 | 254 | const user_can_download_image_from = async (url) => { 255 | const resp = await http(url) 256 | 257 | console.log('downloaded image from', url) 258 | 259 | return resp.data 260 | } 261 | 262 | module.exports = { 263 | user_exists_in_UsersTable, 264 | tweetsCount_is_updated_in_UsersTable, 265 | tweet_exists_in_TweetsTable, 266 | tweet_exists_in_TimelinesTable, 267 | tweet_does_not_exist_in_TimelinesTable, 268 | reply_exists_in_TweetsTable, 269 | retweet_exists_in_TweetsTable, 270 | retweet_does_not_exist_in_TweetsTable, 271 | retweet_exists_in_RetweetsTable, 272 | retweet_does_not_exist_in_RetweetsTable, 273 | there_are_N_tweets_in_TimelinesTable, 274 | there_are_N_messages_in_DirectMessagesTable, 275 | conversation_exists_in_ConversationsTable, 276 | user_can_upload_image_to_url, 277 | user_can_download_image_from 278 | } -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/confirm-user-signup.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | 5 | describe('When a user signs up', () => { 6 | it("The user's profile should be saved in DynamoDB", async () => { 7 | const { password, name, email } = given.a_random_user() 8 | 9 | const user = await when.a_user_signs_up(password, name, email) 10 | 11 | const ddbUser = await then.user_exists_in_UsersTable(user.username) 12 | expect(ddbUser).toMatchObject({ 13 | id: user.username, 14 | name, 15 | createdAt: expect.stringMatching(/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d+)?Z?/g), 16 | followersCount: 0, 17 | followingCount: 0, 18 | tweetsCount: 0, 19 | likesCounts: 0 20 | }) 21 | 22 | const [firstName, lastName] = name.split(' ') 23 | expect(ddbUser.screenName).toContain(firstName) 24 | expect(ddbUser.screenName).toContain(lastName) 25 | }) 26 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/directMessages.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | 5 | describe(`Given two authenticated users`, () => { 6 | let userA, userB 7 | 8 | beforeAll(async () => { 9 | userA = await given.an_authenticated_user() 10 | userB = await given.an_authenticated_user() 11 | }) 12 | 13 | describe("When User A sends a DM to User B", () => { 14 | let conversation 15 | const message = chance.string({ length: 16 }) 16 | beforeAll(async () => { 17 | conversation = await when.a_user_calls_sendDirectMessage(userA, userB.username, message) 18 | }) 19 | 20 | it("The conversation's lastMessage should be user A's message", () => { 21 | expect(conversation.lastMessage).toEqual(message) 22 | }) 23 | 24 | it("User A should see the conversation when he calls listConversations", async () => { 25 | const { conversations, nextToken } = await when.a_user_calls_listConversations(userA, 10) 26 | 27 | expect(nextToken).toBeNull() 28 | expect(conversations).toHaveLength(1) 29 | expect(conversations[0]).toEqual(conversation) 30 | }) 31 | 32 | it("User B should see the conversation when he calls listConversations", async () => { 33 | const { conversations, nextToken } = await when.a_user_calls_listConversations(userB, 10) 34 | 35 | expect(nextToken).toBeNull() 36 | expect(conversations).toHaveLength(1) 37 | expect(conversations[0]).toMatchObject({ 38 | id: conversation.id, 39 | lastMessage: message, 40 | lastModified: conversation.lastModified, 41 | otherUser: { 42 | id: userA.username 43 | } 44 | }) 45 | }) 46 | 47 | it("User A should see the message when he calls getDirectMessages for the conversation", async () => { 48 | const { messages, nextToken } = await when.a_user_calls_getDirectMessages(userA, userB.username, 10) 49 | 50 | expect(nextToken).toBeNull() 51 | expect(messages).toHaveLength(1) 52 | expect(messages[0]).toMatchObject({ 53 | from: { 54 | id: userA.username 55 | }, 56 | message, 57 | }) 58 | }) 59 | 60 | it("User B should see the message when he calls getDirectMessages for the conversation", async () => { 61 | const { messages, nextToken } = await when.a_user_calls_getDirectMessages(userB, userA.username, 10) 62 | 63 | expect(nextToken).toBeNull() 64 | expect(messages).toHaveLength(1) 65 | expect(messages[0]).toMatchObject({ 66 | from: { 67 | id: userA.username 68 | }, 69 | message, 70 | }) 71 | }) 72 | 73 | describe("When User B sends a DM to User A", () => { 74 | let conversation2 75 | const message2 = chance.string({ length: 16 }) 76 | beforeAll(async () => { 77 | conversation2 = await when.a_user_calls_sendDirectMessage(userB, userA.username, message2) 78 | }) 79 | 80 | it("The conversation's lastMessage and lastModified should be updated", () => { 81 | expect(conversation2.lastMessage).toEqual(message2) 82 | expect(conversation2.lastModified > conversation.lastModified).toBe(true) 83 | }) 84 | 85 | it("User A should see the updated conversation when he calls listConversations", async () => { 86 | const { conversations, nextToken } = await when.a_user_calls_listConversations(userA, 10) 87 | 88 | expect(nextToken).toBeNull() 89 | expect(conversations).toHaveLength(1) 90 | expect(conversations[0]).toMatchObject({ 91 | id: conversation.id, 92 | lastMessage: message2, 93 | lastModified: conversation2.lastModified, 94 | otherUser: { 95 | id: userB.username 96 | } 97 | }) 98 | }) 99 | 100 | it("User B should see the updated conversation when he calls listConversations", async () => { 101 | const { conversations, nextToken } = await when.a_user_calls_listConversations(userB, 10) 102 | 103 | expect(nextToken).toBeNull() 104 | expect(conversations).toHaveLength(1) 105 | expect(conversations[0]).toMatchObject(conversation2) 106 | }) 107 | 108 | it("User A should see the new message when he calls getDirectMessages for the conversation", async () => { 109 | const { messages, nextToken } = await when.a_user_calls_getDirectMessages(userA, userB.username, 10) 110 | 111 | expect(nextToken).toBeNull() 112 | expect(messages).toHaveLength(2) 113 | expect(messages[0]).toMatchObject({ 114 | from: { 115 | id: userB.username 116 | }, 117 | message: message2, 118 | }) 119 | }) 120 | 121 | it("User B should see the message when he calls getDirectMessages for the conversation", async () => { 122 | const { messages, nextToken } = await when.a_user_calls_getDirectMessages(userB, userA.username, 10) 123 | 124 | expect(nextToken).toBeNull() 125 | expect(messages).toHaveLength(2) 126 | expect(messages[0]).toMatchObject({ 127 | from: { 128 | id: userB.username 129 | }, 130 | message: message2, 131 | }) 132 | }) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/following.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const given = require('../../steps/given') 3 | const when = require('../../steps/when') 4 | const chance = require('chance').Chance() 5 | const retry = require('async-retry') 6 | 7 | describe('Given authenticated users, user A and B', () => { 8 | let userA, userB, userAsProfile, userBsProfile 9 | let userBsTweet1, userBsTweet2 10 | beforeAll(async () => { 11 | userA = await given.an_authenticated_user() 12 | userB = await given.an_authenticated_user() 13 | userAsProfile = await when.a_user_calls_getMyProfile(userA) 14 | userBsProfile = await when.a_user_calls_getMyProfile(userB) 15 | userBsTweet1 = await when.a_user_calls_tweet(userB, chance.paragraph()) 16 | userBsTweet2 = await when.a_user_calls_tweet(userB, chance.paragraph()) 17 | }) 18 | 19 | describe("When user A follows user B", () => { 20 | beforeAll(async () => { 21 | await when.a_user_calls_follow(userA, userB.username) 22 | }) 23 | 24 | it("User A should see following as true when viewing user B's profile", async () => { 25 | const { following, followedBy } = await when.a_user_calls_getProfile(userA, userBsProfile.screenName) 26 | 27 | expect(following).toBe(true) 28 | expect(followedBy).toBe(false) 29 | }) 30 | 31 | it("User B should see followedBy as true when viewing user A's profile", async () => { 32 | const { following, followedBy } = await when.a_user_calls_getProfile(userB, userAsProfile.screenName) 33 | 34 | expect(following).toBe(false) 35 | expect(followedBy).toBe(true) 36 | }) 37 | 38 | it("User A should see himself in user B's list of followers", async () => { 39 | const { profiles } = await when.a_user_calls_getFollowers(userA, userB.username, 25) 40 | 41 | expect(profiles).toHaveLength(1) 42 | expect(profiles[0]).toMatchObject({ 43 | id: userA.username 44 | }) 45 | expect(profiles[0]).not.toHaveProperty('following') 46 | expect(profiles[0]).not.toHaveProperty('followedBy') 47 | }) 48 | 49 | it("User A should see user B in his list of following", async () => { 50 | const { profiles } = await when.a_user_calls_getFollowing(userA, userA.username, 25) 51 | 52 | expect(profiles).toHaveLength(1) 53 | expect(profiles[0]).toMatchObject({ 54 | id: userB.username, 55 | following: true, 56 | followedBy: false 57 | }) 58 | }) 59 | 60 | it("User B should see user A in his list of followers", async () => { 61 | const { profiles } = await when.a_user_calls_getFollowers(userB, userB.username, 25) 62 | 63 | expect(profiles).toHaveLength(1) 64 | expect(profiles[0]).toMatchObject({ 65 | id: userA.username, 66 | following: false, 67 | followedBy: true 68 | }) 69 | }) 70 | 71 | it("User B should not see user A in his list of following", async () => { 72 | const { profiles } = await when.a_user_calls_getFollowing(userB, userB.username, 25) 73 | 74 | expect(profiles).toHaveLength(0) 75 | }) 76 | 77 | it("Adds user B's tweets to user A's timeline", async () => { 78 | retry(async () => { 79 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 80 | 81 | expect(tweets).toHaveLength(2) 82 | expect(tweets).toEqual([ 83 | expect.objectContaining({ 84 | id: userBsTweet2.id 85 | }), 86 | expect.objectContaining({ 87 | id: userBsTweet1.id 88 | }), 89 | ]) 90 | }, { 91 | retries: 3, 92 | maxTimeout: 1000 93 | }) 94 | }) 95 | 96 | describe("User B sends a tweet", () => { 97 | let tweet 98 | const text = chance.string({ length: 16 }) 99 | beforeAll(async () => { 100 | tweet = await when.a_user_calls_tweet(userB, text) 101 | }) 102 | 103 | it("Should appear in user A's timeline", async () => { 104 | await retry(async () => { 105 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 106 | 107 | expect(tweets).toHaveLength(3) 108 | expect(tweets[0].id).toEqual(tweet.id) 109 | }, { 110 | retries: 3, 111 | maxTimeout: 1000 112 | }) 113 | }) 114 | }) 115 | }) 116 | 117 | describe("When user B follows user A back", () => { 118 | beforeAll(async () => { 119 | await when.a_user_calls_follow(userB, userA.username) 120 | }) 121 | 122 | it("User A should see both following and followedBy as true when viewing user B's profile", async () => { 123 | const { following, followedBy } = await when.a_user_calls_getProfile(userA, userBsProfile.screenName) 124 | 125 | expect(following).toBe(true) 126 | expect(followedBy).toBe(true) 127 | }) 128 | 129 | it("User B should see both following and followedBy as true when viewing user A's profile", async () => { 130 | const { following, followedBy } = await when.a_user_calls_getProfile(userB, userAsProfile.screenName) 131 | 132 | expect(following).toBe(true) 133 | expect(followedBy).toBe(true) 134 | }) 135 | 136 | describe("User A sends a tweet", () => { 137 | let tweet 138 | const text = chance.string({ length: 16 }) 139 | beforeAll(async () => { 140 | tweet = await when.a_user_calls_tweet(userA, text) 141 | }) 142 | 143 | it("Should appear in user B's timeline", async () => { 144 | await retry(async () => { 145 | const { tweets } = await when.a_user_calls_getMyTimeline(userB, 25) 146 | 147 | expect(tweets).toHaveLength(4) 148 | expect(tweets[0].id).toEqual(tweet.id) 149 | }, { 150 | retries: 3, 151 | maxTimeout: 1000 152 | }) 153 | }) 154 | }) 155 | }) 156 | 157 | describe("When user A unfollows user B", () => { 158 | beforeAll(async () => { 159 | await when.a_user_calls_unfollow(userA, userB.username) 160 | }) 161 | 162 | it("User A should see following as false when viewing user B's profile", async () => { 163 | const { following, followedBy } = await when.a_user_calls_getProfile(userA, userBsProfile.screenName) 164 | 165 | expect(following).toBe(false) 166 | expect(followedBy).toBe(true) 167 | }) 168 | 169 | it("User B should see followedBy as false when viewing user A's profile", async () => { 170 | const { following, followedBy } = await when.a_user_calls_getProfile(userB, userAsProfile.screenName) 171 | 172 | expect(following).toBe(true) 173 | expect(followedBy).toBe(false) 174 | }) 175 | 176 | it("Remove user B's tweets from user A's timeline", async () => { 177 | retry(async () => { 178 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 179 | 180 | expect(tweets).toHaveLength(1) 181 | expect(tweets).toEqual([ 182 | expect.objectContaining({ 183 | profile: { 184 | id: userA.username 185 | } 186 | }), 187 | ]) 188 | }, { 189 | retries: 3, 190 | maxTimeout: 1000 191 | }) 192 | }) 193 | }) 194 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/get-hash-tag.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const { HashTagModes, TweetTypes } = require('../../../lib/constants') 4 | const retry = require('async-retry') 5 | const chance = require('chance').Chance() 6 | 7 | describe('Given an authenticated user', () => { 8 | let userA, userAsProfile 9 | const hashTag = `#${chance.string({ length: 16, alpha: true })}` 10 | 11 | beforeAll(async () => { 12 | userA = await given.an_authenticated_user() 13 | userAsProfile = await when.a_user_calls_getMyProfile(userA) 14 | await when.a_user_calls_editMyProfile(userA, { 15 | name: userAsProfile.name, 16 | imageUrl: userAsProfile.imageUrl, 17 | backgroundImageUrl: userAsProfile.backgroundImageUrl, 18 | bio: `my bio has a hashtag: ${hashTag}`, 19 | location: userAsProfile.location, 20 | website: userAsProfile.website, 21 | birthdate: userAsProfile.birthdate, 22 | }) 23 | }) 24 | 25 | it('The user can find himself when he gets the hash tag with PEOPLE', async () => { 26 | await retry(async () => { 27 | const { results, nextToken } = await when.a_user_calls_getHashTag( 28 | userA, HashTagModes.PEOPLE, hashTag, 10) 29 | 30 | expect(nextToken).toBeNull() 31 | expect(results).toHaveLength(1) 32 | expect(results[0]).toMatchObject({ 33 | __typename: 'MyProfile', 34 | id: userAsProfile.id, 35 | name: userAsProfile.name, 36 | screenName: userAsProfile.screenName 37 | }) 38 | }, { 39 | retries: 5, 40 | maxTimeout: 1000 41 | }) 42 | }, 10000) 43 | 44 | describe('When the user sends a tweet', () => { 45 | let tweet 46 | const text = chance.string({ length: 16 }) + ' ' + hashTag 47 | beforeAll(async () => { 48 | tweet = await when.a_user_calls_tweet(userA, text) 49 | }) 50 | 51 | it('The user can find his tweet when he gets the hash tag with LATEST', async () => { 52 | await retry(async () => { 53 | const { results, nextToken } = await when.a_user_calls_getHashTag( 54 | userA, HashTagModes.LATEST, hashTag, 10) 55 | 56 | expect(nextToken).toBeNull() 57 | expect(results).toHaveLength(1) 58 | expect(results[0]).toMatchObject({ 59 | __typename: TweetTypes.TWEET, 60 | id: tweet.id, 61 | text 62 | }) 63 | }, { 64 | retries: 5, 65 | maxTimeout: 1000 66 | }) 67 | }, 10000) 68 | 69 | describe('When the user replies to the tweet', () => { 70 | let reply 71 | const replyText = chance.string({ length: 16 }) + ' ' + hashTag 72 | beforeAll(async () => { 73 | reply = await when.a_user_calls_reply(userA, tweet.id, replyText) 74 | }) 75 | 76 | it('The user can find his reply when he gets the hash tag with LATEST', async () => { 77 | await retry(async () => { 78 | const { results, nextToken } = await when.a_user_calls_getHashTag( 79 | userA, HashTagModes.LATEST, hashTag, 10) 80 | 81 | expect(nextToken).toBeNull() 82 | expect(results).toHaveLength(2) 83 | expect(results[0]).toMatchObject({ 84 | __typename: TweetTypes.REPLY, 85 | id: reply.id, 86 | text: replyText 87 | }) 88 | expect(results[1]).toMatchObject({ 89 | __typename: TweetTypes.TWEET, 90 | id: tweet.id, 91 | text 92 | }) 93 | }, { 94 | retries: 5, 95 | maxTimeout: 1000 96 | }) 97 | }, 10000) 98 | }) 99 | }) 100 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/notifications.js: -------------------------------------------------------------------------------- 1 | global.WebSocket = require('ws') 2 | const given = require('../../steps/given') 3 | const when = require('../../steps/when') 4 | const gql = require('graphql-tag') 5 | const retry = require('async-retry') 6 | const chance = require('chance').Chance() 7 | const { AWSAppSyncClient, AUTH_TYPE } = require('aws-appsync') 8 | require('isomorphic-fetch') 9 | console.warn = jest.fn() 10 | console.error = jest.fn() 11 | 12 | describe('Given two authenticated users', () => { 13 | let userA, userB, userAsProfile, userAsTweet 14 | const text = chance.string({ length: 16 }) 15 | 16 | beforeAll(async () => { 17 | userA = await given.an_authenticated_user() 18 | userAsProfile = await when.a_user_calls_getMyProfile(userA) 19 | userB = await given.an_authenticated_user() 20 | userAsTweet = await when.a_user_calls_tweet(userA, text) 21 | }) 22 | 23 | describe('Given user A subscribes to notifications', () => { 24 | let client, subscription 25 | const notifications = [] 26 | 27 | beforeAll(async () => { 28 | client = new AWSAppSyncClient({ 29 | url: process.env.API_URL, 30 | region: process.env.AWS_REGION, 31 | auth: { 32 | type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS, 33 | jwtToken: () => userA.idToken 34 | }, 35 | disableOffline: true 36 | }) 37 | 38 | subscription = client.subscribe({ 39 | query: gql ` 40 | subscription onNotified ($userId: ID!) { 41 | onNotified(userId: $userId) { 42 | ... on iNotification { 43 | id 44 | type 45 | userId 46 | createdAt 47 | } 48 | 49 | ... on Retweeted { 50 | tweetId 51 | retweetedBy 52 | retweetId 53 | } 54 | 55 | ... on Liked { 56 | tweetId 57 | likedBy 58 | } 59 | 60 | ... on Replied { 61 | tweetId 62 | replyTweetId 63 | repliedBy 64 | } 65 | 66 | ... on Mentioned { 67 | mentionedByTweetId 68 | mentionedBy 69 | } 70 | 71 | ... on DMed { 72 | otherUserId 73 | message 74 | } 75 | } 76 | } 77 | `, 78 | variables: { 79 | userId: userA.username 80 | } 81 | }).subscribe({ 82 | next: resp => { 83 | notifications.push(resp.data.onNotified) 84 | } 85 | }) 86 | }) 87 | 88 | afterAll(() => { 89 | subscription.unsubscribe() 90 | }) 91 | 92 | describe("When user B likes user A's tweet", () => { 93 | beforeAll(async () => { 94 | await when.a_user_calls_like(userB, userAsTweet.id) 95 | }) 96 | 97 | it('User A should receive a notification', async () => { 98 | await retry(async () => { 99 | expect(notifications).toEqual( 100 | expect.arrayContaining([ 101 | expect.objectContaining({ 102 | type: 'Liked', 103 | userId: userA.username, 104 | tweetId: userAsTweet.id, 105 | likedBy: userB.username 106 | }) 107 | ]) 108 | ) 109 | }, { 110 | retries: 10, 111 | maxTimeout: 1000 112 | }) 113 | }, 15000) 114 | }) 115 | 116 | describe("When user B retweets user A's tweet", () => { 117 | let userBsRetweet 118 | beforeAll(async () => { 119 | userBsRetweet = await when.a_user_calls_retweet(userB, userAsTweet.id) 120 | }) 121 | 122 | it('User A should receive a notification', async () => { 123 | await retry(async () => { 124 | expect(notifications).toEqual( 125 | expect.arrayContaining([ 126 | expect.objectContaining({ 127 | type: 'Retweeted', 128 | userId: userA.username, 129 | tweetId: userAsTweet.id, 130 | retweetId: userBsRetweet.id, 131 | retweetedBy: userB.username 132 | }) 133 | ]) 134 | ) 135 | }, { 136 | retries: 10, 137 | maxTimeout: 1000 138 | }) 139 | }, 15000) 140 | }) 141 | 142 | describe("When user B replied to user A's tweet", () => { 143 | let userBsReply 144 | const replyText = chance.string({ length: 16 }) 145 | beforeAll(async () => { 146 | userBsReply = await when.a_user_calls_reply(userB, userAsTweet.id, replyText) 147 | }) 148 | 149 | it('User A should receive a notification', async () => { 150 | await retry(async () => { 151 | expect(notifications).toEqual( 152 | expect.arrayContaining([ 153 | expect.objectContaining({ 154 | type: 'Replied', 155 | userId: userA.username, 156 | tweetId: userAsTweet.id, 157 | repliedBy: userB.username, 158 | replyTweetId: userBsReply.id 159 | }) 160 | ]) 161 | ) 162 | }, { 163 | retries: 10, 164 | maxTimeout: 1000 165 | }) 166 | }, 15000) 167 | }) 168 | 169 | describe("When user B mentions user A in a tweet", () => { 170 | let userBsTweet 171 | 172 | beforeAll(async () => { 173 | const text = `hey @${userAsProfile.screenName}` 174 | userBsTweet = await when.a_user_calls_tweet(userB, text) 175 | }) 176 | 177 | it('User A should receive a notification', async () => { 178 | await retry(async () => { 179 | expect(notifications).toEqual( 180 | expect.arrayContaining([ 181 | expect.objectContaining({ 182 | type: 'Mentioned', 183 | userId: userA.username, 184 | mentionedByTweetId: userBsTweet.id, 185 | mentionedBy: userB.username 186 | }) 187 | ]) 188 | ) 189 | }, { 190 | retries: 10, 191 | maxTimeout: 1000 192 | }) 193 | }, 15000) 194 | }) 195 | 196 | describe("When user B DMs user A", () => { 197 | const message = chance.string({ length: 16 }) 198 | 199 | beforeAll(async () => { 200 | await when.a_user_calls_sendDirectMessage(userB, userA.username, message) 201 | }) 202 | 203 | it("User A should receive a notification", async () => { 204 | await retry(async () => { 205 | expect(notifications).toEqual( 206 | expect.arrayContaining([ 207 | expect.objectContaining({ 208 | userId: userA.username, 209 | type: "DMed", 210 | otherUserId: userB.username, 211 | message, 212 | }) 213 | ]) 214 | ) 215 | }, { 216 | retries: 10, 217 | maxTimeout: 1000 218 | }) 219 | }, 15000) 220 | }) 221 | }) 222 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/reply.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const given = require('../../steps/given') 3 | const when = require('../../steps/when') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given authenticated users, user A, B and C', () => { 7 | let userA, userB, userC, userAsTweet 8 | const text = chance.string({ length: 16 }) 9 | beforeAll(async () => { 10 | userA = await given.an_authenticated_user() 11 | userB = await given.an_authenticated_user() 12 | userC = await given.an_authenticated_user() 13 | userAsTweet = await when.a_user_calls_tweet(userA, text) 14 | }) 15 | 16 | describe("When user B replies to user A's tweet", () => { 17 | let usersBsReply 18 | const replyText = chance.string({ length: 16 }) 19 | beforeAll(async () => { 20 | usersBsReply = await when.a_user_calls_reply(userB, userAsTweet.id, replyText) 21 | }) 22 | 23 | it('User B should see his reply when he calls getTweets', async () => { 24 | const { tweets } = await when.a_user_calls_getTweets(userB, userB.username, 25) 25 | 26 | expect(tweets).toHaveLength(1) 27 | expect(tweets[0]).toMatchObject({ 28 | profile: { 29 | id: userB.username, 30 | tweetsCount: 1 31 | }, 32 | inReplyToTweet: { 33 | id: userAsTweet.id, 34 | replies: 1 35 | }, 36 | inReplyToUsers: [{ 37 | id: userA.username 38 | }] 39 | }) 40 | }) 41 | 42 | it('User B should see his reply when he calls getMyTimeline', async () => { 43 | const { tweets } = await when.a_user_calls_getMyTimeline(userB, 25) 44 | 45 | expect(tweets).toHaveLength(1) 46 | expect(tweets[0]).toMatchObject({ 47 | profile: { 48 | id: userB.username, 49 | tweetsCount: 1 50 | }, 51 | inReplyToTweet: { 52 | id: userAsTweet.id, 53 | replies: 1 54 | }, 55 | inReplyToUsers: [{ 56 | id: userA.username 57 | }] 58 | }) 59 | }) 60 | 61 | describe("When user C replies to user B's reply", () => { 62 | let usersCsReply 63 | const replyText = chance.string({ length: 16 }) 64 | beforeAll(async () => { 65 | usersCsReply = await when.a_user_calls_reply(userC, usersBsReply.id, replyText) 66 | }) 67 | 68 | it('User C should see his reply when he calls getTweets', async () => { 69 | const { tweets } = await when.a_user_calls_getTweets(userC, userC.username, 25) 70 | 71 | expect(tweets).toHaveLength(1) 72 | expect(tweets[0]).toMatchObject({ 73 | profile: { 74 | id: userC.username, 75 | tweetsCount: 1 76 | }, 77 | inReplyToTweet: { 78 | id: usersBsReply.id, 79 | replies: 1 80 | }, 81 | inReplyToUsers: expect.arrayContaining([ 82 | expect.objectContaining({ 83 | id: userB.username 84 | }), 85 | expect.objectContaining({ 86 | id: userA.username 87 | }) 88 | ]) 89 | }) 90 | expect(tweets[0].inReplyToUsers).toHaveLength(2) 91 | }) 92 | 93 | it('User C should see his reply when he calls getMyTimeline', async () => { 94 | const { tweets } = await when.a_user_calls_getMyTimeline(userC, 25) 95 | 96 | expect(tweets).toHaveLength(1) 97 | expect(tweets[0]).toMatchObject({ 98 | profile: { 99 | id: userC.username, 100 | tweetsCount: 1 101 | }, 102 | inReplyToTweet: { 103 | id: usersBsReply.id, 104 | replies: 1 105 | }, 106 | inReplyToUsers: expect.arrayContaining([ 107 | expect.objectContaining({ 108 | id: userB.username 109 | }), 110 | expect.objectContaining({ 111 | id: userA.username 112 | }) 113 | ]) 114 | }) 115 | expect(tweets[0].inReplyToUsers).toHaveLength(2) 116 | }) 117 | }) 118 | }) 119 | 120 | describe("When user C retweets user A's tweet", () => { 121 | let userCsRetweet 122 | beforeAll(async () => { 123 | userCsRetweet = await when.a_user_calls_retweet(userC, userAsTweet.id) 124 | }) 125 | 126 | describe("When user B replies to user C's retweet", () => { 127 | let usersBsReply 128 | const replyText = chance.string({ length: 16 }) 129 | beforeAll(async () => { 130 | usersBsReply = await when.a_user_calls_reply(userB, userCsRetweet.id, replyText) 131 | }) 132 | 133 | it('User B should see his reply when he calls getTweets', async () => { 134 | const { tweets } = await when.a_user_calls_getTweets(userB, userB.username, 25) 135 | 136 | expect(tweets).toHaveLength(2) 137 | expect(tweets[0]).toMatchObject({ 138 | inReplyToTweet: { 139 | id: userCsRetweet.id 140 | }, 141 | inReplyToUsers: expect.arrayContaining([ 142 | expect.objectContaining({ 143 | id: userC.username 144 | }), 145 | expect.objectContaining({ 146 | id: userA.username 147 | }) 148 | ]) 149 | }) 150 | expect(tweets[0].inReplyToUsers).toHaveLength(2) 151 | }) 152 | 153 | it('User B should see his reply when he calls getMyTimeline', async () => { 154 | const { tweets } = await when.a_user_calls_getMyTimeline(userB, 25) 155 | 156 | expect(tweets).toHaveLength(2) 157 | expect(tweets[0]).toMatchObject({ 158 | inReplyToTweet: { 159 | id: userCsRetweet.id 160 | }, 161 | inReplyToUsers: expect.arrayContaining([ 162 | expect.objectContaining({ 163 | id: userC.username 164 | }), 165 | expect.objectContaining({ 166 | id: userA.username 167 | }) 168 | ]) 169 | }) 170 | expect(tweets[0].inReplyToUsers).toHaveLength(2) 171 | }) 172 | }) 173 | }) 174 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/search.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const { SearchModes, TweetTypes } = require('../../../lib/constants') 4 | const retry = require('async-retry') 5 | const chance = require('chance').Chance() 6 | 7 | describe('Given an authenticated user', () => { 8 | let userA, userAsProfile 9 | beforeAll(async () => { 10 | userA = await given.an_authenticated_user() 11 | userAsProfile = await when.a_user_calls_getMyProfile(userA) 12 | }) 13 | 14 | it('The user can find himself when he searches for his twitter handle', async () => { 15 | await retry(async () => { 16 | const { results, nextToken } = await when.a_user_calls_search( 17 | userA, SearchModes.PEOPLE, userAsProfile.screenName, 10) 18 | 19 | expect(nextToken).toBeNull() 20 | expect(results).toHaveLength(1) 21 | expect(results[0]).toMatchObject({ 22 | __typename: 'MyProfile', 23 | id: userAsProfile.id, 24 | name: userAsProfile.name, 25 | screenName: userAsProfile.screenName 26 | }) 27 | }, { 28 | retries: 5, 29 | maxTimeout: 1000 30 | }) 31 | }, 10000) 32 | 33 | it('The user can find himself when he searches for his name', async () => { 34 | await retry(async () => { 35 | const { results } = await when.a_user_calls_search( 36 | userA, SearchModes.PEOPLE, userAsProfile.name, 10) 37 | 38 | expect(results).toEqual(expect.arrayContaining([ 39 | expect.objectContaining({ 40 | __typename: 'MyProfile', 41 | id: userAsProfile.id, 42 | name: userAsProfile.name, 43 | screenName: userAsProfile.screenName 44 | }) 45 | ])) 46 | }, { 47 | retries: 5, 48 | maxTimeout: 1000 49 | }) 50 | }, 10000) 51 | 52 | describe('When the user sends a tweet', () => { 53 | let tweet 54 | const text = chance.string({ length: 16 }) 55 | beforeAll(async () => { 56 | tweet = await when.a_user_calls_tweet(userA, text) 57 | }) 58 | 59 | it('The user can find his tweet when he searches for the text', async () => { 60 | await retry(async () => { 61 | const { results, nextToken } = await when.a_user_calls_search( 62 | userA, SearchModes.LATEST, text, 10) 63 | 64 | expect(nextToken).toBeNull() 65 | expect(results).toHaveLength(1) 66 | expect(results[0]).toMatchObject({ 67 | __typename: TweetTypes.TWEET, 68 | id: tweet.id, 69 | text 70 | }) 71 | }, { 72 | retries: 5, 73 | maxTimeout: 1000 74 | }) 75 | }, 10000) 76 | 77 | describe('When the user replies to the tweet', () => { 78 | let reply 79 | const replyText = chance.string({ length: 16 }) 80 | beforeAll(async () => { 81 | reply = await when.a_user_calls_reply(userA, tweet.id, replyText) 82 | }) 83 | 84 | it('The user can find his reply when he searches for the reply text', async () => { 85 | await retry(async () => { 86 | const { results, nextToken } = await when.a_user_calls_search( 87 | userA, SearchModes.LATEST, replyText, 10) 88 | 89 | expect(nextToken).toBeNull() 90 | expect(results).toHaveLength(1) 91 | expect(results[0]).toMatchObject({ 92 | __typename: TweetTypes.REPLY, 93 | id: reply.id, 94 | text: replyText 95 | }) 96 | }, { 97 | retries: 5, 98 | maxTimeout: 1000 99 | }) 100 | }, 10000) 101 | }) 102 | }) 103 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/tweeting.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const given = require('../../steps/given') 3 | const when = require('../../steps/when') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given an authenticated user', () => { 7 | let userA 8 | beforeAll(async () => { 9 | userA = await given.an_authenticated_user() 10 | }) 11 | 12 | describe('When he sends a tweet', () => { 13 | let tweet 14 | const text = chance.string({ length: 16 }) 15 | beforeAll(async () => { 16 | tweet = await when.a_user_calls_tweet(userA, text) 17 | }) 18 | 19 | it('Should return the new tweet', () => { 20 | expect(tweet).toMatchObject({ 21 | text, 22 | replies: 0, 23 | likes: 0, 24 | retweets: 0, 25 | liked: false, 26 | }) 27 | }) 28 | 29 | describe('When he calls getTweets', () => { 30 | let tweets, nextToken 31 | beforeAll(async () => { 32 | const result = await when.a_user_calls_getTweets(userA, userA.username, 25) 33 | tweets = result.tweets 34 | nextToken = result.nextToken 35 | }) 36 | 37 | it('He will see the new tweet in the tweets array', () => { 38 | expect(nextToken).toBeNull() 39 | expect(tweets.length).toEqual(1) 40 | expect(tweets[0]).toEqual(tweet) 41 | }) 42 | 43 | it('He cannot ask for more than 25 tweets in a page', async () => { 44 | await expect(when.a_user_calls_getTweets(userA, userA.username, 26)) 45 | .rejects 46 | .toMatchObject({ 47 | message: expect.stringContaining('max limit is 25') 48 | }) 49 | }) 50 | }) 51 | 52 | describe('When he calls getMyTimeline', () => { 53 | let tweets, nextToken 54 | beforeAll(async () => { 55 | const result = await when.a_user_calls_getMyTimeline(userA, 25) 56 | tweets = result.tweets 57 | nextToken = result.nextToken 58 | }) 59 | 60 | it('He will see the new tweet in the tweets array', () => { 61 | expect(nextToken).toBeNull() 62 | expect(tweets.length).toEqual(1) 63 | expect(tweets[0]).toEqual(tweet) 64 | }) 65 | 66 | it('He cannot ask for more than 25 tweets in a page', async () => { 67 | await expect(when.a_user_calls_getMyTimeline(userA, 26)) 68 | .rejects 69 | .toMatchObject({ 70 | message: expect.stringContaining('max limit is 25') 71 | }) 72 | }) 73 | }) 74 | 75 | describe('When he likes the tweet', () => { 76 | beforeAll(async () => { 77 | await when.a_user_calls_like(userA, tweet.id) 78 | }) 79 | 80 | it('Should see Tweet.liked as true', async () => { 81 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 82 | 83 | expect(tweets).toHaveLength(1) 84 | expect(tweets[0].id).toEqual(tweet.id) 85 | expect(tweets[0].liked).toEqual(true) 86 | }) 87 | 88 | it('Should not be able to like the same tweet a second time', async () => { 89 | await expect(() => when.a_user_calls_like(userA, tweet.id)) 90 | .rejects 91 | .toMatchObject({ 92 | message: expect.stringContaining('DynamoDB transaction error') 93 | }) 94 | }) 95 | 96 | it('Should see this tweet when he calls getLikes', async () => { 97 | const { tweets, nextToken } = await when.a_user_calls_getLikes(userA, userA.username, 25) 98 | 99 | expect(nextToken).toBeNull() 100 | expect(tweets).toHaveLength(1) 101 | expect(tweets[0]).toMatchObject({ 102 | ...tweet, 103 | liked: true, 104 | likes: 1, 105 | profile: { 106 | ...tweet.profile, 107 | likesCounts: 1 108 | } 109 | }) 110 | }) 111 | 112 | describe('When he unlikes the tweet', () => { 113 | beforeAll(async () => { 114 | await when.a_user_calls_unlike(userA, tweet.id) 115 | }) 116 | 117 | it('Should see Tweet.liked as false', async () => { 118 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 119 | 120 | expect(tweets).toHaveLength(1) 121 | expect(tweets[0].id).toEqual(tweet.id) 122 | expect(tweets[0].liked).toEqual(false) 123 | }) 124 | 125 | it('Should not be able to unlike the same tweet a second time', async () => { 126 | await expect(() => when.a_user_calls_unlike(userA, tweet.id)) 127 | .rejects 128 | .toMatchObject({ 129 | message: expect.stringContaining('DynamoDB transaction error') 130 | }) 131 | }) 132 | 133 | it('Should not see this tweet when he calls getLikes anymore', async () => { 134 | const { tweets, nextToken } = await when.a_user_calls_getLikes(userA, userA.username, 25) 135 | 136 | expect(nextToken).toBeNull() 137 | expect(tweets).toHaveLength(0) 138 | }) 139 | }) 140 | }) 141 | 142 | describe('When he retweets the tweet', () => { 143 | beforeAll(async () => { 144 | await when.a_user_calls_retweet(userA, tweet.id) 145 | }) 146 | 147 | it('Should see the retweet when he calls getTweets', async () => { 148 | const { tweets } = await when.a_user_calls_getTweets(userA, userA.username, 25) 149 | 150 | expect(tweets).toHaveLength(2) 151 | expect(tweets[0]).toMatchObject({ 152 | profile: { 153 | id: userA.username, 154 | tweetsCount: 2 155 | }, 156 | retweetOf: { 157 | ...tweet, 158 | retweets: 1, 159 | retweeted: true, 160 | profile: { 161 | id: userA.username, 162 | tweetsCount: 2 163 | } 164 | } 165 | }) 166 | expect(tweets[1]).toMatchObject({ 167 | ...tweet, 168 | retweets: 1, 169 | retweeted: true, 170 | profile: { 171 | id: userA.username, 172 | tweetsCount: 2 173 | } 174 | }) 175 | }) 176 | 177 | it('Should not see the retweet when he calls getMyTimeline', async () => { 178 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 179 | 180 | expect(tweets).toHaveLength(1) 181 | expect(tweets[0]).toMatchObject({ 182 | ...tweet, 183 | retweets: 1, 184 | retweeted: true, 185 | profile: { 186 | id: userA.username, 187 | tweetsCount: 2 188 | } 189 | }) 190 | }) 191 | 192 | describe('When he unretweets the tweet', () => { 193 | beforeAll(async () => { 194 | await when.a_user_calls_unretweet(userA, tweet.id) 195 | }) 196 | 197 | it('Should not see the retweet when he calls getTweets anymore', async () => { 198 | const { tweets } = await when.a_user_calls_getTweets(userA, userA.username, 25) 199 | 200 | expect(tweets).toHaveLength(1) 201 | expect(tweets[0]).toMatchObject({ 202 | ...tweet, 203 | retweets: 0, 204 | retweeted: false, 205 | profile: { 206 | id: userA.username, 207 | tweetsCount: 1 208 | } 209 | }) 210 | }) 211 | }) 212 | }) 213 | 214 | describe('Given another user, user B, sends a tweet', () => { 215 | let userB, anotherTweet 216 | const text = chance.string({ length: 16 }) 217 | beforeAll(async () => { 218 | userB = await given.an_authenticated_user() 219 | anotherTweet = await when.a_user_calls_tweet(userB, text) 220 | }) 221 | 222 | describe("When user A retweets user B's tweet", () => { 223 | beforeAll(async () => { 224 | await when.a_user_calls_retweet(userA, anotherTweet.id) 225 | }) 226 | 227 | it('Should see the retweet when he calls getTweets', async () => { 228 | const { tweets } = await when.a_user_calls_getTweets(userA, userA.username, 25) 229 | 230 | expect(tweets).toHaveLength(2) 231 | expect(tweets[0]).toMatchObject({ 232 | profile: { 233 | id: userA.username, 234 | tweetsCount: 2 235 | }, 236 | retweetOf: { 237 | ...anotherTweet, 238 | retweets: 1, 239 | retweeted: true 240 | } 241 | }) 242 | }) 243 | 244 | it('Should see the retweet when he calls getMyTimeline', async () => { 245 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 246 | 247 | expect(tweets).toHaveLength(2) 248 | expect(tweets[0]).toMatchObject({ 249 | profile: { 250 | id: userA.username, 251 | tweetsCount: 2 252 | }, 253 | retweetOf: { 254 | ...anotherTweet, 255 | retweets: 1, 256 | retweeted: true 257 | } 258 | }) 259 | }) 260 | 261 | describe("When user A unretweets user B's tweet", () => { 262 | beforeAll(async () => { 263 | await when.a_user_calls_unretweet(userA, anotherTweet.id) 264 | }) 265 | 266 | it('User A should not see the retweet when he calls getTweets anymore', async () => { 267 | const { tweets } = await when.a_user_calls_getTweets(userA, userA.username, 25) 268 | 269 | expect(tweets).toHaveLength(1) 270 | expect(tweets[0]).toMatchObject({ 271 | ...tweet, 272 | retweets: 0, 273 | retweeted: false, 274 | profile: { 275 | id: userA.username, 276 | tweetsCount: 1 277 | } 278 | }) 279 | }) 280 | 281 | it('User A should not see the retweet when he calls getMyTimeline anymore', async () => { 282 | const { tweets } = await when.a_user_calls_getMyTimeline(userA, 25) 283 | 284 | expect(tweets).toHaveLength(1) 285 | expect(tweets[0]).toMatchObject({ 286 | ...tweet, 287 | profile: { 288 | id: userA.username, 289 | tweetsCount: 1 290 | } 291 | }) 292 | }) 293 | }) 294 | }) 295 | }) 296 | }) 297 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/e2e/user-profile.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const given = require('../../steps/given') 3 | const then = require('../../steps/then') 4 | const when = require('../../steps/when') 5 | const chance = require('chance').Chance() 6 | const path = require('path') 7 | 8 | describe('Given an authenticated user', () => { 9 | let user, profile 10 | beforeAll(async () => { 11 | user = await given.an_authenticated_user() 12 | }) 13 | 14 | it('The user can fetch his profile with getMyProfile', async () => { 15 | profile = await when.a_user_calls_getMyProfile(user) 16 | 17 | expect(profile).toMatchObject({ 18 | id: user.username, 19 | name: user.name, 20 | imageUrl: null, 21 | backgroundImageUrl: null, 22 | bio: null, 23 | location: null, 24 | website: null, 25 | birthdate: null, 26 | createdAt: expect.stringMatching(/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d+)?Z?/g), 27 | // tweets 28 | followersCount: 0, 29 | followingCount: 0, 30 | tweetsCount: 0, 31 | likesCounts: 0, 32 | tweets: { 33 | nextToken: null, 34 | tweets: [] 35 | } 36 | }) 37 | 38 | const [firstName, lastName] = profile.name.split(' ') 39 | expect(profile.screenName).toContain(firstName) 40 | expect(profile.screenName).toContain(lastName) 41 | }) 42 | 43 | it('The user can get an URL to upload new profile image', async () => { 44 | const uploadUrl = await when.a_user_calls_getImageUploadUrl(user, '.png', 'image/png') 45 | 46 | const bucketName = process.env.BUCKET_NAME 47 | const regex = new RegExp(`https://${bucketName}.s3-accelerate.amazonaws.com/${user.username}/.*\.png\?.*Content-Type=image%2Fpng.*`) 48 | expect(uploadUrl).toMatch(regex) 49 | 50 | const filePath = path.join(__dirname, '../../data/logo.png') 51 | await then.user_can_upload_image_to_url(uploadUrl, filePath, 'image/png') 52 | 53 | const downloadUrl = uploadUrl.split('?')[0] 54 | await then.user_can_download_image_from(downloadUrl) 55 | }) 56 | 57 | it('The user can edit his profile with editMyProfile', async () => { 58 | const newName = chance.first() 59 | const input = { 60 | name: newName 61 | } 62 | const newProfile = await when.a_user_calls_editMyProfile(user, input) 63 | 64 | expect(newProfile).toMatchObject({ 65 | ...profile, 66 | name: newName 67 | }) 68 | }) 69 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/confirm-user-signup.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('When confirmUserSignup runs', () => { 7 | it("The user's profile should be saved in DynamoDB", async () => { 8 | const { name, email } = given.a_random_user() 9 | const username = chance.guid() 10 | 11 | await when.we_invoke_confirmUserSignup(username, name, email) 12 | 13 | const ddbUser = await then.user_exists_in_UsersTable(username) 14 | expect(ddbUser).toMatchObject({ 15 | id: username, 16 | name, 17 | createdAt: expect.stringMatching(/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d+)?Z?/g), 18 | followersCount: 0, 19 | followingCount: 0, 20 | tweetsCount: 0, 21 | likesCounts: 0 22 | }) 23 | 24 | const [firstName, lastName] = name.split(' ') 25 | expect(ddbUser.screenName).toContain(firstName) 26 | expect(ddbUser.screenName).toContain(lastName) 27 | }) 28 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/distribute-tweets-to-follower.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given use A and user B', () => { 7 | let userA, userB 8 | let userAsTweet1, userAsTweet2 9 | beforeAll(async () => { 10 | userA = await given.an_authenticated_user() 11 | userB = await given.an_authenticated_user() 12 | userAsTweet1 = await when.we_invoke_tweet(userA.username, chance.paragraph()) 13 | userAsTweet2 = await when.we_invoke_tweet(userA.username, chance.paragraph()) 14 | }) 15 | 16 | describe('When user B follows user A', () => { 17 | beforeAll(async () => { 18 | const event = require('../../data/new-follower.json') 19 | const { NewImage } = event.Records[0].dynamodb 20 | NewImage.userId.S = userB.username 21 | NewImage.otherUserId.S = userA.username 22 | NewImage.sk.S = `FOLLOWS_${userA.username}` 23 | await when.we_invoke_distributeTweetsToFollower(event) 24 | }) 25 | 26 | it("Adds user A's tweets to user B's timeline", async () => { 27 | await then.tweet_exists_in_TimelinesTable(userB.username, userAsTweet1.id) 28 | await then.tweet_exists_in_TimelinesTable(userB.username, userAsTweet2.id) 29 | }) 30 | 31 | describe('When user B unfollows user A', () => { 32 | beforeAll(async () => { 33 | const event = require('../../data/delete-follower.json') 34 | const { OldImage } = event.Records[0].dynamodb 35 | OldImage.userId.S = userB.username 36 | OldImage.otherUserId.S = userA.username 37 | OldImage.sk.S = `FOLLOWS_${userA.username}` 38 | await when.we_invoke_distributeTweetsToFollower(event) 39 | }) 40 | 41 | it("Removes user A's tweets from user B's timeline", async () => { 42 | await then.tweet_does_not_exist_in_TimelinesTable(userB.username, userAsTweet1.id) 43 | await then.tweet_does_not_exist_in_TimelinesTable(userB.username, userAsTweet2.id) 44 | }) 45 | }) 46 | }) 47 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/distribute-tweets.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given use A follows user B', () => { 7 | let userA, userB 8 | beforeAll(async () => { 9 | userA = await given.an_authenticated_user() 10 | userB = await given.an_authenticated_user() 11 | await given.a_user_follows_another(userA.username, userB.username) 12 | }) 13 | 14 | describe('When user B sends a new tweet', () => { 15 | const tweetId = chance.guid() 16 | beforeAll(async () => { 17 | const event = require('../../data/new-tweet.json') 18 | const { NewImage } = event.Records[0].dynamodb 19 | NewImage.creator.S = userB.username 20 | NewImage.id.S = tweetId 21 | await when.we_invoke_distributeTweets(event) 22 | }) 23 | 24 | it("Adds user B's tweet to user A's timeline", async () => { 25 | await then.tweet_exists_in_TimelinesTable(userA.username, tweetId) 26 | }) 27 | 28 | describe('When user B deletes the tweet', () => { 29 | const tweetId = chance.guid() 30 | beforeAll(async () => { 31 | const event = require('../../data/delete-tweet.json') 32 | const { OldImage } = event.Records[0].dynamodb 33 | OldImage.creator.S = userB.username 34 | OldImage.id.S = tweetId 35 | await when.we_invoke_distributeTweets(event) 36 | }) 37 | 38 | it("Removes user B's tweet from user A's timeline", async () => { 39 | await then.tweet_does_not_exist_in_TimelinesTable(userA.username, tweetId) 40 | }) 41 | }) 42 | }) 43 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/reply.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given two authenticated users, use A and user B', () => { 7 | let userA, userB 8 | beforeAll(async () => { 9 | userA = await given.an_authenticated_user() 10 | userB = await given.an_authenticated_user() 11 | }) 12 | 13 | describe('When user A sends a tweet', () => { 14 | let tweet 15 | const text = chance.string({ length: 16 }) 16 | beforeAll(async () => { 17 | tweet = await when.we_invoke_tweet(userA.username, text) 18 | }) 19 | 20 | describe("When user B replies to user A's tweet", () => { 21 | const replyText = chance.string({ length: 16 }) 22 | beforeAll(async () => { 23 | await when.we_invoke_reply(userB.username, tweet.id, replyText) 24 | }) 25 | 26 | it('Saves the reply in the Tweets table', async () => { 27 | const reply = await then.reply_exists_in_TweetsTable(userB.username, tweet.id) 28 | 29 | expect(reply).toMatchObject({ 30 | text: replyText, 31 | replies: 0, 32 | likes: 0, 33 | retweets: 0, 34 | inReplyToTweetId: tweet.id, 35 | inReplyToUserIds: [userA.username] 36 | }) 37 | }) 38 | 39 | it('Increments the replies count in the Tweets table', async () => { 40 | const { replies } = await then.tweet_exists_in_TweetsTable(tweet.id) 41 | 42 | expect(replies).toEqual(1) 43 | }) 44 | 45 | it('Increments the tweetsCount in the Users table', async () => { 46 | await then.tweetsCount_is_updated_in_UsersTable(userB.username, 1) 47 | }) 48 | 49 | it("Saves the reply in the Timelines tables", async () => { 50 | const tweets = await then.there_are_N_tweets_in_TimelinesTable(userB.username, 1) 51 | 52 | expect(tweets[0].inReplyToTweetId).toEqual(tweet.id) 53 | }) 54 | 55 | describe("When user A replies to user B's reply", () => { 56 | let userBsReply 57 | const replyText = chance.string({ length: 16 }) 58 | beforeAll(async () => { 59 | userBsReply = await then.reply_exists_in_TweetsTable(userB.username, tweet.id) 60 | await when.we_invoke_reply(userA.username, userBsReply.id, replyText) 61 | }) 62 | 63 | it('Saves the reply in the Tweets table', async () => { 64 | const reply = await then.reply_exists_in_TweetsTable(userA.username, userBsReply.id) 65 | 66 | expect(reply).toMatchObject({ 67 | text: replyText, 68 | replies: 0, 69 | likes: 0, 70 | retweets: 0, 71 | inReplyToTweetId: userBsReply.id, 72 | inReplyToUserIds: expect.arrayContaining([userA.username, userB.username]) 73 | }) 74 | expect(reply.inReplyToUserIds).toHaveLength(2) 75 | }) 76 | }) 77 | }) 78 | 79 | describe("When user B retweets user A's tweet", () => { 80 | let userBsRetweet 81 | beforeAll(async () => { 82 | await when.we_invoke_retweet(userB.username, tweet.id) 83 | userBsRetweet = await then.retweet_exists_in_TweetsTable(userB.username, tweet.id) 84 | }) 85 | 86 | describe("When user A replies to user B's retweet", () => { 87 | const replyText = chance.string({ length: 16 }) 88 | beforeAll(async () => { 89 | await when.we_invoke_reply(userA.username, userBsRetweet.id, replyText) 90 | }) 91 | 92 | it('Saves the reply in the Tweets table', async () => { 93 | const reply = await then.reply_exists_in_TweetsTable(userA.username, userBsRetweet.id) 94 | 95 | expect(reply).toMatchObject({ 96 | text: replyText, 97 | replies: 0, 98 | likes: 0, 99 | retweets: 0, 100 | inReplyToTweetId: userBsRetweet.id, 101 | inReplyToUserIds: expect.arrayContaining([userA.username, userB.username]) 102 | }) 103 | expect(reply.inReplyToUserIds).toHaveLength(2) 104 | }) 105 | }) 106 | }) 107 | }) 108 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/retweet.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given an authenticated user with a tweet', () => { 7 | let userA, tweet 8 | const text = chance.string({ length: 16 }) 9 | beforeAll(async () => { 10 | userA = await given.an_authenticated_user() 11 | tweet = await when.we_invoke_tweet(userA.username, text) 12 | }) 13 | 14 | describe('When he retweets his own tweet', () => { 15 | beforeAll(async () => { 16 | await when.we_invoke_retweet(userA.username, tweet.id) 17 | }) 18 | 19 | it('Saves the retweet in the Tweets table', async () => { 20 | await then.retweet_exists_in_TweetsTable(userA.username, tweet.id) 21 | }) 22 | 23 | it('Saves the retweet in the Retweets table', async () => { 24 | await then.retweet_exists_in_RetweetsTable(userA.username, tweet.id) 25 | }) 26 | 27 | it('Increments the retweets count in the Tweets table', async () => { 28 | const { retweets } = await then.tweet_exists_in_TweetsTable(tweet.id) 29 | 30 | expect(retweets).toEqual(1) 31 | }) 32 | 33 | it('Increments the tweetsCount in the Users table', async () => { 34 | await then.tweetsCount_is_updated_in_UsersTable(userA.username, 2) 35 | }) 36 | 37 | it("Doesn't save the retweet in the Timelines tables", async () => { 38 | const tweets = await then.there_are_N_tweets_in_TimelinesTable(userA.username, 1) 39 | 40 | expect(tweets[0].tweetId).toEqual(tweet.id) 41 | }) 42 | }) 43 | 44 | describe("When he retweets another user's tweet", () => { 45 | let userB, anotherTweet 46 | const text = chance.string({ length: 16 }) 47 | beforeAll(async () => { 48 | userB = await given.an_authenticated_user() 49 | anotherTweet = await when.we_invoke_tweet(userB.username, text) 50 | await when.we_invoke_retweet(userA.username, anotherTweet.id) 51 | }) 52 | 53 | it('Saves the retweet in the Tweets table', async () => { 54 | await then.retweet_exists_in_TweetsTable(userA.username, anotherTweet.id) 55 | }) 56 | 57 | it('Saves the retweet in the Retweets table', async () => { 58 | await then.retweet_exists_in_RetweetsTable(userA.username, anotherTweet.id) 59 | }) 60 | 61 | it('Increments the retweets count in the Tweets table', async () => { 62 | const { retweets } = await then.tweet_exists_in_TweetsTable(anotherTweet.id) 63 | 64 | expect(retweets).toEqual(1) 65 | }) 66 | 67 | it('Increments the tweetsCount in the Users table', async () => { 68 | await then.tweetsCount_is_updated_in_UsersTable(userA.username, 3) 69 | }) 70 | 71 | it("Saves the retweet in the Timelines tables", async () => { 72 | const tweets = await then.there_are_N_tweets_in_TimelinesTable(userA.username, 2) 73 | 74 | expect(tweets[0].retweetOf).toEqual(anotherTweet.id) 75 | expect(tweets[1].tweetId).toEqual(tweet.id) 76 | }) 77 | }) 78 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/send-direct-message.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe(`Given two authenticated users`, () => { 7 | let userA, userB 8 | beforeAll(async () => { 9 | userA = await given.an_authenticated_user() 10 | userB = await given.an_authenticated_user() 11 | }) 12 | 13 | describe('When user A sends a DM to user B', () => { 14 | let conversation 15 | const text = chance.string({ length: 16 }) 16 | beforeAll(async () => { 17 | conversation = await when.we_invoke_sendDirectMessage(userA.username, userB.username, text) 18 | }) 19 | 20 | it('Saves the message in the DirectMessages table', async () => { 21 | const [ message ] = await then.there_are_N_messages_in_DirectMessagesTable(conversation.id, 1) 22 | 23 | expect(message).toMatchObject({ 24 | conversationId: conversation.id, 25 | message: text, 26 | from: userA.username 27 | }) 28 | }) 29 | 30 | it('Saves the conversation in the Conversations table for both user A and user B', async () => { 31 | await then.conversation_exists_in_ConversationsTable(userA.username, userB.username) 32 | await then.conversation_exists_in_ConversationsTable(userB.username, userA.username) 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /__tests__/test_cases/integration/tweet.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe('Given an authenticated user', () => { 7 | let user 8 | beforeAll(async () => { 9 | user = await given.an_authenticated_user() 10 | }) 11 | 12 | describe('When he sends a tweet', () => { 13 | let tweet 14 | const text = chance.string({ length: 16 }) 15 | beforeAll(async () => { 16 | tweet = await when.we_invoke_tweet(user.username, text) 17 | }) 18 | 19 | it('Saves the tweet in the Tweets table', async () => { 20 | await then.tweet_exists_in_TweetsTable(tweet.id) 21 | }) 22 | 23 | it('Saves the tweet in the Timelines table', async () => { 24 | await then.tweet_exists_in_TimelinesTable(user.username, tweet.id) 25 | }) 26 | 27 | it('Increments the tweetsCount in the Users table to 1', async () => { 28 | await then.tweetsCount_is_updated_in_UsersTable(user.username, 1) 29 | }) 30 | }) 31 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/integration/unretweet.tests.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const then = require('../../steps/then') 4 | const chance = require('chance').Chance() 5 | 6 | describe("Given an authenticated user retweeted another user's tweet", () => { 7 | let userA, userB, tweet 8 | const text = chance.string({ length: 16 }) 9 | beforeAll(async () => { 10 | userA = await given.an_authenticated_user() 11 | userB = await given.an_authenticated_user() 12 | tweet = await when.we_invoke_tweet(userB.username, text) 13 | await when.we_invoke_retweet(userA.username, tweet.id) 14 | }) 15 | 16 | describe("When user A unretweets user B's tweet", () => { 17 | beforeAll(async () => { 18 | await when.we_invoke_unretweet(userA.username, tweet.id) 19 | }) 20 | 21 | it('Removes the retweet from the Tweets table', async () => { 22 | await then.retweet_does_not_exist_in_TweetsTable(userA.username, tweet.id) 23 | }) 24 | 25 | it('Removes the retweet from the Retweets table', async () => { 26 | await then.retweet_does_not_exist_in_RetweetsTable(userA.username, tweet.id) 27 | }) 28 | 29 | it('Decrements the retweets count in the Tweets table', async () => { 30 | const { retweets } = await then.tweet_exists_in_TweetsTable(tweet.id) 31 | 32 | expect(retweets).toEqual(0) 33 | }) 34 | 35 | it('Decrements the tweetsCount in the Users table', async () => { 36 | await then.tweetsCount_is_updated_in_UsersTable(userA.username, 0) 37 | }) 38 | 39 | it("Removes the retweet from the Timelines tables", async () => { 40 | await then.there_are_N_tweets_in_TimelinesTable(userA.username, 0) 41 | }) 42 | }) 43 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Mutation.editMyProfile.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Mutation.editMyProfile.request template', () => { 7 | it("Should use 'newProfile' fields in expression values", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Mutation.editMyProfile.request.vtl') 9 | 10 | const username = chance.guid() 11 | const newProfile = { 12 | name: 'Yan', 13 | imageUrl: null, 14 | backgroundImageUrl: null, 15 | bio: 'test', 16 | location: null, 17 | website: null, 18 | birthdate: null, 19 | } 20 | const context = given.an_appsync_context({ username }, { newProfile }) 21 | const result = when.we_invoke_an_appsync_template(templatePath, context) 22 | 23 | expect(result).toEqual({ 24 | "version" : "2018-05-29", 25 | "operation" : "UpdateItem", 26 | "key": { 27 | "id" : { 28 | S: username 29 | } 30 | }, 31 | "update" : { 32 | "expression" : "set #name = :name, imageUrl = :imageUrl, backgroundImageUrl = :backgroundImageUrl, bio = :bio, #location = :location, website = :website, birthdate = :birthdate", 33 | "expressionNames" : { 34 | "#name" : "name", 35 | "#location" : "location" 36 | }, 37 | "expressionValues" : { 38 | ":name" : { 39 | S: 'Yan' 40 | }, 41 | ":imageUrl" : { 42 | NULL: true 43 | }, 44 | ":backgroundImageUrl" : { 45 | NULL: true 46 | }, 47 | ":bio" : { 48 | S: 'test' 49 | }, 50 | ":location" : { 51 | NULL: true 52 | }, 53 | ":website" : { 54 | NULL: true 55 | }, 56 | ":birthdate" : { 57 | NULL: true 58 | }, 59 | } 60 | }, 61 | "condition" : { 62 | "expression" : "attribute_exists(id)" 63 | } 64 | }) 65 | }) 66 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Query.getMyProfile.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Query.getMyProfile.request template', () => { 7 | it("Should use username as 'id'", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Query.getMyProfile.request.vtl') 9 | 10 | const username = chance.guid() 11 | const context = given.an_appsync_context({ username }, {}) 12 | const result = when.we_invoke_an_appsync_template(templatePath, context) 13 | 14 | expect(result).toEqual({ 15 | "version" : "2018-05-29", 16 | "operation" : "GetItem", 17 | "key" : { 18 | "id" : { 19 | "S": username 20 | } 21 | } 22 | }) 23 | }) 24 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Query.getTweets.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Query.getTweets.request template', () => { 7 | it("Should error if limit is over 25", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Query.getTweets.request.vtl') 9 | 10 | const username = chance.guid() 11 | const context = given.an_appsync_context({ username }, { userId: username, limit: 26, nextToken: null }) 12 | expect(() => when.we_invoke_an_appsync_template(templatePath, context)) 13 | .toThrowError('max limit is 25') 14 | }) 15 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Reply.inReplyToUsers.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Reply.inReplyToUsers.request template', () => { 7 | it("Should not short-circuit if selectionSetList has more than just 'id'", () => { 8 | const templatePath = path.resolve( 9 | __dirname, '../../../mapping-templates/Reply.inReplyToUsers.request.vtl') 10 | 11 | const username = chance.guid() 12 | const info = { 13 | selectionSetList: ['id', 'bio'] 14 | } 15 | const inReplyToUserIds = [ 16 | username 17 | ] 18 | const context = given.an_appsync_context({ username }, {}, {}, { inReplyToUserIds }, info) 19 | const result = when.we_invoke_an_appsync_template(templatePath, context) 20 | 21 | expect(result).toEqual({ 22 | "version" : "2018-05-29", 23 | "operation" : "BatchGetItem", 24 | "tables" : { 25 | "${UsersTable}": { 26 | "keys": [{ 27 | "id": { 28 | "S": username 29 | } 30 | }], 31 | "consistentRead": false 32 | } 33 | } 34 | }) 35 | }) 36 | 37 | it("Should short-circuit if selectionSetList has only 'id'", () => { 38 | const templatePath = path.resolve( 39 | __dirname, '../../../mapping-templates/Reply.inReplyToUsers.request.vtl') 40 | 41 | const username1 = chance.guid() 42 | const username2 = chance.guid() 43 | const info = { 44 | selectionSetList: ['id'] 45 | } 46 | const inReplyToUserIds = [ 47 | username1, 48 | username2 49 | ] 50 | const context = given.an_appsync_context( 51 | { username: username1 }, {}, {}, { inReplyToUserIds }, info) 52 | const result = when.we_invoke_an_appsync_template(templatePath, context) 53 | 54 | expect(result).toEqual([{ 55 | id: username1, 56 | __typename: 'MyProfile' 57 | }, { 58 | id: username2, 59 | __typename: 'OtherProfile' 60 | }]) 61 | }) 62 | 63 | it("Should short-circuit if inReplyToUsers array is empty", () => { 64 | const templatePath = path.resolve( 65 | __dirname, '../../../mapping-templates/Reply.inReplyToUsers.request.vtl') 66 | 67 | const username = chance.guid() 68 | const info = { 69 | selectionSetList: ['id'] 70 | } 71 | const inReplyToUserIds = [] 72 | const context = given.an_appsync_context( 73 | { username }, {}, {}, { inReplyToUserIds }, info) 74 | const result = when.we_invoke_an_appsync_template(templatePath, context) 75 | 76 | expect(result).toEqual([]) 77 | }) 78 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Tweet.profile.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Tweet.profile.request template', () => { 7 | it("Should not short-circuit if selectionSetList has more than just 'id'", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Tweet.profile.request.vtl') 9 | 10 | const username = chance.guid() 11 | const info = { 12 | selectionSetList: ['id', 'bio'] 13 | } 14 | const context = given.an_appsync_context({ username }, {}, {}, { creator: username }, info) 15 | const result = when.we_invoke_an_appsync_template(templatePath, context) 16 | 17 | expect(result).toEqual({ 18 | "version" : "2018-05-29", 19 | "operation" : "GetItem", 20 | "key" : { 21 | "id" : { 22 | "S": username 23 | } 24 | } 25 | }) 26 | }) 27 | 28 | it("Should short-circuit if selectionSetList has only 'id'", () => { 29 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Tweet.profile.request.vtl') 30 | 31 | const username = chance.guid() 32 | const info = { 33 | selectionSetList: ['id'] 34 | } 35 | const context = given.an_appsync_context({ username }, {}, {}, { creator: username }, info) 36 | const result = when.we_invoke_an_appsync_template(templatePath, context) 37 | 38 | expect(result).toEqual({ 39 | id: username, 40 | __typename: 'MyProfile' 41 | }) 42 | }) 43 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/Tweet.profile.response.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('Tweet.profile.response template', () => { 7 | it("Should set __typename as 'MyProfile' for current user", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Tweet.profile.response.vtl') 9 | 10 | const username = chance.guid() 11 | const context = given.an_appsync_context({ username }, {}, { id: username }) 12 | const result = when.we_invoke_an_appsync_template(templatePath, context) 13 | 14 | expect(result).toEqual({ 15 | id: username, 16 | __typename: 'MyProfile' 17 | }) 18 | }) 19 | 20 | it("Should set __typename as 'OtherProfile' for other users", () => { 21 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/Tweet.profile.response.vtl') 22 | 23 | const username = chance.guid() 24 | const id = chance.guid() 25 | const context = given.an_appsync_context({ username }, {}, { id }) 26 | const result = when.we_invoke_an_appsync_template(templatePath, context) 27 | 28 | expect(result).toEqual({ 29 | id, 30 | __typename: 'OtherProfile' 31 | }) 32 | }) 33 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/UnhydratedTweetsPage.tweets.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('UnhydratedTweetsPage.tweets.request template', () => { 7 | it("Should return empty array if source.tweets is empty", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/UnhydratedTweetsPage.tweets.request.vtl') 9 | 10 | const username = chance.guid() 11 | const context = given.an_appsync_context({ username }, {}, {}, { tweets: [] }) 12 | const result = when.we_invoke_an_appsync_template(templatePath, context) 13 | 14 | expect(result).toEqual([]) 15 | }) 16 | 17 | it("Should convert timeline tweets to BatchGetItem keys", () => { 18 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/UnhydratedTweetsPage.tweets.request.vtl') 19 | 20 | const username = chance.guid() 21 | const tweetId = chance.guid() 22 | const tweets = [{ 23 | userId: username, 24 | tweetId 25 | }] 26 | const context = given.an_appsync_context({ username }, {}, {}, { tweets }) 27 | const result = when.we_invoke_an_appsync_template(templatePath, context) 28 | 29 | expect(result).toEqual({ 30 | "version" : "2018-05-29", 31 | "operation" : "BatchGetItem", 32 | "tables" : { 33 | "${TweetsTable}": { 34 | "keys": [{ 35 | "id": { 36 | "S": tweetId 37 | } 38 | }], 39 | "consistentRead": false 40 | } 41 | } 42 | }) 43 | }) 44 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/get-upload-url.tests.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | 5 | describe('When getImageUploadUrl runs', () => { 6 | it.each([ 7 | [ '.png', 'image/png' ], 8 | [ '.jpeg', 'image/jpeg' ], 9 | [ '.png', null ], 10 | [ null, 'image/png' ], 11 | [ null, null ], 12 | ])('Returns a signed S3 url for extension %s and content type %s', async (extension, contentType) => { 13 | const username = chance.guid() 14 | const signedUrl = await when.we_invoke_getImageUploadUrl(username, extension, contentType) 15 | 16 | const { BUCKET_NAME } = process.env 17 | const regex = new RegExp(`https://${BUCKET_NAME}.s3-accelerate.amazonaws.com/${username}/.*${extension || ''}\?.*Content-Type=${contentType ? contentType.replace('/', '%2F') : 'image%2Fjpeg'}.*`) 18 | expect(signedUrl).toMatch(regex) 19 | }) 20 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/hydrateFollowers.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('hydrateFollowers.request template', () => { 7 | it("Should return empty array if prev.result.relationships is empty", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/hydrateFollowers.request.vtl') 9 | 10 | const username = chance.guid() 11 | const prev = { 12 | result: { 13 | relationships: [] 14 | } 15 | } 16 | const context = given.an_appsync_context({ username }, {}, {}, {}, {}, prev) 17 | const result = when.we_invoke_an_appsync_template(templatePath, context) 18 | 19 | expect(result).toEqual({ 20 | profiles: [] 21 | }) 22 | }) 23 | 24 | it("Should convert relationships to BatchGetItem keys", () => { 25 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/hydrateFollowers.request.vtl') 26 | 27 | const username = chance.guid() 28 | const userId = chance.guid() 29 | const otherUserId = chance.guid() 30 | const relationships = [{ 31 | userId, 32 | sk: `FOLLOWS_${otherUserId}`, 33 | otherUserId 34 | }] 35 | const prev = { 36 | result: { 37 | relationships 38 | } 39 | } 40 | const context = given.an_appsync_context({ username }, {}, {}, {}, {}, prev) 41 | const result = when.we_invoke_an_appsync_template(templatePath, context) 42 | 43 | expect(result).toEqual({ 44 | "version" : "2018-05-29", 45 | "operation" : "BatchGetItem", 46 | "tables" : { 47 | "${UsersTable}": { 48 | "keys": [{ 49 | "id": { 50 | "S": userId 51 | } 52 | }], 53 | "consistentRead": false 54 | } 55 | } 56 | }) 57 | }) 58 | }) -------------------------------------------------------------------------------- /__tests__/test_cases/unit/hydrateFollowing.request.js: -------------------------------------------------------------------------------- 1 | const given = require('../../steps/given') 2 | const when = require('../../steps/when') 3 | const chance = require('chance').Chance() 4 | const path = require('path') 5 | 6 | describe('hydrateFollowing.request template', () => { 7 | it("Should return empty array if prev.result.relationships is empty", () => { 8 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/hydrateFollowing.request.vtl') 9 | 10 | const username = chance.guid() 11 | const prev = { 12 | result: { 13 | relationships: [] 14 | } 15 | } 16 | const context = given.an_appsync_context({ username }, {}, {}, {}, {}, prev) 17 | const result = when.we_invoke_an_appsync_template(templatePath, context) 18 | 19 | expect(result).toEqual({ 20 | profiles: [] 21 | }) 22 | }) 23 | 24 | it("Should convert relationships to BatchGetItem keys", () => { 25 | const templatePath = path.resolve(__dirname, '../../../mapping-templates/hydrateFollowing.request.vtl') 26 | 27 | const username = chance.guid() 28 | const userId = chance.guid() 29 | const otherUserId = chance.guid() 30 | const relationships = [{ 31 | userId, 32 | sk: `FOLLOWS_${otherUserId}`, 33 | otherUserId 34 | }] 35 | const prev = { 36 | result: { 37 | relationships 38 | } 39 | } 40 | const context = given.an_appsync_context({ username }, {}, {}, {}, {}, prev) 41 | const result = when.we_invoke_an_appsync_template(templatePath, context) 42 | 43 | expect(result).toEqual({ 44 | "version" : "2018-05-29", 45 | "operation" : "BatchGetItem", 46 | "tables" : { 47 | "${UsersTable}": { 48 | "keys": [{ 49 | "id": { 50 | "S": otherUserId 51 | } 52 | }], 53 | "consistentRead": false 54 | } 55 | } 56 | }) 57 | }) 58 | }) -------------------------------------------------------------------------------- /functions/confirm-user-signup.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const DocumentClient = new DynamoDB.DocumentClient() 3 | const Chance = require('chance') 4 | const chance = new Chance() 5 | 6 | const { USERS_TABLE } = process.env 7 | 8 | module.exports.handler = async (event) => { 9 | if (event.triggerSource === 'PostConfirmation_ConfirmSignUp') { 10 | const name = event.request.userAttributes['name'] 11 | const suffix = chance.string({ length: 8, casing: 'upper', alpha: true, numeric: true }) 12 | const screenName = `${name.replace(/[^a-zA-Z0-9]/g, "")}${suffix}` 13 | const user = { 14 | id: event.userName, 15 | name, 16 | screenName, 17 | createdAt: new Date().toJSON(), 18 | followersCount: 0, 19 | followingCount: 0, 20 | tweetsCount: 0, 21 | likesCounts: 0 22 | } 23 | 24 | await DocumentClient.put({ 25 | TableName: USERS_TABLE, 26 | Item: user, 27 | ConditionExpression: 'attribute_not_exists(id)' 28 | }).promise() 29 | 30 | return event 31 | } else { 32 | return event 33 | } 34 | } -------------------------------------------------------------------------------- /functions/distribute-tweets-to-follower.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const DocumentClient = new DynamoDB.DocumentClient() 4 | const Constants = require('../lib/constants') 5 | 6 | const { TWEETS_TABLE, TIMELINES_TABLE, MAX_TWEETS } = process.env 7 | const MaxTweets = parseInt(MAX_TWEETS) 8 | 9 | module.exports.handler = async (event) => { 10 | for (const record of event.Records) { 11 | if (record.eventName === 'INSERT') { 12 | const relationship = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 13 | 14 | const [relType] = relationship.sk.split('_') 15 | if (relType === 'FOLLOWS') { 16 | const tweets = await getTweets(relationship.otherUserId) 17 | await distribute(tweets, relationship.userId) 18 | } 19 | } else if (record.eventName === 'REMOVE') { 20 | const relationship = DynamoDB.Converter.unmarshall(record.dynamodb.OldImage) 21 | 22 | const [relType] = relationship.sk.split('_') 23 | if (relType === 'FOLLOWS') { 24 | const tweets = await getTimelineEntriesBy(relationship.otherUserId, relationship.userId) 25 | await undistribute(tweets, relationship.userId) 26 | } 27 | } 28 | } 29 | } 30 | 31 | async function getTweets(userId) { 32 | const loop = async (acc, exclusiveStartKey) => { 33 | const resp = await DocumentClient.query({ 34 | TableName: TWEETS_TABLE, 35 | KeyConditionExpression: 'creator = :userId', 36 | ExpressionAttributeValues: { 37 | ':userId': userId, 38 | }, 39 | IndexName: 'byCreator', 40 | ExclusiveStartKey: exclusiveStartKey 41 | }).promise() 42 | 43 | const tweets = resp.Items || [] 44 | const newAcc = acc.concat(tweets) 45 | 46 | if (resp.LastEvaluatedKey && newAcc.length < MaxTweets) { 47 | return await loop(newAcc, resp.LastEvaluatedKey) 48 | } else { 49 | return newAcc 50 | } 51 | } 52 | 53 | return await loop([]) 54 | } 55 | 56 | async function getTimelineEntriesBy(distributedFrom, userId) { 57 | const loop = async (acc, exclusiveStartKey) => { 58 | const resp = await DocumentClient.query({ 59 | TableName: TIMELINES_TABLE, 60 | KeyConditionExpression: 'userId = :userId AND distributedFrom = :distributedFrom', 61 | ExpressionAttributeValues: { 62 | ':userId': userId, 63 | ':distributedFrom': distributedFrom, 64 | }, 65 | IndexName: 'byDistributedFrom', 66 | ExclusiveStartKey: exclusiveStartKey 67 | }).promise() 68 | 69 | const tweets = resp.Items || [] 70 | const newAcc = acc.concat(tweets) 71 | 72 | if (resp.LastEvaluatedKey) { 73 | return await loop(newAcc, resp.LastEvaluatedKey) 74 | } else { 75 | return newAcc 76 | } 77 | } 78 | 79 | return await loop([]) 80 | } 81 | 82 | async function distribute(tweets, userId) { 83 | const timelineEntries = tweets.map(tweet => ({ 84 | PutRequest: { 85 | Item: { 86 | userId, 87 | tweetId: tweet.id, 88 | timestamp: tweet.createdAt, 89 | distributedFrom: tweet.creator, 90 | retweetOf: tweet.retweetOf, 91 | inReplyToTweetId: tweet.inReplyToTweetId, 92 | inReplyToUserIds: tweet.inReplyToUserIds 93 | } 94 | } 95 | })) 96 | 97 | const chunks = _.chunk(timelineEntries, Constants.DynamoDB.MAX_BATCH_SIZE) 98 | 99 | const promises = chunks.map(async chunk => { 100 | await DocumentClient.batchWrite({ 101 | RequestItems: { 102 | [TIMELINES_TABLE]: chunk 103 | } 104 | }).promise() 105 | }) 106 | 107 | await Promise.all(promises) 108 | } 109 | 110 | async function undistribute(tweets, userId) { 111 | const timelineEntries = tweets.map(tweet => ({ 112 | DeleteRequest: { 113 | Key: { 114 | userId, 115 | tweetId: tweet.tweetId 116 | } 117 | } 118 | })) 119 | 120 | const chunks = _.chunk(timelineEntries, Constants.DynamoDB.MAX_BATCH_SIZE) 121 | 122 | const promises = chunks.map(async chunk => { 123 | await DocumentClient.batchWrite({ 124 | RequestItems: { 125 | [TIMELINES_TABLE]: chunk 126 | } 127 | }).promise() 128 | }) 129 | 130 | await Promise.all(promises) 131 | } -------------------------------------------------------------------------------- /functions/distribute-tweets.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const DocumentClient = new DynamoDB.DocumentClient() 4 | const Constants = require('../lib/constants') 5 | 6 | const { RELATIONSHIPS_TABLE, TIMELINES_TABLE } = process.env 7 | 8 | module.exports.handler = async (event) => { 9 | for (const record of event.Records) { 10 | if (record.eventName === 'INSERT') { 11 | const tweet = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 12 | const followers = await getFollowers(tweet.creator) 13 | await distribute(tweet, followers) 14 | } else if (record.eventName === 'REMOVE') { 15 | const tweet = DynamoDB.Converter.unmarshall(record.dynamodb.OldImage) 16 | const followers = await getFollowers(tweet.creator) 17 | await undistribute(tweet, followers) 18 | } 19 | } 20 | } 21 | 22 | async function getFollowers(userId) { 23 | const loop = async (acc, exclusiveStartKey) => { 24 | const resp = await DocumentClient.query({ 25 | TableName: RELATIONSHIPS_TABLE, 26 | KeyConditionExpression: 'otherUserId = :otherUserId and begins_with(sk, :follows)', 27 | ExpressionAttributeValues: { 28 | ':otherUserId': userId, 29 | ':follows': 'FOLLOWS_' 30 | }, 31 | IndexName: 'byOtherUser', 32 | ExclusiveStartKey: exclusiveStartKey 33 | }).promise() 34 | 35 | const userIds = (resp.Items || []).map(x => x.userId) 36 | 37 | if (resp.LastEvaluatedKey) { 38 | return await loop(acc.concat(userIds), resp.LastEvaluatedKey) 39 | } else { 40 | return acc.concat(userIds) 41 | } 42 | } 43 | 44 | return await loop([]) 45 | } 46 | 47 | async function distribute(tweet, followers) { 48 | const timelineEntries = followers.map(userId => ({ 49 | PutRequest: { 50 | Item: { 51 | userId, 52 | tweetId: tweet.id, 53 | timestamp: tweet.createdAt, 54 | distributedFrom: tweet.creator, 55 | retweetOf: tweet.retweetOf, 56 | inReplyToTweetId: tweet.inReplyToTweetId, 57 | inReplyToUserIds: tweet.inReplyToUserIds 58 | } 59 | } 60 | })) 61 | 62 | const chunks = _.chunk(timelineEntries, Constants.DynamoDB.MAX_BATCH_SIZE) 63 | 64 | const promises = chunks.map(async chunk => { 65 | await DocumentClient.batchWrite({ 66 | RequestItems: { 67 | [TIMELINES_TABLE]: chunk 68 | } 69 | }).promise() 70 | }) 71 | 72 | await Promise.all(promises) 73 | } 74 | 75 | async function undistribute(tweet, followers) { 76 | const timelineEntries = followers.map(userId => ({ 77 | DeleteRequest: { 78 | Key: { 79 | userId, 80 | tweetId: tweet.id 81 | } 82 | } 83 | })) 84 | 85 | const chunks = _.chunk(timelineEntries, Constants.DynamoDB.MAX_BATCH_SIZE) 86 | 87 | const promises = chunks.map(async chunk => { 88 | await DocumentClient.batchWrite({ 89 | RequestItems: { 90 | [TIMELINES_TABLE]: chunk 91 | } 92 | }).promise() 93 | }) 94 | 95 | await Promise.all(promises) 96 | } -------------------------------------------------------------------------------- /functions/firehose-transformer.js: -------------------------------------------------------------------------------- 1 | module.exports.handler = async (event) => { 2 | const output = event.records.map(record => { 3 | const data = Buffer.from(record.data, 'base64').toString() 4 | const newData = data + '\n' 5 | 6 | return { 7 | recordId: record.recordId, 8 | result: 'Ok', 9 | data: Buffer.from(newData).toString('base64') 10 | } 11 | }) 12 | 13 | return { records: output } 14 | } -------------------------------------------------------------------------------- /functions/get-hash-tag.js: -------------------------------------------------------------------------------- 1 | const chance = require('chance').Chance() 2 | const { initUsersIndex, initTweetsIndex } = require('../lib/algolia') 3 | const { SearchModes } = require('../lib/constants') 4 | const middy = require('@middy/core') 5 | const ssm = require('@middy/ssm') 6 | 7 | const { STAGE } = process.env 8 | 9 | module.exports.handler = middy(async (event, context) => { 10 | const userId = event.identity.username 11 | const { hashTag, mode, limit, nextToken } = event.arguments 12 | 13 | switch (mode) { 14 | case SearchModes.PEOPLE: 15 | return await searchPeople(context, userId, hashTag, limit, nextToken) 16 | case SearchModes.LATEST: 17 | return await searchLatest(context, hashTag, limit, nextToken) 18 | default: 19 | throw new Error('Only "People" and "Latest" hash tag modes are supported right now') 20 | } 21 | }).use(ssm({ 22 | cache: true, 23 | cacheExpiryInMillis: 5 * 60 * 1000, // 5 mins 24 | names: { 25 | ALGOLIA_APP_ID: `/${STAGE}/algolia-app-id`, 26 | ALGOLIA_WRITE_KEY: `/${STAGE}/algolia-admin-key` 27 | }, 28 | setToContext: true, 29 | throwOnFailedCall: true 30 | })) 31 | 32 | async function searchPeople(context, userId, hashTag, limit, nextToken) { 33 | const index = await initUsersIndex( 34 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 35 | 36 | const searchParams = parseNextToken(nextToken) || { 37 | hitsPerPage: limit, 38 | page: 0 39 | } 40 | 41 | const query = hashTag.replace('#', '') 42 | const { hits, page, nbPages } = await index.search(query, searchParams) 43 | hits.forEach(x => { 44 | x.__typename = x.id === userId ? 'MyProfile' : 'OtherProfile' 45 | }) 46 | 47 | let nextSearchParams 48 | if (page + 1 >= nbPages) { 49 | nextSearchParams = null 50 | } else { 51 | nextSearchParams = Object.assign({}, searchParams, { page: page + 1 }) 52 | } 53 | 54 | return { 55 | results: hits, 56 | nextToken: genNextToken(nextSearchParams) 57 | } 58 | } 59 | 60 | async function searchLatest(context, hashTag, limit, nextToken) { 61 | const index = await initTweetsIndex( 62 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 63 | 64 | const searchParams = parseNextToken(nextToken) || { 65 | facetFilters: [`hashTags:${hashTag}`], 66 | hitsPerPage: limit, 67 | page: 0 68 | } 69 | 70 | const { hits, page, nbPages } = await index.search("", searchParams) 71 | 72 | let nextSearchParams 73 | if (page + 1 >= nbPages) { 74 | nextSearchParams = null 75 | } else { 76 | nextSearchParams = Object.assign({}, searchParams, { page: page + 1 }) 77 | } 78 | 79 | return { 80 | results: hits, 81 | nextToken: genNextToken(nextSearchParams) 82 | } 83 | } 84 | 85 | function parseNextToken(nextToken) { 86 | if (!nextToken) { 87 | return undefined 88 | } 89 | 90 | const token = Buffer.from(nextToken, 'base64').toString() 91 | const searchParams = JSON.parse(token) 92 | delete searchParams.random 93 | 94 | return searchParams 95 | } 96 | 97 | function genNextToken(searchParams) { 98 | if (!searchParams) { 99 | return null 100 | } 101 | 102 | const payload = Object.assign( 103 | {}, searchParams, { random: chance.string({ length: 16 }) }) 104 | const token = JSON.stringify(payload) 105 | return Buffer.from(token).toString('base64') 106 | } 107 | -------------------------------------------------------------------------------- /functions/get-tweet-creator.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const XRay = require('aws-xray-sdk-core') 3 | const DynamoDB = require('aws-sdk/clients/dynamodb') 4 | const DocumentClient = new DynamoDB.DocumentClient() 5 | XRay.captureAWSClient(DocumentClient.service) 6 | 7 | const { USERS_TABLE } = process.env 8 | 9 | module.exports.handler = async (payloads) => { 10 | const { caller, selection } = payloads[0] 11 | const userIds = payloads.map(x => x.userId) 12 | 13 | if (selection.length === 1 && selection[0] === 'id') { 14 | return userIds.map(id => ({ 15 | id, 16 | __typename: id === caller ? 'MyProfile' : 'OtherProfile' 17 | })) 18 | } 19 | 20 | const uniqUserIds = _.uniq(userIds) 21 | 22 | const resp = await DocumentClient.batchGet({ 23 | RequestItems: { 24 | [USERS_TABLE]: { 25 | Keys: uniqUserIds.map(x => ({ id: x })) 26 | } 27 | } 28 | }).promise() 29 | 30 | const users = resp.Responses[USERS_TABLE] 31 | users.forEach(user => { 32 | if (user.id === caller) { 33 | user.__typename = 'MyProfile' 34 | } else { 35 | user.__typename = 'OtherProfile' 36 | } 37 | }) 38 | 39 | // { data, errorMessage, errorType } 40 | return userIds.map(id => { 41 | const user = _.find(users, { id }) 42 | if (user) { 43 | return { data: user } 44 | } else { 45 | return { errorType: 'UserNotFound', errorMessage: 'User is not found.' } 46 | } 47 | }) 48 | } -------------------------------------------------------------------------------- /functions/get-upload-url.js: -------------------------------------------------------------------------------- 1 | const S3 = require('aws-sdk/clients/s3') 2 | const s3 = new S3({ useAccelerateEndpoint: true }) 3 | const ulid = require('ulid') 4 | 5 | const { BUCKET_NAME } = process.env 6 | 7 | module.exports.handler = async (event) => { 8 | const id = ulid.ulid() 9 | let key = `${event.identity.username}/${id}` 10 | 11 | const extension = event.arguments.extension 12 | if (extension) { 13 | if (extension.startsWith('.')) { 14 | key += extension 15 | } else { 16 | key += `.${extension}` 17 | } 18 | } 19 | 20 | const contentType = event.arguments.contentType || 'image/jpeg' 21 | if (!contentType.startsWith('image/')) { 22 | throw new Error('content type should be an image') 23 | } 24 | 25 | const params = { 26 | Bucket: BUCKET_NAME, 27 | Key: key, 28 | ACL: 'public-read', 29 | ContentType: contentType 30 | } 31 | const signedUrl = s3.getSignedUrl('putObject', params) 32 | return signedUrl 33 | } -------------------------------------------------------------------------------- /functions/notify-dmed.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const graphql = require('graphql-tag') 3 | const { mutate } = require('../lib/graphql') 4 | const ulid = require('ulid') 5 | 6 | module.exports.handler = async (event) => { 7 | for (const record of event.Records) { 8 | if (record.eventName === 'INSERT') { 9 | const dm = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 10 | await notifyDMed(dm) 11 | } 12 | } 13 | } 14 | 15 | async function notifyDMed(dm) { 16 | const userIds = dm.conversationId.split('_') 17 | const userId = userIds.filter(x => x != dm.from)[0] 18 | 19 | await mutate(graphql `mutation notifyDMed( 20 | $id: ID! 21 | $userId: ID! 22 | $otherUserId: ID! 23 | $message: String! 24 | ) { 25 | notifyDMed( 26 | id: $id 27 | userId: $userId 28 | otherUserId: $otherUserId 29 | message: $message 30 | ) { 31 | __typename 32 | ... on DMed { 33 | id 34 | type 35 | userId 36 | createdAt 37 | otherUserId 38 | message 39 | } 40 | } 41 | }`, { 42 | id: ulid.ulid(), 43 | userId: userId, 44 | otherUserId: dm.from, 45 | message: dm.message 46 | }) 47 | } -------------------------------------------------------------------------------- /functions/notify-liked.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const graphql = require('graphql-tag') 3 | const { mutate } = require('../lib/graphql') 4 | const ulid = require('ulid') 5 | const { getTweetById } = require('../lib/tweets') 6 | 7 | module.exports.handler = async (event) => { 8 | for (const record of event.Records) { 9 | if (record.eventName === 'INSERT') { 10 | const like = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 11 | await notifyLiked(like) 12 | } 13 | } 14 | } 15 | 16 | async function notifyLiked(like) { 17 | const tweet = await getTweetById(like.tweetId) 18 | await mutate(graphql `mutation notifyLiked( 19 | $id: ID! 20 | $userId: ID! 21 | $tweetId: ID! 22 | $likedBy: ID! 23 | ) { 24 | notifyLiked( 25 | id: $id 26 | userId: $userId 27 | tweetId: $tweetId 28 | likedBy: $likedBy 29 | ) { 30 | __typename 31 | ... on Liked { 32 | id 33 | type 34 | userId 35 | tweetId 36 | likedBy 37 | createdAt 38 | } 39 | } 40 | }`, { 41 | id: ulid.ulid(), 42 | userId: tweet.creator, 43 | tweetId: tweet.id, 44 | likedBy: like.userId 45 | }) 46 | } -------------------------------------------------------------------------------- /functions/notify.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const { TweetTypes } = require('../lib/constants') 4 | const graphql = require('graphql-tag') 5 | const { mutate } = require('../lib/graphql') 6 | const ulid = require('ulid') 7 | const { getTweetById, extractMentions } = require('../lib/tweets') 8 | const { getUserByScreenName } = require('../lib/users') 9 | 10 | module.exports.handler = async (event) => { 11 | for (const record of event.Records) { 12 | if (record.eventName === 'INSERT') { 13 | const tweet = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 14 | 15 | switch (tweet.__typename) { 16 | case TweetTypes.RETWEET: 17 | await notifyRetweet(tweet) 18 | break 19 | case TweetTypes.REPLY: 20 | await notifyReply(tweet.inReplyToUserIds, tweet) 21 | break 22 | } 23 | 24 | if (tweet.text) { 25 | const mentions = extractMentions(tweet.text) 26 | if (!_.isEmpty(mentions)) { 27 | await notifyMentioned(mentions, tweet) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | async function notifyRetweet(tweet) { 35 | const retweetOf = await getTweetById(tweet.retweetOf) 36 | await mutate(graphql `mutation notifyRetweeted( 37 | $id: ID! 38 | $userId: ID! 39 | $tweetId: ID! 40 | $retweetedBy: ID! 41 | $retweetId: ID! 42 | ) { 43 | notifyRetweeted( 44 | id: $id 45 | userId: $userId 46 | tweetId: $tweetId 47 | retweetedBy: $retweetedBy 48 | retweetId: $retweetId 49 | ) { 50 | __typename 51 | ... on Retweeted { 52 | id 53 | type 54 | userId 55 | tweetId 56 | retweetedBy 57 | retweetId 58 | createdAt 59 | } 60 | } 61 | }`, { 62 | id: ulid.ulid(), 63 | userId: retweetOf.creator, 64 | tweetId: tweet.retweetOf, 65 | retweetId: tweet.id, 66 | retweetedBy: tweet.creator 67 | }) 68 | } 69 | 70 | async function notifyReply(userIds, tweet) { 71 | const promises = userIds.map(userId => 72 | mutate(graphql `mutation notifyReplied( 73 | $id: ID! 74 | $userId: ID! 75 | $tweetId: ID! 76 | $replyTweetId: ID! 77 | $repliedBy: ID! 78 | ) { 79 | notifyReplied( 80 | id: $id 81 | userId: $userId 82 | tweetId: $tweetId 83 | replyTweetId: $replyTweetId 84 | repliedBy: $repliedBy 85 | ) { 86 | __typename 87 | ... on Replied { 88 | id 89 | type 90 | userId 91 | tweetId 92 | repliedBy 93 | replyTweetId 94 | createdAt 95 | } 96 | } 97 | }`, { 98 | id: ulid.ulid(), 99 | userId, 100 | tweetId: tweet.inReplyToTweetId, 101 | replyTweetId: tweet.id, 102 | repliedBy: tweet.creator 103 | }) 104 | ) 105 | 106 | await Promise.all(promises) 107 | } 108 | 109 | async function notifyMentioned(screenNames, tweet) { 110 | const promises = screenNames.map(async (screenName) => { 111 | const user = await getUserByScreenName(screenName.replace('@', '')) 112 | if (!user) { 113 | return 114 | } 115 | 116 | await mutate(graphql `mutation notifyMentioned( 117 | $id: ID! 118 | $userId: ID! 119 | $mentionedBy: ID! 120 | $mentionedByTweetId: ID! 121 | ) { 122 | notifyMentioned( 123 | id: $id 124 | userId: $userId 125 | mentionedBy: $mentionedBy 126 | mentionedByTweetId: $mentionedByTweetId 127 | ) { 128 | __typename 129 | ... on Mentioned { 130 | id 131 | type 132 | userId 133 | mentionedBy 134 | mentionedByTweetId 135 | createdAt 136 | } 137 | } 138 | }`, { 139 | id: ulid.ulid(), 140 | userId: user.id, 141 | mentionedBy: tweet.creator, 142 | mentionedByTweetId: tweet.id 143 | }) 144 | }) 145 | 146 | await Promise.all(promises) 147 | } -------------------------------------------------------------------------------- /functions/reply.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const DocumentClient = new DynamoDB.DocumentClient() 4 | const ulid = require('ulid') 5 | const { TweetTypes } = require('../lib/constants') 6 | const { getTweetById, extractHashTags } = require('../lib/tweets') 7 | 8 | const { USERS_TABLE, TIMELINES_TABLE, TWEETS_TABLE } = process.env 9 | 10 | module.exports.handler = async (event) => { 11 | const { tweetId, text } = event.arguments 12 | const { username } = event.identity 13 | const id = ulid.ulid() 14 | const timestamp = new Date().toJSON() 15 | const hashTags = extractHashTags(text) 16 | 17 | const tweet = await getTweetById(tweetId) 18 | if (!tweet) { 19 | throw new Error('Tweet is not found') 20 | } 21 | 22 | const inReplyToUserIds = await getUserIdsToReplyTo(tweet) 23 | 24 | const newTweet = { 25 | __typename: TweetTypes.REPLY, 26 | id, 27 | creator: username, 28 | createdAt: timestamp, 29 | inReplyToTweetId: tweetId, 30 | inReplyToUserIds, 31 | text, 32 | replies: 0, 33 | likes: 0, 34 | retweets: 0, 35 | hashTags 36 | } 37 | 38 | const transactItems = [{ 39 | Put: { 40 | TableName: TWEETS_TABLE, 41 | Item: newTweet 42 | } 43 | }, { 44 | Update: { 45 | TableName: TWEETS_TABLE, 46 | Key: { 47 | id: tweetId 48 | }, 49 | UpdateExpression: 'ADD replies :one', 50 | ExpressionAttributeValues: { 51 | ':one': 1 52 | }, 53 | ConditionExpression: 'attribute_exists(id)' 54 | } 55 | }, { 56 | Update: { 57 | TableName: USERS_TABLE, 58 | Key: { 59 | id: username 60 | }, 61 | UpdateExpression: 'ADD tweetsCount :one', 62 | ExpressionAttributeValues: { 63 | ':one': 1 64 | }, 65 | ConditionExpression: 'attribute_exists(id)' 66 | } 67 | }, { 68 | Put: { 69 | TableName: TIMELINES_TABLE, 70 | Item: { 71 | userId: username, 72 | tweetId: id, 73 | timestamp, 74 | inReplyToTweetId: tweetId, 75 | inReplyToUserIds 76 | } 77 | } 78 | }] 79 | 80 | await DocumentClient.transactWrite({ 81 | TransactItems: transactItems 82 | }).promise() 83 | 84 | return newTweet 85 | } 86 | 87 | async function getUserIdsToReplyTo(tweet) { 88 | let userIds = [tweet.creator] 89 | if (tweet.__typename === TweetTypes.REPLY) { 90 | userIds = userIds.concat(tweet.inReplyToUserIds) 91 | } else if (tweet.__typename === TweetTypes.RETWEET) { 92 | const retweetOf = await getTweetById(tweet.retweetOf) 93 | userIds = userIds.concat(await getUserIdsToReplyTo(retweetOf)) 94 | } 95 | 96 | return _.uniq(userIds) 97 | } -------------------------------------------------------------------------------- /functions/retweet.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const DocumentClient = new DynamoDB.DocumentClient() 3 | const ulid = require('ulid') 4 | const { TweetTypes } = require('../lib/constants') 5 | 6 | const { USERS_TABLE, TIMELINES_TABLE, TWEETS_TABLE, RETWEETS_TABLE } = process.env 7 | 8 | module.exports.handler = async (event) => { 9 | const { tweetId } = event.arguments 10 | const { username } = event.identity 11 | const id = ulid.ulid() 12 | const timestamp = new Date().toJSON() 13 | 14 | const getTweetResp = await DocumentClient.get({ 15 | TableName: TWEETS_TABLE, 16 | Key: { 17 | id: tweetId 18 | } 19 | }).promise() 20 | 21 | const tweet = getTweetResp.Item 22 | if (!tweet) { 23 | throw new Error('Tweet is not found') 24 | } 25 | 26 | const newTweet = { 27 | __typename: TweetTypes.RETWEET, 28 | id, 29 | creator: username, 30 | createdAt: timestamp, 31 | retweetOf: tweetId, 32 | } 33 | 34 | const transactItems = [{ 35 | Put: { 36 | TableName: TWEETS_TABLE, 37 | Item: newTweet 38 | } 39 | }, { 40 | Put: { 41 | TableName: RETWEETS_TABLE, 42 | Item: { 43 | userId: username, 44 | tweetId, 45 | createdAt: timestamp, 46 | }, 47 | ConditionExpression: 'attribute_not_exists(tweetId)' 48 | } 49 | }, { 50 | Update: { 51 | TableName: TWEETS_TABLE, 52 | Key: { 53 | id: tweetId 54 | }, 55 | UpdateExpression: 'ADD retweets :one', 56 | ExpressionAttributeValues: { 57 | ':one': 1 58 | }, 59 | ConditionExpression: 'attribute_exists(id)' 60 | } 61 | }, { 62 | Update: { 63 | TableName: USERS_TABLE, 64 | Key: { 65 | id: username 66 | }, 67 | UpdateExpression: 'ADD tweetsCount :one', 68 | ExpressionAttributeValues: { 69 | ':one': 1 70 | }, 71 | ConditionExpression: 'attribute_exists(id)' 72 | } 73 | }] 74 | 75 | console.log(`creator: [${tweet.creator}]; username: [${username}]`) 76 | if (tweet.creator !== username) { 77 | transactItems.push({ 78 | Put: { 79 | TableName: TIMELINES_TABLE, 80 | Item: { 81 | userId: username, 82 | tweetId: id, 83 | retweetOf: tweetId, 84 | timestamp 85 | } 86 | } 87 | }) 88 | } 89 | 90 | await DocumentClient.transactWrite({ 91 | TransactItems: transactItems 92 | }).promise() 93 | 94 | return newTweet 95 | } -------------------------------------------------------------------------------- /functions/search.js: -------------------------------------------------------------------------------- 1 | const chance = require('chance').Chance() 2 | const { initUsersIndex, initTweetsIndex } = require('../lib/algolia') 3 | const { SearchModes } = require('../lib/constants') 4 | const middy = require('@middy/core') 5 | const ssm = require('@middy/ssm') 6 | 7 | const { STAGE } = process.env 8 | 9 | module.exports.handler = middy(async (event, context) => { 10 | const userId = event.identity.username 11 | const { query, mode, limit, nextToken } = event.arguments 12 | 13 | switch (mode) { 14 | case SearchModes.PEOPLE: 15 | return await searchPeople(context, userId, query, limit, nextToken) 16 | case SearchModes.LATEST: 17 | return await searchLatest(context, query, limit, nextToken) 18 | default: 19 | throw new Error('Only "People" and "Latest" search modes are supported right now') 20 | } 21 | }).use(ssm({ 22 | cache: true, 23 | cacheExpiryInMillis: 5 * 60 * 1000, // 5 mins 24 | names: { 25 | ALGOLIA_APP_ID: `/${STAGE}/algolia-app-id`, 26 | ALGOLIA_WRITE_KEY: `/${STAGE}/algolia-admin-key` 27 | }, 28 | setToContext: true, 29 | throwOnFailedCall: true 30 | })) 31 | 32 | async function searchPeople(context, userId, query, limit, nextToken) { 33 | const index = await initUsersIndex( 34 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 35 | 36 | const searchParams = parseNextToken(nextToken) || { 37 | hitsPerPage: limit, 38 | page: 0 39 | } 40 | 41 | const { hits, page, nbPages } = await index.search(query, searchParams) 42 | hits.forEach(x => { 43 | x.__typename = x.id === userId ? 'MyProfile' : 'OtherProfile' 44 | }) 45 | 46 | let nextSearchParams 47 | if (page + 1 >= nbPages) { 48 | nextSearchParams = null 49 | } else { 50 | nextSearchParams = Object.assign({}, searchParams, { page: page + 1 }) 51 | } 52 | 53 | return { 54 | results: hits, 55 | nextToken: genNextToken(nextSearchParams) 56 | } 57 | } 58 | 59 | async function searchLatest(context, query, limit, nextToken) { 60 | const index = await initTweetsIndex( 61 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 62 | 63 | const searchParams = parseNextToken(nextToken) || { 64 | hitsPerPage: limit, 65 | page: 0 66 | } 67 | 68 | const { hits, page, nbPages } = await index.search(query, searchParams) 69 | 70 | let nextSearchParams 71 | if (page + 1 >= nbPages) { 72 | nextSearchParams = null 73 | } else { 74 | nextSearchParams = Object.assign({}, searchParams, { page: page + 1 }) 75 | } 76 | 77 | return { 78 | results: hits, 79 | nextToken: genNextToken(nextSearchParams) 80 | } 81 | } 82 | 83 | function parseNextToken(nextToken) { 84 | if (!nextToken) { 85 | return undefined 86 | } 87 | 88 | const token = Buffer.from(nextToken, 'base64').toString() 89 | const searchParams = JSON.parse(token) 90 | delete searchParams.random 91 | 92 | return searchParams 93 | } 94 | 95 | function genNextToken(searchParams) { 96 | if (!searchParams) { 97 | return null 98 | } 99 | 100 | const payload = Object.assign( 101 | {}, searchParams, { random: chance.string({ length: 16 }) }) 102 | const token = JSON.stringify(payload) 103 | return Buffer.from(token).toString('base64') 104 | } 105 | -------------------------------------------------------------------------------- /functions/send-direct-message.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const DocumentClient = new DynamoDB.DocumentClient() 3 | const ulid = require('ulid') 4 | 5 | const { CONVERSATIONS_TABLE, DIRECT_MESSAGES_TABLE } = process.env 6 | 7 | module.exports.handler = async (event) => { 8 | const { otherUserId, message } = event.arguments 9 | const { username } = event.identity 10 | const timestamp = new Date().toJSON() 11 | 12 | const conversationId = username < otherUserId 13 | ? `${username}_${otherUserId}` 14 | : `${otherUserId}_${username}` 15 | 16 | await DocumentClient.transactWrite({ 17 | TransactItems: [{ 18 | Put: { 19 | TableName: DIRECT_MESSAGES_TABLE, 20 | Item: { 21 | conversationId, 22 | messageId: ulid.ulid(), 23 | message, 24 | from: username, 25 | timestamp 26 | } 27 | } 28 | }, { 29 | Update: { 30 | TableName: CONVERSATIONS_TABLE, 31 | Key: { 32 | userId: username, 33 | otherUserId 34 | }, 35 | UpdateExpression: 'SET id = :id, lastMessage = :lastMessage, lastModified = :now', 36 | ExpressionAttributeValues: { 37 | ':id': conversationId, 38 | ':lastMessage': message, 39 | ':now': timestamp 40 | } 41 | } 42 | }, { 43 | Update: { 44 | TableName: CONVERSATIONS_TABLE, 45 | Key: { 46 | userId: otherUserId, 47 | otherUserId: username, 48 | }, 49 | UpdateExpression: 'SET id = :id, lastMessage = :lastMessage, lastModified = :now', 50 | ExpressionAttributeValues: { 51 | ':id': conversationId, 52 | ':lastMessage': message, 53 | ':now': timestamp 54 | } 55 | } 56 | }] 57 | }).promise() 58 | 59 | return { 60 | id: conversationId, 61 | otherUserId, 62 | lastMessage: message, 63 | lastModified: timestamp 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /functions/set-resolver-log-level.js: -------------------------------------------------------------------------------- 1 | const AppSync = require('aws-sdk/clients/appsync') 2 | const AppSyncClient = new AppSync() 3 | 4 | const { APPSYNC_API_ID, FIELD_LOG_LEVEL } = process.env 5 | 6 | module.exports.handler = async () => { 7 | const resp = await AppSyncClient.getGraphqlApi({ 8 | apiId: APPSYNC_API_ID 9 | }).promise() 10 | 11 | const api = resp.graphqlApi 12 | api.logConfig.fieldLogLevel = FIELD_LOG_LEVEL 13 | 14 | delete api.arn 15 | delete api.uris 16 | delete api.tags 17 | delete api.wafWebAclArn 18 | 19 | await AppSyncClient.updateGraphqlApi(api).promise() 20 | } 21 | -------------------------------------------------------------------------------- /functions/sync-tweets-to-algolia.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const middy = require('@middy/core') 3 | const ssm = require('@middy/ssm') 4 | const { initTweetsIndex } = require('../lib/algolia') 5 | const { TweetTypes } = require('../lib/constants') 6 | 7 | const { STAGE } = process.env 8 | 9 | module.exports.handler = middy(async (event, context) => { 10 | const index = await initTweetsIndex( 11 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 12 | 13 | for (const record of event.Records) { 14 | if (record.eventName === 'INSERT' || record.eventName === 'MODIFY') { 15 | const tweet = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 16 | 17 | if (tweet.__typename === TweetTypes.RETWEET) { 18 | continue 19 | } 20 | 21 | tweet.objectID = tweet.id 22 | 23 | await index.saveObjects([tweet]) 24 | } else if (record.eventName === 'REMOVE') { 25 | const tweet = DynamoDB.Converter.unmarshall(record.dynamodb.OldImage) 26 | 27 | if (tweet.__typename === TweetTypes.RETWEET) { 28 | continue 29 | } 30 | 31 | await index.deleteObjects([tweet.id]) 32 | } 33 | } 34 | }).use(ssm({ 35 | cache: true, 36 | cacheExpiryInMillis: 5 * 60 * 1000, // 5 mins 37 | names: { 38 | ALGOLIA_APP_ID: `/${STAGE}/algolia-app-id`, 39 | ALGOLIA_WRITE_KEY: `/${STAGE}/algolia-admin-key` 40 | }, 41 | setToContext: true, 42 | throwOnFailedCall: true 43 | })) -------------------------------------------------------------------------------- /functions/sync-users-to-algolia.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const { initUsersIndex } = require('../lib/algolia') 3 | const middy = require('@middy/core') 4 | const ssm = require('@middy/ssm') 5 | 6 | const { STAGE } = process.env 7 | 8 | module.exports.handler = middy(async (event, context) => { 9 | const index = await initUsersIndex( 10 | context.ALGOLIA_APP_ID, context.ALGOLIA_WRITE_KEY, STAGE) 11 | 12 | for (const record of event.Records) { 13 | if (record.eventName === 'INSERT' || record.eventName === 'MODIFY') { 14 | const profile = DynamoDB.Converter.unmarshall(record.dynamodb.NewImage) 15 | 16 | profile.objectID = profile.id 17 | 18 | await index.saveObjects([profile]) 19 | } else if (record.eventName === 'REMOVE') { 20 | const profile = DynamoDB.Converter.unmarshall(record.dynamodb.OldImage) 21 | 22 | await index.deleteObjects([profile.id]) 23 | } 24 | } 25 | }).use(ssm({ 26 | cache: true, 27 | cacheExpiryInMillis: 5 * 60 * 1000, // 5 mins 28 | names: { 29 | ALGOLIA_APP_ID: `/${STAGE}/algolia-app-id`, 30 | ALGOLIA_WRITE_KEY: `/${STAGE}/algolia-admin-key` 31 | }, 32 | setToContext: true, 33 | throwOnFailedCall: true 34 | })) -------------------------------------------------------------------------------- /functions/tweet.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const DocumentClient = new DynamoDB.DocumentClient() 3 | const ulid = require('ulid') 4 | const { TweetTypes } = require('../lib/constants') 5 | const { extractHashTags } = require('../lib/tweets') 6 | 7 | const { USERS_TABLE, TIMELINES_TABLE, TWEETS_TABLE } = process.env 8 | 9 | module.exports.handler = async (event) => { 10 | const { text } = event.arguments 11 | const { username } = event.identity 12 | const id = ulid.ulid() 13 | const timestamp = new Date().toJSON() 14 | const hashTags = extractHashTags(text) 15 | 16 | const newTweet = { 17 | __typename: TweetTypes.TWEET, 18 | id, 19 | text, 20 | creator: username, 21 | createdAt: timestamp, 22 | replies: 0, 23 | likes: 0, 24 | retweets: 0, 25 | hashTags 26 | } 27 | 28 | await DocumentClient.transactWrite({ 29 | TransactItems: [{ 30 | Put: { 31 | TableName: TWEETS_TABLE, 32 | Item: newTweet 33 | } 34 | }, { 35 | Put: { 36 | TableName: TIMELINES_TABLE, 37 | Item: { 38 | userId: username, 39 | tweetId: id, 40 | timestamp 41 | } 42 | } 43 | }, { 44 | Update: { 45 | TableName: USERS_TABLE, 46 | Key: { 47 | id: username 48 | }, 49 | UpdateExpression: 'ADD tweetsCount :one', 50 | ExpressionAttributeValues: { 51 | ':one': 1 52 | }, 53 | ConditionExpression: 'attribute_exists(id)' 54 | } 55 | }] 56 | }).promise() 57 | 58 | return newTweet 59 | } -------------------------------------------------------------------------------- /functions/unretweet.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const DocumentClient = new DynamoDB.DocumentClient() 4 | 5 | const { USERS_TABLE, TIMELINES_TABLE, TWEETS_TABLE, RETWEETS_TABLE } = process.env 6 | 7 | module.exports.handler = async (event) => { 8 | const { tweetId } = event.arguments 9 | const { username } = event.identity 10 | 11 | const getTweetResp = await DocumentClient.get({ 12 | TableName: TWEETS_TABLE, 13 | Key: { 14 | id: tweetId 15 | } 16 | }).promise() 17 | 18 | const tweet = getTweetResp.Item 19 | if (!tweet) { 20 | throw new Error('Tweet is not found') 21 | } 22 | 23 | const queryResp = await DocumentClient.query({ 24 | TableName: process.env.TWEETS_TABLE, 25 | IndexName: 'retweetsByCreator', 26 | KeyConditionExpression: 'creator = :creator AND retweetOf = :tweetId', 27 | ExpressionAttributeValues: { 28 | ':creator': username, 29 | ':tweetId': tweetId 30 | }, 31 | Limit: 1 32 | }).promise() 33 | 34 | const retweet = _.get(queryResp, 'Items.0') 35 | 36 | if (!retweet) { 37 | throw new Error('Retweet is not found') 38 | } 39 | 40 | const transactItems = [{ 41 | Delete: { 42 | TableName: TWEETS_TABLE, 43 | Key: { 44 | id: retweet.id 45 | }, 46 | ConditionExpression: 'attribute_exists(id)' 47 | } 48 | }, { 49 | Delete: { 50 | TableName: RETWEETS_TABLE, 51 | Key: { 52 | userId: username, 53 | tweetId 54 | }, 55 | ConditionExpression: 'attribute_exists(tweetId)' 56 | } 57 | }, { 58 | Update: { 59 | TableName: TWEETS_TABLE, 60 | Key: { 61 | id: tweetId 62 | }, 63 | UpdateExpression: 'ADD retweets :minusOne', 64 | ExpressionAttributeValues: { 65 | ':minusOne': -1 66 | }, 67 | ConditionExpression: 'attribute_exists(id)' 68 | } 69 | }, { 70 | Update: { 71 | TableName: USERS_TABLE, 72 | Key: { 73 | id: username 74 | }, 75 | UpdateExpression: 'ADD tweetsCount :minusOne', 76 | ExpressionAttributeValues: { 77 | ':minusOne': -1 78 | }, 79 | ConditionExpression: 'attribute_exists(id)' 80 | } 81 | }] 82 | 83 | console.log(`creator: [${tweet.creator}]; username: [${username}]`) 84 | if (tweet.creator !== username) { 85 | transactItems.push({ 86 | Delete: { 87 | TableName: TIMELINES_TABLE, 88 | Key: { 89 | userId: username, 90 | tweetId: retweet.id 91 | } 92 | } 93 | }) 94 | } 95 | 96 | await DocumentClient.transactWrite({ 97 | TransactItems: transactItems 98 | }).promise() 99 | 100 | return true 101 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testMatch: ['**/__tests__/test_cases/**/*'] 4 | } -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | {"typeAcquisition": { "include": [ "jest" ] }} -------------------------------------------------------------------------------- /lib/algolia.js: -------------------------------------------------------------------------------- 1 | const algoliasearch = require('algoliasearch') 2 | 3 | let usersIndex, tweetsIndex 4 | 5 | const initUsersIndex = async (appId, key, stage) => { 6 | if (!usersIndex) { 7 | const client = algoliasearch(appId, key) 8 | usersIndex = client.initIndex(`users_${stage}`) 9 | await usersIndex.setSettings({ 10 | searchableAttributes: [ 11 | "name", "screenName", "bio" 12 | ] 13 | }) 14 | } 15 | 16 | return usersIndex 17 | } 18 | 19 | const initTweetsIndex = async (appId, key, stage) => { 20 | if (!tweetsIndex) { 21 | const client = algoliasearch(appId, key) 22 | tweetsIndex = client.initIndex(`tweets_${stage}`) 23 | await tweetsIndex.setSettings({ 24 | attributesForFaceting: [ 25 | "hashTags" 26 | ], 27 | searchableAttributes: [ 28 | "text" 29 | ], 30 | customRanking: [ 31 | "desc(createdAt)" 32 | ] 33 | }) 34 | } 35 | 36 | return tweetsIndex 37 | } 38 | 39 | module.exports = { 40 | initUsersIndex, 41 | initTweetsIndex 42 | } 43 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | const TweetTypes = { 2 | TWEET: 'Tweet', 3 | RETWEET: 'Retweet', 4 | REPLY: 'Reply' 5 | } 6 | 7 | const SearchModes = { 8 | PEOPLE: 'People', 9 | LATEST: 'Latest' 10 | } 11 | 12 | const HashTagModes = { 13 | PEOPLE: 'People', 14 | LATEST: 'Latest' 15 | } 16 | 17 | const DynamoDB = { 18 | MAX_BATCH_SIZE: 25 19 | } 20 | 21 | module.exports = { 22 | TweetTypes, 23 | DynamoDB, 24 | SearchModes, 25 | HashTagModes 26 | } -------------------------------------------------------------------------------- /lib/graphql.js: -------------------------------------------------------------------------------- 1 | require('isomorphic-fetch') 2 | const AWS = require('aws-sdk/global') 3 | const { AWSAppSyncClient, AUTH_TYPE } = require('aws-appsync') 4 | 5 | const { GRAPHQL_API_URL, AWS_REGION } = process.env 6 | const config = { 7 | url: GRAPHQL_API_URL, 8 | region: AWS_REGION, 9 | auth: { 10 | type: AUTH_TYPE.AWS_IAM, 11 | credentials: AWS.config.credentials 12 | }, 13 | disableOffline: true 14 | } 15 | const appSyncClient = new AWSAppSyncClient(config) 16 | 17 | async function mutate(query, variables) { 18 | await appSyncClient.mutate({ 19 | mutation: query, 20 | variables 21 | }) 22 | } 23 | 24 | module.exports = { 25 | mutate 26 | } -------------------------------------------------------------------------------- /lib/tweets.js: -------------------------------------------------------------------------------- 1 | const DynamoDB = require('aws-sdk/clients/dynamodb') 2 | const DocumentClient = new DynamoDB.DocumentClient() 3 | 4 | const { TWEETS_TABLE } = process.env 5 | 6 | const getTweetById = async (tweetId) => { 7 | const resp = await DocumentClient.get({ 8 | TableName: TWEETS_TABLE, 9 | Key: { 10 | id: tweetId 11 | } 12 | }).promise() 13 | 14 | return resp.Item 15 | } 16 | 17 | const extractHashTags = (text) => { 18 | const hashTags = new Set() 19 | const regex = /(\#[a-zA-Z0-9_]+\b)/gm 20 | while ((m = regex.exec(text)) !== null) { 21 | // this is necessary to avoid infinite loops with zero-width matches 22 | if (m.index === regex.lastIndex) { 23 | regex.lastIndex++ 24 | } 25 | 26 | m.forEach(match => hashTags.add(match)) 27 | } 28 | 29 | return Array.from(hashTags) 30 | } 31 | 32 | const extractMentions = (text) => { 33 | const mentions = new Set() 34 | const regex = /@\w+/gm 35 | 36 | while ((m = regex.exec(text)) !== null) { 37 | // this is necessary to avoid infinite loops with zero-width matches 38 | if (m.index === regex.lastIndex) { 39 | regex.lastIndex++ 40 | } 41 | 42 | m.forEach(match => mentions.add(match)) 43 | } 44 | 45 | return Array.from(mentions) 46 | } 47 | 48 | module.exports = { 49 | getTweetById, 50 | extractHashTags, 51 | extractMentions 52 | } -------------------------------------------------------------------------------- /lib/users.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const DynamoDB = require('aws-sdk/clients/dynamodb') 3 | const DocumentClient = new DynamoDB.DocumentClient() 4 | 5 | const { USERS_TABLE } = process.env 6 | 7 | const getUserByScreenName = async (screenName) => { 8 | const resp = await DocumentClient.query({ 9 | TableName: USERS_TABLE, 10 | KeyConditionExpression: 'screenName = :screenName', 11 | ExpressionAttributeValues: { 12 | ':screenName': screenName 13 | }, 14 | IndexName: 'byScreenName', 15 | Limit: 1 16 | }).promise() 17 | 18 | return _.get(resp, 'Items.0') 19 | } 20 | 21 | module.exports = { 22 | getUserByScreenName 23 | } 24 | -------------------------------------------------------------------------------- /mapping-templates/Conversation.otherUser.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.info.selectionSetList.size() == 1 && $context.info.selectionSetList[0] == "id") 2 | #set ($result = { "id": "$context.source.otherUserId" }) 3 | 4 | #return($result) 5 | #end 6 | 7 | { 8 | "version" : "2018-05-29", 9 | "operation" : "GetItem", 10 | "key" : { 11 | "id" : $util.dynamodb.toDynamoDBJson($context.source.otherUserId) 12 | } 13 | } -------------------------------------------------------------------------------- /mapping-templates/Conversation.otherUser.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Message.from.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.info.selectionSetList.size() == 1 && $context.info.selectionSetList[0] == "id") 2 | #set ($result = { "id": "$context.source.from" }) 3 | 4 | #if ($context.source.from == $context.identity.username) 5 | #set ($result["__typename"] = "MyProfile") 6 | #else 7 | #set ($result["__typename"] = "OtherProfile") 8 | #end 9 | 10 | #return($result) 11 | #end 12 | 13 | { 14 | "version" : "2018-05-29", 15 | "operation" : "GetItem", 16 | "key" : { 17 | "id" : $util.dynamodb.toDynamoDBJson($context.source.from) 18 | } 19 | } -------------------------------------------------------------------------------- /mapping-templates/Message.from.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result)) 2 | #if ($context.result.id == $context.identity.username) 3 | #set ($context.result["__typename"] = "MyProfile") 4 | #else 5 | #set ($context.result["__typename"] = "OtherProfile") 6 | #end 7 | #end 8 | 9 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.editMyProfile.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "UpdateItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.identity.username) 6 | }, 7 | "update" : { 8 | "expression" : "set #name = :name, imageUrl = :imageUrl, backgroundImageUrl = :backgroundImageUrl, bio = :bio, #location = :location, website = :website, birthdate = :birthdate", 9 | "expressionNames" : { 10 | "#name" : "name", 11 | "#location" : "location" 12 | }, 13 | "expressionValues" : { 14 | ":name" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.name), 15 | ":imageUrl" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.imageUrl), 16 | ":backgroundImageUrl" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.backgroundImageUrl), 17 | ":bio" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.bio), 18 | ":location" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.location), 19 | ":website" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.website), 20 | ":birthdate" : $util.dynamodb.toDynamoDBJson($context.arguments.newProfile.birthdate) 21 | } 22 | }, 23 | "condition" : { 24 | "expression" : "attribute_exists(id)" 25 | } 26 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.editMyProfile.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.follow.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($sk = "FOLLOWS_" + $context.arguments.userId) 2 | 3 | { 4 | "version": "2018-05-29", 5 | "operation": "TransactWriteItems", 6 | "transactItems": [ 7 | { 8 | "table": "${RelationshipsTable}", 9 | "operation": "PutItem", 10 | "key": { 11 | "userId": $util.dynamodb.toDynamoDBJson($context.identity.username), 12 | "sk": $util.dynamodb.toDynamoDBJson($sk) 13 | }, 14 | "attributeValues": { 15 | "otherUserId": $util.dynamodb.toDynamoDBJson($context.arguments.userId), 16 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 17 | }, 18 | "condition": { 19 | "expression": "attribute_not_exists(sk)" 20 | } 21 | }, 22 | { 23 | "table":"${UsersTable}", 24 | "operation": "UpdateItem", 25 | "key": { 26 | "id": $util.dynamodb.toDynamoDBJson($context.identity.username) 27 | }, 28 | "update": { 29 | "expression": "ADD followingCount :one", 30 | "expressionValues": { 31 | ":one": $util.dynamodb.toDynamoDBJson(1) 32 | } 33 | }, 34 | "condition": { 35 | "expression": "attribute_exists(id)" 36 | } 37 | }, 38 | { 39 | "table":"${UsersTable}", 40 | "operation": "UpdateItem", 41 | "key": { 42 | "id": $util.dynamodb.toDynamoDBJson($context.arguments.userId) 43 | }, 44 | "update": { 45 | "expression": "ADD followersCount :one", 46 | "expressionValues": { 47 | ":one": $util.dynamodb.toDynamoDBJson(1) 48 | } 49 | }, 50 | "condition": { 51 | "expression": "attribute_exists(id)" 52 | } 53 | } 54 | ] 55 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.follow.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result.cancellationReasons)) 2 | $util.error('DynamoDB transaction error') 3 | #end 4 | 5 | #if (!$util.isNull($context.error)) 6 | $util.error('Failed to execute DynamoDB transaction') 7 | #end 8 | 9 | true -------------------------------------------------------------------------------- /mapping-templates/Mutation.like.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "TransactWriteItems", 4 | "transactItems": [ 5 | { 6 | "table": "${LikesTable}", 7 | "operation": "PutItem", 8 | "key": { 9 | "userId": $util.dynamodb.toDynamoDBJson($context.identity.username), 10 | "tweetId": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId) 11 | }, 12 | "condition": { 13 | "expression": "attribute_not_exists(tweetId)" 14 | } 15 | }, 16 | { 17 | "table": "${TweetsTable}", 18 | "operation": "UpdateItem", 19 | "key": { 20 | "id": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId) 21 | }, 22 | "update": { 23 | "expression": "ADD likes :one", 24 | "expressionValues": { 25 | ":one": $util.dynamodb.toDynamoDBJson(1) 26 | } 27 | }, 28 | "condition": { 29 | "expression": "attribute_exists(id)" 30 | } 31 | }, 32 | { 33 | "table": "${UsersTable}", 34 | "operation": "UpdateItem", 35 | "key": { 36 | "id": $util.dynamodb.toDynamoDBJson($context.identity.username) 37 | }, 38 | "update": { 39 | "expression": "ADD likesCounts :one", 40 | "expressionValues": { 41 | ":one": $util.dynamodb.toDynamoDBJson(1) 42 | } 43 | }, 44 | "condition": { 45 | "expression": "attribute_exists(id)" 46 | } 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.like.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result.cancellationReasons)) 2 | $util.error('DynamoDB transaction error') 3 | #end 4 | 5 | #if (!$util.isNull($context.error)) 6 | $util.error('Failed to execute DynamoDB transaction') 7 | #end 8 | 9 | true -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyDMed.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id), 6 | "userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 7 | }, 8 | "attributeValues" : { 9 | "otherUserId": $util.dynamodb.toDynamoDBJson($context.arguments.otherUserId), 10 | "message": $util.dynamodb.toDynamoDBJson($context.arguments.message), 11 | "type": $util.dynamodb.toDynamoDBJson("DMed"), 12 | "__typename": $util.dynamodb.toDynamoDBJson("DMed"), 13 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 14 | } 15 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyDMed.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyLiked.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id), 6 | "userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 7 | }, 8 | "attributeValues" : { 9 | "tweetId": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId), 10 | "likedBy": $util.dynamodb.toDynamoDBJson($context.arguments.likedBy), 11 | "type": $util.dynamodb.toDynamoDBJson("Liked"), 12 | "__typename": $util.dynamodb.toDynamoDBJson("Liked"), 13 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 14 | } 15 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyLiked.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyMentioned.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id), 6 | "userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 7 | }, 8 | "attributeValues" : { 9 | "mentionedBy": $util.dynamodb.toDynamoDBJson($context.arguments.mentionedBy), 10 | "mentionedByTweetId": $util.dynamodb.toDynamoDBJson($context.arguments.mentionedByTweetId), 11 | "type": $util.dynamodb.toDynamoDBJson("Mentioned"), 12 | "__typename": $util.dynamodb.toDynamoDBJson("Mentioned"), 13 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 14 | } 15 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyMentioned.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyReplied.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id), 6 | "userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 7 | }, 8 | "attributeValues" : { 9 | "tweetId": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId), 10 | "replyTweetId": $util.dynamodb.toDynamoDBJson($context.arguments.replyTweetId), 11 | "repliedBy": $util.dynamodb.toDynamoDBJson($context.arguments.repliedBy), 12 | "type": $util.dynamodb.toDynamoDBJson("Replied"), 13 | "__typename": $util.dynamodb.toDynamoDBJson("Replied"), 14 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 15 | } 16 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyReplied.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyRetweeted.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "PutItem", 4 | "key": { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id), 6 | "userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 7 | }, 8 | "attributeValues" : { 9 | "tweetId": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId), 10 | "retweetedBy": $util.dynamodb.toDynamoDBJson($context.arguments.retweetedBy), 11 | "retweetId": $util.dynamodb.toDynamoDBJson($context.arguments.retweetId), 12 | "type": $util.dynamodb.toDynamoDBJson("Retweeted"), 13 | "__typename": $util.dynamodb.toDynamoDBJson("Retweeted"), 14 | "createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601()) 15 | } 16 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.notifyRetweeted.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Mutation.unfollow.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($sk = "FOLLOWS_" + $context.arguments.userId) 2 | 3 | { 4 | "version": "2018-05-29", 5 | "operation": "TransactWriteItems", 6 | "transactItems": [ 7 | { 8 | "table": "${RelationshipsTable}", 9 | "operation": "DeleteItem", 10 | "key": { 11 | "userId": $util.dynamodb.toDynamoDBJson($context.identity.username), 12 | "sk": $util.dynamodb.toDynamoDBJson($sk) 13 | }, 14 | "condition": { 15 | "expression": "attribute_exists(sk)" 16 | } 17 | }, 18 | { 19 | "table":"${UsersTable}", 20 | "operation": "UpdateItem", 21 | "key": { 22 | "id": $util.dynamodb.toDynamoDBJson($context.identity.username) 23 | }, 24 | "update": { 25 | "expression": "ADD followingCount :minusOne", 26 | "expressionValues": { 27 | ":minusOne": $util.dynamodb.toDynamoDBJson(-1) 28 | } 29 | }, 30 | "condition": { 31 | "expression": "attribute_exists(id)" 32 | } 33 | }, 34 | { 35 | "table":"${UsersTable}", 36 | "operation": "UpdateItem", 37 | "key": { 38 | "id": $util.dynamodb.toDynamoDBJson($context.arguments.userId) 39 | }, 40 | "update": { 41 | "expression": "ADD followersCount :minusOne", 42 | "expressionValues": { 43 | ":minusOne": $util.dynamodb.toDynamoDBJson(-1) 44 | } 45 | }, 46 | "condition": { 47 | "expression": "attribute_exists(id)" 48 | } 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.unfollow.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result.cancellationReasons)) 2 | $util.error('DynamoDB transaction error') 3 | #end 4 | 5 | #if (!$util.isNull($context.error)) 6 | $util.error('Failed to execute DynamoDB transaction') 7 | #end 8 | 9 | true -------------------------------------------------------------------------------- /mapping-templates/Mutation.unlike.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "TransactWriteItems", 4 | "transactItems": [ 5 | { 6 | "table": "${LikesTable}", 7 | "operation": "DeleteItem", 8 | "key": { 9 | "userId": $util.dynamodb.toDynamoDBJson($context.identity.username), 10 | "tweetId": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId) 11 | }, 12 | "condition": { 13 | "expression": "attribute_exists(tweetId)" 14 | } 15 | }, 16 | { 17 | "table": "${TweetsTable}", 18 | "operation": "UpdateItem", 19 | "key": { 20 | "id": $util.dynamodb.toDynamoDBJson($context.arguments.tweetId) 21 | }, 22 | "update": { 23 | "expression": "ADD likes :one", 24 | "expressionValues": { 25 | ":one": $util.dynamodb.toDynamoDBJson(-1) 26 | } 27 | }, 28 | "condition": { 29 | "expression": "attribute_exists(id)" 30 | } 31 | }, 32 | { 33 | "table": "${UsersTable}", 34 | "operation": "UpdateItem", 35 | "key": { 36 | "id": $util.dynamodb.toDynamoDBJson($context.identity.username) 37 | }, 38 | "update": { 39 | "expression": "ADD likesCounts :one", 40 | "expressionValues": { 41 | ":one": $util.dynamodb.toDynamoDBJson(-1) 42 | } 43 | }, 44 | "condition": { 45 | "expression": "attribute_exists(id)" 46 | } 47 | } 48 | ] 49 | } -------------------------------------------------------------------------------- /mapping-templates/Mutation.unlike.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result.cancellationReasons)) 2 | $util.error('DynamoDB transaction error') 3 | #end 4 | 5 | #if (!$util.isNull($context.error)) 6 | $util.error('Failed to execute DynamoDB transaction') 7 | #end 8 | 9 | true -------------------------------------------------------------------------------- /mapping-templates/MyProfile.tweets.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query" : { 5 | "expression" : "creator = :userId", 6 | "expressionValues" : { 7 | ":userId" : $util.dynamodb.toDynamoDBJson($context.source.id) 8 | } 9 | }, 10 | "index" : "byCreator", 11 | "limit" : $util.toJson(10), 12 | "scanIndexForward" : false, 13 | "consistentRead" : false, 14 | "select" : "ALL_ATTRIBUTES" 15 | } -------------------------------------------------------------------------------- /mapping-templates/MyProfile.tweets.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "tweets": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/OtherProfile.followedBy.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($sk = "FOLLOWS_" + $context.identity.username) 2 | 3 | { 4 | "version" : "2018-05-29", 5 | "operation" : "GetItem", 6 | "key" : { 7 | "userId" : $util.dynamodb.toDynamoDBJson($context.source.id), 8 | "sk" : $util.dynamodb.toDynamoDBJson($sk) 9 | } 10 | } -------------------------------------------------------------------------------- /mapping-templates/OtherProfile.followedBy.response.vtl: -------------------------------------------------------------------------------- 1 | #if ($util.isNull($context.result)) 2 | false 3 | #else 4 | true 5 | #end 6 | -------------------------------------------------------------------------------- /mapping-templates/OtherProfile.following.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($sk = "FOLLOWS_" + $context.source.id) 2 | 3 | { 4 | "version" : "2018-05-29", 5 | "operation" : "GetItem", 6 | "key" : { 7 | "userId" : $util.dynamodb.toDynamoDBJson($context.identity.username), 8 | "sk" : $util.dynamodb.toDynamoDBJson($sk) 9 | } 10 | } -------------------------------------------------------------------------------- /mapping-templates/OtherProfile.following.response.vtl: -------------------------------------------------------------------------------- 1 | #if ($util.isNull($context.result)) 2 | false 3 | #else 4 | true 5 | #end 6 | -------------------------------------------------------------------------------- /mapping-templates/Query.getAnalyticsConfig.request.vtl: -------------------------------------------------------------------------------- 1 | #return({ 2 | "identityPoolId": "${IdentityPoolId}", 3 | "streamName": "${FirehoseStreamName}" 4 | }) -------------------------------------------------------------------------------- /mapping-templates/Query.getAnalyticsConfig.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Query.getDirectMessages.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($isValidLimit = $context.arguments.limit <= 25) 2 | $util.validate($isValidLimit, "max limit is 25") 3 | 4 | #set ($userId = $context.identity.username) 5 | #set ($otherUserId = $context.arguments.otherUserId) 6 | 7 | #if ($userId.compareTo($otherUserId) < 0) 8 | #set ($conversationId = $userId + '_' + $otherUserId) 9 | #else 10 | #set ($conversationId = $otherUserId + '_' + $userId) 11 | #end 12 | 13 | { 14 | "version" : "2018-05-29", 15 | "operation" : "Query", 16 | "query" : { 17 | "expression" : "conversationId = :conversationId", 18 | "expressionValues" : { 19 | ":conversationId" : $util.dynamodb.toDynamoDBJson($conversationId) 20 | } 21 | }, 22 | "nextToken" : $util.toJson($context.arguments.nextToken), 23 | "limit" : $util.toJson($context.arguments.limit), 24 | "scanIndexForward" : false, 25 | "consistentRead" : false, 26 | "select" : "ALL_ATTRIBUTES" 27 | } 28 | -------------------------------------------------------------------------------- /mapping-templates/Query.getDirectMessages.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "messages": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getLikes.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($isValidLimit = $context.arguments.limit <= 25) 2 | $util.validate($isValidLimit, "max limit is 25") 3 | 4 | { 5 | "version" : "2018-05-29", 6 | "operation" : "Query", 7 | "query" : { 8 | "expression" : "userId = :userId", 9 | "expressionValues" : { 10 | ":userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 11 | } 12 | }, 13 | "nextToken" : $util.toJson($context.arguments.nextToken), 14 | "limit" : $util.toJson($context.arguments.limit), 15 | "scanIndexForward" : false, 16 | "consistentRead" : false, 17 | "select" : "ALL_ATTRIBUTES" 18 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getLikes.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "tweets": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getMyProfile.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "GetItem", 4 | "key" : { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.identity.username) 6 | } 7 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getMyProfile.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Query.getMyTimeline.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($isValidLimit = $context.arguments.limit <= 25) 2 | $util.validate($isValidLimit, "max limit is 25") 3 | 4 | { 5 | "version" : "2018-05-29", 6 | "operation" : "Query", 7 | "query" : { 8 | "expression" : "userId = :userId", 9 | "expressionValues" : { 10 | ":userId" : $util.dynamodb.toDynamoDBJson($context.identity.username) 11 | } 12 | }, 13 | "nextToken" : $util.toJson($context.arguments.nextToken), 14 | "limit" : $util.toJson($context.arguments.limit), 15 | "scanIndexForward" : false, 16 | "consistentRead" : false, 17 | "select" : "ALL_ATTRIBUTES" 18 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getMyTimeline.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "tweets": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getProfile.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query" : { 5 | "expression" : "screenName = :screenName", 6 | "expressionValues" : { 7 | ":screenName" : $util.dynamodb.toDynamoDBJson($context.arguments.screenName) 8 | } 9 | }, 10 | "index": "byScreenName", 11 | "limit" : 1, 12 | "scanIndexForward" : false, 13 | "consistentRead" : false, 14 | "select" : "ALL_ATTRIBUTES" 15 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getProfile.response.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.result.items.size() == 0) 2 | null 3 | #else 4 | $util.toJson($context.result.items[0]) 5 | #end -------------------------------------------------------------------------------- /mapping-templates/Query.getTweets.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($isValidLimit = $context.arguments.limit <= 25) 2 | $util.validate($isValidLimit, "max limit is 25") 3 | 4 | { 5 | "version" : "2018-05-29", 6 | "operation" : "Query", 7 | "query" : { 8 | "expression" : "creator = :userId", 9 | "expressionValues" : { 10 | ":userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId) 11 | } 12 | }, 13 | "index" : "byCreator", 14 | "nextToken" : $util.toJson($context.arguments.nextToken), 15 | "limit" : $util.toJson($context.arguments.limit), 16 | "scanIndexForward" : false, 17 | "consistentRead" : false, 18 | "select" : "ALL_ATTRIBUTES" 19 | } -------------------------------------------------------------------------------- /mapping-templates/Query.getTweets.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "tweets": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/Query.listConversations.request.vtl: -------------------------------------------------------------------------------- 1 | #set ($isValidLimit = $context.arguments.limit <= 25) 2 | $util.validate($isValidLimit, "max limit is 25") 3 | 4 | { 5 | "version" : "2018-05-29", 6 | "operation" : "Query", 7 | "query" : { 8 | "expression" : "userId = :userId", 9 | "expressionValues" : { 10 | ":userId" : $util.dynamodb.toDynamoDBJson($context.identity.username) 11 | } 12 | }, 13 | "index" : "byUserId", 14 | "nextToken" : $util.toJson($context.arguments.nextToken), 15 | "limit" : $util.toJson($context.arguments.limit), 16 | "scanIndexForward" : false, 17 | "consistentRead" : false, 18 | "select" : "ALL_ATTRIBUTES" 19 | } -------------------------------------------------------------------------------- /mapping-templates/Query.listConversations.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "conversations": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/Reply.inReplyToTweet.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "GetItem", 4 | "key" : { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.source.inReplyToTweetId) 6 | } 7 | } -------------------------------------------------------------------------------- /mapping-templates/Reply.inReplyToTweet.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Reply.inReplyToUsers.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.source.inReplyToUserIds.size() == 0) 2 | #return([]) 3 | #end 4 | 5 | #set ($users = []) 6 | #if ($context.info.selectionSetList.size() == 1 && $context.info.selectionSetList[0] == "id") 7 | #foreach ($id in $context.source.inReplyToUserIds) 8 | #set ($user = { "id": "$id" }) 9 | 10 | #if ($id == $context.identity.username) 11 | #set ($user["__typename"] = "MyProfile") 12 | #else 13 | #set ($user["__typename"] = "OtherProfile") 14 | #end 15 | 16 | $util.qr($users.add($user)) 17 | 18 | #end 19 | 20 | #return($users) 21 | 22 | #else 23 | #foreach ($id in $context.source.inReplyToUserIds) 24 | #set ($user = {}) 25 | #set ($user.id = $id) 26 | $util.qr($users.add($util.dynamodb.toMapValues($user))) 27 | #end 28 | #end 29 | 30 | { 31 | "version" : "2018-05-29", 32 | "operation" : "BatchGetItem", 33 | "tables" : { 34 | "${UsersTable}": { 35 | "keys": $util.toJson($users), 36 | "consistentRead": false 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /mapping-templates/Reply.inReplyToUsers.response.vtl: -------------------------------------------------------------------------------- 1 | #foreach ($user in $context.result.data.${UsersTable}) 2 | #if ($user.id == $context.identity.username) 3 | #set ($user["__typename"] = "MyProfile") 4 | #else 5 | #set ($user["__typename"] = "OtherProfile") 6 | #end 7 | #end 8 | 9 | $util.toJson($context.result.data.${UsersTable}) -------------------------------------------------------------------------------- /mapping-templates/Retweet.retweetOf.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "GetItem", 4 | "key" : { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.source.retweetOf) 6 | } 7 | } -------------------------------------------------------------------------------- /mapping-templates/Retweet.retweetOf.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Subscription.onNotified.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.arguments.userId != $context.identity.username) 2 | $util.unauthorized() 3 | #else 4 | #return 5 | #end -------------------------------------------------------------------------------- /mapping-templates/Subscription.onNotified.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Tweet.liked.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "GetItem", 4 | "key" : { 5 | "userId" : $util.dynamodb.toDynamoDBJson($context.identity.username), 6 | "tweetId" : $util.dynamodb.toDynamoDBJson($context.source.id) 7 | } 8 | } -------------------------------------------------------------------------------- /mapping-templates/Tweet.liked.response.vtl: -------------------------------------------------------------------------------- 1 | #if ($util.isNull($context.result)) 2 | false 3 | #else 4 | true 5 | #end 6 | -------------------------------------------------------------------------------- /mapping-templates/Tweet.profile.batchInvoke.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "BatchInvoke", 4 | "payload": { 5 | "userId": $utils.toJson($context.source.creator), 6 | "caller": $utils.toJson($context.identity.username), 7 | "selection": $utils.toJson($context.info.selectionSetList) 8 | } 9 | } -------------------------------------------------------------------------------- /mapping-templates/Tweet.profile.batchInvoke.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result) && !$util.isNull($context.result.errorMessage)) 2 | $util.error($context.result.errorMessage, $context.result.errorType) 3 | #else 4 | $util.toJson($context.result.data) 5 | #end -------------------------------------------------------------------------------- /mapping-templates/Tweet.profile.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.info.selectionSetList.size() == 1 && $context.info.selectionSetList[0] == "id") 2 | #set ($result = { "id": "$context.source.creator" }) 3 | 4 | #if ($context.source.creator == $context.identity.username) 5 | #set ($result["__typename"] = "MyProfile") 6 | #else 7 | #set ($result["__typename"] = "OtherProfile") 8 | #end 9 | 10 | #return($result) 11 | #end 12 | 13 | { 14 | "version" : "2018-05-29", 15 | "operation" : "GetItem", 16 | "key" : { 17 | "id" : $util.dynamodb.toDynamoDBJson($context.source.creator) 18 | } 19 | } -------------------------------------------------------------------------------- /mapping-templates/Tweet.profile.response.vtl: -------------------------------------------------------------------------------- 1 | #if (!$util.isNull($context.result)) 2 | #if ($context.result.id == $context.identity.username) 3 | #set ($context.result["__typename"] = "MyProfile") 4 | #else 5 | #set ($context.result["__typename"] = "OtherProfile") 6 | #end 7 | #end 8 | 9 | $util.toJson($context.result) -------------------------------------------------------------------------------- /mapping-templates/Tweet.retweeted.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "GetItem", 4 | "key" : { 5 | "userId" : $util.dynamodb.toDynamoDBJson($context.identity.username), 6 | "tweetId" : $util.dynamodb.toDynamoDBJson($context.source.id) 7 | } 8 | } -------------------------------------------------------------------------------- /mapping-templates/Tweet.retweeted.response.vtl: -------------------------------------------------------------------------------- 1 | #if ($util.isNull($context.result)) 2 | false 3 | #else 4 | true 5 | #end 6 | -------------------------------------------------------------------------------- /mapping-templates/UnhydratedTweetsPage.tweets.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.source.tweets.size() == 0) 2 | #return([]) 3 | #end 4 | 5 | #set ($tweets = []) 6 | #foreach ($item in $context.source.tweets) 7 | #set ($tweet = {}) 8 | #set ($tweet.id = $item.tweetId) 9 | $util.qr($tweets.add($util.dynamodb.toMapValues($tweet))) 10 | #end 11 | 12 | { 13 | "version" : "2018-05-29", 14 | "operation" : "BatchGetItem", 15 | "tables" : { 16 | "${TweetsTable}": { 17 | "keys": $util.toJson($tweets), 18 | "consistentRead": false 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /mapping-templates/UnhydratedTweetsPage.tweets.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result.data.${TweetsTable}) -------------------------------------------------------------------------------- /mapping-templates/getFollowers.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query" : { 5 | "expression" : "otherUserId = :userId AND begins_with(sk, :follows)", 6 | "expressionValues" : { 7 | ":userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId), 8 | ":follows" : $util.dynamodb.toDynamoDBJson("FOLLOWS_") 9 | } 10 | }, 11 | "index": "byOtherUser", 12 | "nextToken" : $util.toJson($context.arguments.nextToken), 13 | "limit" : $util.toJson($context.arguments.limit), 14 | "scanIndexForward" : false, 15 | "consistentRead" : false, 16 | "select" : "ALL_ATTRIBUTES" 17 | } -------------------------------------------------------------------------------- /mapping-templates/getFollowers.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "relationships": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/getFollowing.request.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version" : "2018-05-29", 3 | "operation" : "Query", 4 | "query" : { 5 | "expression" : "userId = :userId AND begins_with(sk, :follows)", 6 | "expressionValues" : { 7 | ":userId" : $util.dynamodb.toDynamoDBJson($context.arguments.userId), 8 | ":follows" : $util.dynamodb.toDynamoDBJson("FOLLOWS_") 9 | } 10 | }, 11 | "nextToken" : $util.toJson($context.arguments.nextToken), 12 | "limit" : $util.toJson($context.arguments.limit), 13 | "scanIndexForward" : false, 14 | "consistentRead" : false, 15 | "select" : "ALL_ATTRIBUTES" 16 | } -------------------------------------------------------------------------------- /mapping-templates/getFollowing.response.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "relationships": $util.toJson($context.result.items), 3 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.result.nextToken, null)) 4 | } -------------------------------------------------------------------------------- /mapping-templates/hydrateFollowers.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.prev.result.relationships.size() == 0) 2 | #return({ "profiles": [] }) 3 | #end 4 | 5 | #set ($users = []) 6 | #foreach ($relationship in $context.prev.result.relationships) 7 | #set ($user = {}) 8 | #set ($user.id = $relationship.userId) 9 | $util.qr($users.add($util.dynamodb.toMapValues($user))) 10 | #end 11 | 12 | { 13 | "version" : "2018-05-29", 14 | "operation" : "BatchGetItem", 15 | "tables" : { 16 | "${UsersTable}": { 17 | "keys": $util.toJson($users), 18 | "consistentRead": false 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mapping-templates/hydrateFollowers.response.vtl: -------------------------------------------------------------------------------- 1 | #foreach ($user in $context.result.data.${UsersTable}) 2 | #if ($user.id == $context.identity.username) 3 | #set ($user["__typename"] = "MyProfile") 4 | #else 5 | #set ($user["__typename"] = "OtherProfile") 6 | #end 7 | #end 8 | 9 | { 10 | "profiles": $util.toJson($context.result.data.${UsersTable}), 11 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.prev.result.nextToken, null)) 12 | } -------------------------------------------------------------------------------- /mapping-templates/hydrateFollowing.request.vtl: -------------------------------------------------------------------------------- 1 | #if ($context.prev.result.relationships.size() == 0) 2 | #return({ "profiles": [] }) 3 | #end 4 | 5 | #set ($users = []) 6 | #foreach ($relationship in $context.prev.result.relationships) 7 | #set ($user = {}) 8 | #set ($user.id = $relationship.otherUserId) 9 | $util.qr($users.add($util.dynamodb.toMapValues($user))) 10 | #end 11 | 12 | { 13 | "version" : "2018-05-29", 14 | "operation" : "BatchGetItem", 15 | "tables" : { 16 | "${UsersTable}": { 17 | "keys": $util.toJson($users), 18 | "consistentRead": false 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mapping-templates/hydrateFollowing.response.vtl: -------------------------------------------------------------------------------- 1 | #foreach ($user in $context.result.data.${UsersTable}) 2 | #if ($user.id == $context.identity.username) 3 | #set ($user["__typename"] = "MyProfile") 4 | #else 5 | #set ($user["__typename"] = "OtherProfile") 6 | #end 7 | #end 8 | 9 | { 10 | "profiles": $util.toJson($context.result.data.${UsersTable}), 11 | "nextToken": $util.toJson($util.defaultIfNullOrBlank($context.prev.result.nextToken, null)) 12 | } -------------------------------------------------------------------------------- /mapping-templates/simplePipeline.request.vtl: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /mapping-templates/simplePipeline.response.vtl: -------------------------------------------------------------------------------- 1 | $util.toJson($context.result) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "appsyncmasterclass-backend", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Backend for the AppSync Masterclass demo app", 6 | "main": "index.js", 7 | "scripts": { 8 | "sls": "sls", 9 | "exportEnv": "sls export-env", 10 | "jest": "jest", 11 | "test": "jest --verbose ./__tests__/test_cases/unit --silent", 12 | "integration-test": "jest --verbose ./__tests__/test_cases/integration --silent", 13 | "e2e-test": "jest --verbose ./__tests__/test_cases/e2e --silent", 14 | "load-test": "dotenv artillery run ./.artillery/test.yml", 15 | "load-test:debug": "DEBUG=http,http:response dotenv artillery run ./.artillery/test.yml" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/theburningmonk/appsyncmasterclass-backend.git" 20 | }, 21 | "keywords": [], 22 | "author": "", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/theburningmonk/appsyncmasterclass-backend/issues" 26 | }, 27 | "homepage": "https://github.com/theburningmonk/appsyncmasterclass-backend#readme", 28 | "devDependencies": { 29 | "@types/jest": "^26.0.15", 30 | "amplify-appsync-simulator": "^1.23.8", 31 | "amplify-velocity-template": "^1.3.4", 32 | "artillery": "^2.0.0-21", 33 | "async-retry": "^1.3.1", 34 | "aws-sdk": "^2.778.0", 35 | "axios": "^0.21.0", 36 | "dotenv": "^8.2.0", 37 | "dotenv-cli": "^6.0.0", 38 | "jest": "^26.6.1", 39 | "serverless": "2.4.0", 40 | "serverless-appsync-plugin": "^1.4.0", 41 | "serverless-export-env": "^1.4.0", 42 | "serverless-iam-roles-per-function": "^2.0.2", 43 | "serverless-layers": "^2.3.3", 44 | "serverless-lumigo": "^1.13.2", 45 | "serverless-plugin-ifelse": "^1.0.7", 46 | "ws": "^7.4.4", 47 | "yargs": "^17.5.1" 48 | }, 49 | "dependencies": { 50 | "@lumigo/tracer": "^1.71.0", 51 | "@middy/core": "^1.5.2", 52 | "@middy/ssm": "^1.5.2", 53 | "algoliasearch": "^4.8.5", 54 | "aws-appsync": "^4.0.3", 55 | "aws-xray-sdk-core": "^3.3.4", 56 | "chance": "^1.1.7", 57 | "graphql": "^15.5.0", 58 | "graphql-tag": "^2.11.0", 59 | "isomorphic-fetch": "^3.0.0", 60 | "lodash": "^4.17.20", 61 | "ulid": "^2.3.0", 62 | "uuid": "^8.3.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages-to-install.txt: -------------------------------------------------------------------------------- 1 | npm i --save-dev serverless@2.4.0 2 | npm i --save-dev serverless-appsync-plugin 3 | npm i --save-dev serverless-export-env 4 | npm i --save-dev serverless-iam-roles-per-function 5 | npm i --save-dev aws-sdk 6 | npm i --save-dev dotenv 7 | npm i --save-dev jest 8 | npm i --save-dev @types/jest 9 | npm i --save-dev amplify-appsync-simulator 10 | npm i --save-dev amplify-velocity-template 11 | npm i --save-dev axios 12 | npm i --save-dev serverless-manifest-plugin 13 | npm i --save chance 14 | npm i --save ulid 15 | npm i --save lodash -------------------------------------------------------------------------------- /schema.api.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutation 4 | subscription: Subscription 5 | } 6 | 7 | type Query { 8 | getAnalyticsConfig: AnalyticsConfig 9 | @aws_iam @aws_cognito_user_pools 10 | 11 | getImageUploadUrl(extension: String, contentType: String): AWSURL! 12 | 13 | getMyTimeline(limit: Int!, nextToken: String): UnhydratedTweetsPage! 14 | 15 | getMyProfile: MyProfile! 16 | 17 | getProfile(screenName: String!): OtherProfile 18 | 19 | getTweets(userId: ID!, limit: Int!, nextToken: String): TweetsPage! 20 | 21 | getLikes(userId: ID!, limit: Int!, nextToken: String): UnhydratedTweetsPage! 22 | 23 | getFollowers(userId: ID!, limit: Int!, nextToken: String): ProfilesPage! 24 | 25 | getFollowing(userId: ID!, limit: Int!, nextToken: String): ProfilesPage! 26 | 27 | search( 28 | query: String! 29 | mode: SearchMode! 30 | limit: Int! 31 | nextToken: String 32 | ): SearchResultsPage! 33 | 34 | getHashTag( 35 | hashTag: String! 36 | mode: HashTagMode! 37 | limit: Int! 38 | nextToken: String 39 | ): HashTagResultsPage! 40 | 41 | listConversations( 42 | limit: Int! 43 | nextToken: String 44 | ): ConversationsPage! 45 | 46 | getDirectMessages( 47 | otherUserId: ID! 48 | limit: Int! 49 | nextToken: String 50 | ): MessagesPage! 51 | } 52 | 53 | type Mutation { 54 | editMyProfile(newProfile: ProfileInput!): MyProfile! 55 | 56 | tweet(text: String!): Tweet! 57 | 58 | like(tweetId: ID!): Boolean! 59 | 60 | unlike(tweetId: ID!): Boolean! 61 | 62 | retweet(tweetId: ID!): Retweet! 63 | 64 | unretweet(tweetId: ID!): Boolean! 65 | 66 | reply(tweetId: ID!, text: String!): Reply! 67 | 68 | follow(userId: ID!): Boolean! 69 | 70 | unfollow(userId: ID!): Boolean! 71 | 72 | notifyRetweeted( 73 | id: ID! 74 | userId: ID! 75 | tweetId: ID! 76 | retweetedBy: ID! 77 | retweetId: ID! 78 | ): Notification! 79 | @aws_iam 80 | 81 | notifyLiked( 82 | id: ID! 83 | userId: ID! 84 | tweetId: ID! 85 | likedBy: ID! 86 | ): Notification! 87 | @aws_iam 88 | 89 | notifyMentioned( 90 | id: ID! 91 | userId: ID! 92 | mentionedBy: ID! 93 | mentionedByTweetId: ID! 94 | ): Notification! 95 | @aws_iam 96 | 97 | notifyReplied( 98 | id: ID! 99 | userId: ID! 100 | tweetId: ID! 101 | replyTweetId: ID! 102 | repliedBy: ID! 103 | ): Notification! 104 | @aws_iam 105 | 106 | notifyDMed( 107 | id: ID! 108 | userId: ID! 109 | otherUserId: ID! 110 | message: String! 111 | ): Notification! 112 | @aws_iam 113 | 114 | sendDirectMessage( 115 | otherUserId: ID! 116 | message: String! 117 | ): Conversation! 118 | } 119 | 120 | type Subscription { 121 | onNotified(userId: ID!, type: NotificationType): Notification 122 | @aws_subscribe(mutations: ["notifyRetweeted", "notifyLiked", "notifyMentioned", "notifyReplied", "notifyDMed"]) 123 | } 124 | 125 | enum SearchMode { 126 | Top 127 | Latest 128 | People 129 | Photos 130 | Videos 131 | } 132 | 133 | enum HashTagMode { 134 | Top 135 | Latest 136 | People 137 | Photos 138 | Videos 139 | } 140 | 141 | input ProfileInput { 142 | name: String! 143 | imageUrl: AWSURL 144 | backgroundImageUrl: AWSURL 145 | bio: String 146 | location: String 147 | website: AWSURL 148 | birthdate: AWSDate 149 | } 150 | 151 | interface IProfile { 152 | id: ID! 153 | name: String! 154 | screenName: String! 155 | imageUrl: AWSURL 156 | backgroundImageUrl: AWSURL 157 | bio: String 158 | location: String 159 | website: AWSURL 160 | birthdate: AWSDate 161 | createdAt: AWSDateTime! 162 | tweets: TweetsPage! 163 | followersCount: Int! 164 | followingCount: Int! 165 | tweetsCount: Int! 166 | likesCounts: Int! 167 | } 168 | 169 | type MyProfile implements IProfile { 170 | id: ID! 171 | name: String! 172 | screenName: String! 173 | imageUrl: AWSURL 174 | backgroundImageUrl: AWSURL 175 | bio: String 176 | location: String 177 | website: AWSURL 178 | birthdate: AWSDate 179 | createdAt: AWSDateTime! 180 | tweets: TweetsPage! 181 | followersCount: Int! 182 | followingCount: Int! 183 | tweetsCount: Int! 184 | likesCounts: Int! 185 | } 186 | 187 | type OtherProfile implements IProfile { 188 | id: ID! 189 | name: String! 190 | screenName: String! 191 | imageUrl: AWSURL 192 | backgroundImageUrl: AWSURL 193 | bio: String 194 | location: String 195 | website: AWSURL 196 | birthdate: AWSDate 197 | createdAt: AWSDateTime! 198 | tweets: TweetsPage! 199 | followersCount: Int! 200 | followingCount: Int! 201 | tweetsCount: Int! 202 | likesCounts: Int! 203 | following: Boolean! 204 | followedBy: Boolean! 205 | } 206 | 207 | interface ITweet { 208 | id: ID! 209 | profile: IProfile 210 | createdAt: AWSDateTime! 211 | } 212 | 213 | type Tweet implements ITweet { 214 | id: ID! 215 | profile: IProfile 216 | createdAt: AWSDateTime! 217 | text: String! 218 | replies: Int! 219 | likes: Int! 220 | retweets: Int! 221 | liked: Boolean! 222 | retweeted: Boolean! 223 | } 224 | 225 | type Reply implements ITweet { 226 | id: ID! 227 | profile: IProfile 228 | createdAt: AWSDateTime! 229 | inReplyToTweet: ITweet! 230 | inReplyToUsers: [IProfile!] 231 | text: String! 232 | replies: Int! 233 | likes: Int! 234 | retweets: Int! 235 | liked: Boolean! 236 | retweeted: Boolean! 237 | } 238 | 239 | type Retweet implements ITweet { 240 | id: ID! 241 | profile: IProfile 242 | createdAt: AWSDateTime! 243 | retweetOf: ITweet! 244 | } 245 | 246 | type TweetsPage { 247 | tweets: [ITweet!] 248 | nextToken: String 249 | } 250 | 251 | type UnhydratedTweetsPage { 252 | tweets: [ITweet!] 253 | nextToken: String 254 | } 255 | 256 | type ProfilesPage { 257 | profiles: [IProfile!] 258 | nextToken: String 259 | } 260 | 261 | union SearchResult = MyProfile | OtherProfile | Tweet | Reply 262 | 263 | type SearchResultsPage { 264 | results: [SearchResult!] 265 | nextToken: String 266 | } 267 | 268 | union HashTagResult = MyProfile | OtherProfile | Tweet | Reply 269 | 270 | type HashTagResultsPage { 271 | results: [HashTagResult!] 272 | nextToken: String 273 | } 274 | 275 | type Retweeted implements iNotification @aws_iam @aws_cognito_user_pools { 276 | id: ID! 277 | type: NotificationType! 278 | userId: ID! 279 | tweetId: ID! 280 | retweetedBy: ID! 281 | retweetId: ID! 282 | createdAt: AWSDateTime! 283 | } 284 | 285 | type Liked implements iNotification @aws_iam @aws_cognito_user_pools { 286 | id: ID! 287 | type: NotificationType! 288 | userId: ID! 289 | tweetId: ID! 290 | likedBy: ID! 291 | createdAt: AWSDateTime! 292 | } 293 | 294 | type Mentioned implements iNotification @aws_iam @aws_cognito_user_pools { 295 | id: ID! 296 | type: NotificationType! 297 | userId: ID! 298 | mentionedBy: ID! 299 | mentionedByTweetId: ID! 300 | createdAt: AWSDateTime! 301 | } 302 | 303 | type Replied implements iNotification @aws_iam @aws_cognito_user_pools { 304 | id: ID! 305 | type: NotificationType! 306 | userId: ID! 307 | tweetId: ID! 308 | replyTweetId: ID! 309 | repliedBy: ID! 310 | createdAt: AWSDateTime! 311 | } 312 | 313 | type DMed implements iNotification @aws_iam @aws_cognito_user_pools { 314 | id: ID! 315 | type: NotificationType! 316 | userId: ID! 317 | createdAt: AWSDateTime! 318 | otherUserId: ID! 319 | message: String! 320 | } 321 | 322 | union Notification @aws_iam @aws_cognito_user_pools = Retweeted | Liked | Mentioned | Replied | DMed 323 | 324 | interface iNotification @aws_iam @aws_cognito_user_pools { 325 | id: ID! 326 | type: NotificationType! 327 | userId: ID! 328 | createdAt: AWSDateTime! 329 | } 330 | 331 | enum NotificationType { 332 | Retweeted 333 | Liked 334 | Mentioned 335 | Replied 336 | DMed 337 | } 338 | 339 | type Conversation { 340 | id: ID! 341 | otherUser: OtherProfile! 342 | lastMessage: String! 343 | lastModified: AWSDateTime! 344 | } 345 | 346 | type ConversationsPage { 347 | conversations: [Conversation!] 348 | nextToken: String 349 | } 350 | 351 | type Message { 352 | messageId: ID! 353 | from: IProfile! 354 | message: String! 355 | timestamp: AWSDateTime! 356 | } 357 | 358 | type MessagesPage { 359 | messages: [Message!] 360 | nextToken: String 361 | } 362 | 363 | type AnalyticsConfig @aws_iam @aws_cognito_user_pools { 364 | identityPoolId: ID! 365 | streamName: String! 366 | } -------------------------------------------------------------------------------- /scripts/dummy-update-tweets.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | AWS.config.region = 'eu-west-1' 3 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 4 | 5 | const { resolve } = require('path') 6 | require('dotenv').config({ 7 | path: resolve(__dirname, '../.env'), 8 | }) 9 | 10 | const run = async () => { 11 | const loop = async (exclusiveStartKey) => { 12 | const resp = await DynamoDB.scan({ 13 | TableName: process.env.TWEETS_TABLE, 14 | ExclusiveStartKey: exclusiveStartKey, 15 | Limit: 100 16 | }).promise() 17 | 18 | const promises = resp.Items.map(async x => { 19 | await DynamoDB.update({ 20 | TableName: process.env.TWEETS_TABLE, 21 | Key: { 22 | id: x.id 23 | }, 24 | UpdateExpression: "SET lastUpdated = :now", 25 | ExpressionAttributeValues: { 26 | ":now": new Date().toJSON() 27 | } 28 | }).promise() 29 | }) 30 | await Promise.all(promises) 31 | 32 | if (resp.LastEvaluatedKey) { 33 | return await loop(resp.LastEvaluatedKey) 34 | } 35 | } 36 | 37 | await loop() 38 | } 39 | 40 | run().then(x => console.log('all done')) 41 | -------------------------------------------------------------------------------- /scripts/dummy-update-users.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | AWS.config.region = 'eu-west-1' 3 | const DynamoDB = new AWS.DynamoDB.DocumentClient() 4 | 5 | const { resolve } = require('path') 6 | require('dotenv').config({ 7 | path: resolve(__dirname, '../.env'), 8 | }) 9 | 10 | const run = async () => { 11 | const loop = async (exclusiveStartKey) => { 12 | const resp = await DynamoDB.scan({ 13 | TableName: process.env.USERS_TABLE, 14 | ExclusiveStartKey: exclusiveStartKey, 15 | Limit: 100 16 | }).promise() 17 | 18 | const promises = resp.Items.map(async x => { 19 | await DynamoDB.update({ 20 | TableName: process.env.USERS_TABLE, 21 | Key: { 22 | id: x.id 23 | }, 24 | UpdateExpression: "SET lastUpdated = :now", 25 | ExpressionAttributeValues: { 26 | ":now": new Date().toJSON() 27 | } 28 | }).promise() 29 | }) 30 | await Promise.all(promises) 31 | 32 | if (resp.LastEvaluatedKey) { 33 | return await loop(resp.LastEvaluatedKey) 34 | } 35 | } 36 | 37 | await loop() 38 | } 39 | 40 | run().then(x => console.log('all done')) 41 | -------------------------------------------------------------------------------- /serverless.appsync-api.yml: -------------------------------------------------------------------------------- 1 | name: appsyncmasterclass 2 | schema: schema.api.graphql 3 | caching: ${self:custom.appSyncCaching.${self:custom.stage}, self:custom.appSyncCaching.default} 4 | authenticationType: AMAZON_COGNITO_USER_POOLS 5 | userPoolConfig: 6 | awsRegion: eu-west-1 7 | defaultAction: ALLOW 8 | userPoolId: !Ref CognitoUserPool 9 | logConfig: 10 | loggingRoleArn: !GetAtt AppSyncLoggingServiceRole.Arn 11 | level: ${self:custom.appSyncLogLevel.${self:custom.stage}, self:custom.appSyncLogLevel.default} 12 | excludeVerboseContent: false 13 | xrayEnabled: true 14 | additionalAuthenticationProviders: 15 | - authenticationType: AWS_IAM 16 | mappingTemplatesLocation: mapping-templates 17 | mappingTemplates: 18 | - type: Subscription 19 | field: onNotified 20 | dataSource: none 21 | 22 | - type: Query 23 | field: getAnalyticsConfig 24 | dataSource: none 25 | - type: Query 26 | field: getMyProfile 27 | dataSource: usersTable 28 | - type: Query 29 | field: getProfile 30 | dataSource: usersTable 31 | caching: 32 | keys: 33 | - $context.arguments.screenName 34 | ttl: 300 35 | - type: Query 36 | field: getImageUploadUrl 37 | dataSource: getImageUploadUrlFunction 38 | request: false 39 | response: false 40 | - type: Query 41 | field: getTweets 42 | dataSource: tweetsTable 43 | - type: Query 44 | field: getMyTimeline 45 | dataSource: timelinesTable 46 | - type: Query 47 | field: getLikes 48 | dataSource: likesTable 49 | - type: Query 50 | field: getFollowers 51 | kind: PIPELINE 52 | functions: 53 | - getFollowers 54 | - hydrateFollowers 55 | request: simplePipeline.request.vtl 56 | response: simplePipeline.response.vtl 57 | - type: Query 58 | field: getFollowing 59 | kind: PIPELINE 60 | functions: 61 | - getFollowing 62 | - hydrateFollowing 63 | request: simplePipeline.request.vtl 64 | response: simplePipeline.response.vtl 65 | - type: Query 66 | field: search 67 | dataSource: searchFunction 68 | request: false 69 | response: false 70 | - type: Query 71 | field: getHashTag 72 | dataSource: getHashTagFunction 73 | request: false 74 | response: false 75 | - type: Query 76 | field: listConversations 77 | dataSource: conversationsTable 78 | - type: Query 79 | field: getDirectMessages 80 | dataSource: directMessagesTable 81 | 82 | - type: Mutation 83 | field: editMyProfile 84 | dataSource: usersTable 85 | - type: Mutation 86 | field: tweet 87 | dataSource: tweetFunction 88 | request: false 89 | response: false 90 | - type: Mutation 91 | field: like 92 | dataSource: likeMutation 93 | - type: Mutation 94 | field: unlike 95 | dataSource: unlikeMutation 96 | - type: Mutation 97 | field: retweet 98 | dataSource: retweetFunction 99 | request: false 100 | response: false 101 | - type: Mutation 102 | field: unretweet 103 | dataSource: unretweetFunction 104 | request: false 105 | response: false 106 | - type: Mutation 107 | field: reply 108 | dataSource: replyFunction 109 | request: false 110 | response: false 111 | - type: Mutation 112 | field: follow 113 | dataSource: followMutation 114 | - type: Mutation 115 | field: unfollow 116 | dataSource: unfollowMutation 117 | - type: Mutation 118 | field: notifyRetweeted 119 | dataSource: notificationsTable 120 | - type: Mutation 121 | field: notifyLiked 122 | dataSource: notificationsTable 123 | - type: Mutation 124 | field: notifyDMed 125 | dataSource: notificationsTable 126 | - type: Mutation 127 | field: notifyMentioned 128 | dataSource: notificationsTable 129 | - type: Mutation 130 | field: notifyReplied 131 | dataSource: notificationsTable 132 | - type: Mutation 133 | field: sendDirectMessage 134 | dataSource: sendDirectMessageFunction 135 | request: false 136 | response: false 137 | 138 | # NESTED FIELDS 139 | - type: Tweet 140 | field: profile 141 | dataSource: getTweetCreatorFunction 142 | request: Tweet.profile.batchInvoke.request.vtl 143 | response: Tweet.profile.batchInvoke.response.vtl 144 | caching: 145 | keys: 146 | - $context.identity.username 147 | - $context.source.creator 148 | ttl: 300 149 | - type: Retweet 150 | field: profile 151 | dataSource: getTweetCreatorFunction 152 | request: Tweet.profile.batchInvoke.request.vtl 153 | response: Tweet.profile.batchInvoke.response.vtl 154 | caching: 155 | keys: 156 | - $context.identity.username 157 | - $context.source.creator 158 | ttl: 300 159 | - type: Reply 160 | field: profile 161 | dataSource: getTweetCreatorFunction 162 | request: Tweet.profile.batchInvoke.request.vtl 163 | response: Tweet.profile.batchInvoke.response.vtl 164 | caching: 165 | keys: 166 | - $context.identity.username 167 | - $context.source.creator 168 | ttl: 300 169 | - type: Tweet 170 | field: liked 171 | dataSource: likesTable 172 | - type: Reply 173 | field: liked 174 | dataSource: likesTable 175 | request: Tweet.liked.request.vtl 176 | response: Tweet.liked.response.vtl 177 | - type: Tweet 178 | field: retweeted 179 | dataSource: retweetsTable 180 | - type: Reply 181 | field: retweeted 182 | dataSource: retweetsTable 183 | request: Tweet.retweeted.request.vtl 184 | response: Tweet.retweeted.response.vtl 185 | - type: Retweet 186 | field: retweetOf 187 | dataSource: tweetsTable 188 | - type: Reply 189 | field: inReplyToTweet 190 | dataSource: tweetsTable 191 | - type: Reply 192 | field: inReplyToUsers 193 | dataSource: usersTable 194 | caching: 195 | keys: 196 | - $context.identity.username 197 | - $context.source.inReplyToUserIds 198 | ttl: 300 199 | - type: UnhydratedTweetsPage 200 | field: tweets 201 | dataSource: tweetsTable 202 | - type: MyProfile 203 | field: tweets 204 | dataSource: tweetsTable 205 | - type: OtherProfile 206 | field: tweets 207 | dataSource: tweetsTable 208 | request: MyProfile.tweets.request.vtl 209 | response: MyProfile.tweets.response.vtl 210 | - type: OtherProfile 211 | field: following 212 | dataSource: relationshipsTable 213 | - type: OtherProfile 214 | field: followedBy 215 | dataSource: relationshipsTable 216 | - type: Conversation 217 | field: otherUser 218 | dataSource: usersTable 219 | - type: Message 220 | field: from 221 | dataSource: usersTable 222 | 223 | functionConfigurations: 224 | - name: getFollowers 225 | dataSource: relationshipsTable 226 | - name: hydrateFollowers 227 | dataSource: usersTable 228 | - name: getFollowing 229 | dataSource: relationshipsTable 230 | - name: hydrateFollowing 231 | dataSource: usersTable 232 | 233 | dataSources: 234 | - type: NONE 235 | name: none 236 | - type: AMAZON_DYNAMODB 237 | name: usersTable 238 | config: 239 | tableName: !Ref UsersTable 240 | - type: AMAZON_DYNAMODB 241 | name: tweetsTable 242 | config: 243 | tableName: !Ref TweetsTable 244 | - type: AMAZON_DYNAMODB 245 | name: timelinesTable 246 | config: 247 | tableName: !Ref TimelinesTable 248 | - type: AMAZON_DYNAMODB 249 | name: likesTable 250 | config: 251 | tableName: !Ref LikesTable 252 | - type: AMAZON_DYNAMODB 253 | name: retweetsTable 254 | config: 255 | tableName: !Ref RetweetsTable 256 | - type: AMAZON_DYNAMODB 257 | name: relationshipsTable 258 | config: 259 | tableName: !Ref RelationshipsTable 260 | - type: AMAZON_DYNAMODB 261 | name: notificationsTable 262 | config: 263 | tableName: !Ref NotificationsTable 264 | - type: AMAZON_DYNAMODB 265 | name: conversationsTable 266 | config: 267 | tableName: !Ref ConversationsTable 268 | - type: AMAZON_DYNAMODB 269 | name: directMessagesTable 270 | config: 271 | tableName: !Ref DirectMessagesTable 272 | - type: AMAZON_DYNAMODB 273 | name: likeMutation 274 | config: 275 | tableName: !Ref LikesTable 276 | iamRoleStatements: 277 | - Effect: Allow 278 | Action: dynamodb:PutItem 279 | Resource: !GetAtt LikesTable.Arn 280 | - Effect: Allow 281 | Action: dynamodb:UpdateItem 282 | Resource: 283 | - !GetAtt UsersTable.Arn 284 | - !GetAtt TweetsTable.Arn 285 | - type: AMAZON_DYNAMODB 286 | name: unlikeMutation 287 | config: 288 | tableName: !Ref LikesTable 289 | iamRoleStatements: 290 | - Effect: Allow 291 | Action: dynamodb:DeleteItem 292 | Resource: !GetAtt LikesTable.Arn 293 | - Effect: Allow 294 | Action: dynamodb:UpdateItem 295 | Resource: 296 | - !GetAtt UsersTable.Arn 297 | - !GetAtt TweetsTable.Arn 298 | - type: AMAZON_DYNAMODB 299 | name: followMutation 300 | config: 301 | tableName: !Ref RelationshipsTable 302 | iamRoleStatements: 303 | - Effect: Allow 304 | Action: dynamodb:PutItem 305 | Resource: !GetAtt RelationshipsTable.Arn 306 | - Effect: Allow 307 | Action: dynamodb:UpdateItem 308 | Resource: !GetAtt UsersTable.Arn 309 | - type: AMAZON_DYNAMODB 310 | name: unfollowMutation 311 | config: 312 | tableName: !Ref RelationshipsTable 313 | iamRoleStatements: 314 | - Effect: Allow 315 | Action: dynamodb:DeleteItem 316 | Resource: !GetAtt RelationshipsTable.Arn 317 | - Effect: Allow 318 | Action: dynamodb:UpdateItem 319 | Resource: !GetAtt UsersTable.Arn 320 | - type: AWS_LAMBDA 321 | name: getImageUploadUrlFunction 322 | config: 323 | functionName: getImageUploadUrl 324 | - type: AWS_LAMBDA 325 | name: tweetFunction 326 | config: 327 | functionName: tweet 328 | - type: AWS_LAMBDA 329 | name: retweetFunction 330 | config: 331 | functionName: retweet 332 | - type: AWS_LAMBDA 333 | name: unretweetFunction 334 | config: 335 | functionName: unretweet 336 | - type: AWS_LAMBDA 337 | name: replyFunction 338 | config: 339 | functionName: reply 340 | - type: AWS_LAMBDA 341 | name: searchFunction 342 | config: 343 | functionName: search 344 | - type: AWS_LAMBDA 345 | name: getHashTagFunction 346 | config: 347 | functionName: getHashTag 348 | - type: AWS_LAMBDA 349 | name: sendDirectMessageFunction 350 | config: 351 | functionName: sendDirectMessage 352 | - type: AWS_LAMBDA 353 | name: getTweetCreatorFunction 354 | config: 355 | functionName: getTweetCreator 356 | 357 | substitutions: 358 | TweetsTable: !Ref TweetsTable 359 | LikesTable: !Ref LikesTable 360 | UsersTable: !Ref UsersTable 361 | RelationshipsTable: !Ref RelationshipsTable 362 | FirehoseStreamName: !Ref FirehoseStream 363 | IdentityPoolId: !Ref IdentityPool -------------------------------------------------------------------------------- /testCognitoIdentity.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const AWS = require('aws-sdk') 3 | 4 | const providerName = process.env.COGNITO_USER_POOL_PROVIDER_NAME 5 | 6 | AWS.config.credentials = new AWS.CognitoIdentityCredentials({ 7 | IdentityPoolId: process.env.COGNITO_IDENTITY_POOL_ID, 8 | Logins: { 9 | [providerName]: 'eyJraWQiOiJhdTlUcUxaT0pTZitnRklhRXdJT2NFcG1HTnBMdDBWRUkzUFlIdjdnUW0wPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJkMzg3NjY4NS00YTJjLTQ0NzgtYjEwYy0xZDIzN2UzMDAzNDIiLCJhdWQiOiI2bmRmY2piZ2M5Yzc5cjBncDlpMGQ1cDl2diIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZXZlbnRfaWQiOiJiYmQ1Yjg5MS1hY2FmLTQ2NWMtOGYxYy01MDA3YzA2MWVkNWYiLCJ0b2tlbl91c2UiOiJpZCIsImF1dGhfdGltZSI6MTYzODY3MTM2MiwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLmV1LXdlc3QtMS5hbWF6b25hd3MuY29tXC9ldS13ZXN0LTFfTXNpSXBpbmd4IiwibmFtZSI6IllhbiIsImNvZ25pdG86dXNlcm5hbWUiOiJkMzg3NjY4NS00YTJjLTQ0NzgtYjEwYy0xZDIzN2UzMDAzNDIiLCJleHAiOjE2Mzg2NzQ5NjIsImlhdCI6MTYzODY3MTM2MiwiZW1haWwiOiJ0aGVidXJuaW5nbW9ua0BnbWFpbC5jb20ifQ.UxgPC3wYgyS0Ck_57X7b-G3k_-Ieg6fDyx7utertSYdScJEVDTJXUwPFzqqesKsuHfMkFi2xVO9UgsIslQ0BJUQazCUm4FqOiMEzfCumG2kwr0g2pxF81EHiz0ZkT-24ticGBwPhUGx7OZGjOa-UhOQN354I4H48KBbUOjLVPHqUw16K79ugJJopdNjIbVFzpn6yUCQJ3n34At5xxPFwLDV240AQ4f1C0gjos05N6Z5Qfr_xEi07Psjy-3R_9X4DJjo7vA1GAqBrkPPr4tj9ICl_HmLfhwNgUxXzEcdbvskcZwbqu_zYq7MpyPMO_uqV6MyPewW1d1WyLUEcor7jvw' 10 | } 11 | }) 12 | 13 | AWS.config.credentials.get(function() { 14 | const { accessKeyId, secretAccessKey, sessionToken } = AWS.config.credentials 15 | process.env.AWS_ACCESS_KEY_ID = accessKeyId 16 | process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey 17 | process.env.AWS_SESSION_TOKEN = sessionToken 18 | 19 | const Firehose = new AWS.Firehose() 20 | Firehose.putRecord({ 21 | DeliveryStreamName: process.env.FIREHOSE_STREAM_NAME, 22 | Record: { 23 | Data: JSON.stringify({ 24 | eventType: 'impression', 25 | tweetId: '123' 26 | }) 27 | } 28 | }).promise() 29 | .then(() => console.log('all done')) 30 | .catch(err => console.error('failed', err)) 31 | 32 | }) --------------------------------------------------------------------------------