├── .circleci
└── config.yml
├── .gitignore
├── README.md
├── _config.yml
├── doc
├── build_a_schema.md
├── connect_client_api.md
├── fetch_data_with_queries.md
├── get_started.md
├── graph_resolvers.md
├── hook_up_datasource.md
├── img
│ ├── console.png
│ ├── graphQL.png
│ └── spacex.png
├── manage_local_state.md
└── update_data_with_mutations.md
├── final
├── client
│ ├── .env.example
│ ├── .gitignore
│ ├── README.md
│ ├── apollo.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ └── manifest.json
│ ├── src
│ │ ├── assets
│ │ │ ├── curve.svg
│ │ │ ├── icons
│ │ │ │ ├── cart.svg
│ │ │ │ ├── exit.svg
│ │ │ │ ├── home.svg
│ │ │ │ └── profile.svg
│ │ │ ├── images
│ │ │ │ ├── badge-1.png
│ │ │ │ ├── badge-2.png
│ │ │ │ ├── badge-3.png
│ │ │ │ ├── dog-1.png
│ │ │ │ ├── dog-2.png
│ │ │ │ ├── dog-3.png
│ │ │ │ ├── galaxy.jpg
│ │ │ │ ├── iss.jpg
│ │ │ │ ├── moon.jpg
│ │ │ │ └── space.jpg
│ │ │ ├── logo.svg
│ │ │ └── rocket.svg
│ │ ├── components
│ │ │ ├── __tests__
│ │ │ │ ├── button.js
│ │ │ │ ├── footer.js
│ │ │ │ ├── header.js
│ │ │ │ ├── launch-detail.js
│ │ │ │ ├── launch-tile.js
│ │ │ │ ├── loading.js
│ │ │ │ ├── login-form.js
│ │ │ │ ├── menu-item.js
│ │ │ │ └── page-container.js
│ │ │ ├── button.js
│ │ │ ├── footer.js
│ │ │ ├── header.js
│ │ │ ├── index.js
│ │ │ ├── launch-detail.js
│ │ │ ├── launch-tile.js
│ │ │ ├── loading.js
│ │ │ ├── login-form.js
│ │ │ ├── menu-item.js
│ │ │ └── page-container.js
│ │ ├── containers
│ │ │ ├── __tests__
│ │ │ │ ├── action-button.js
│ │ │ │ ├── book-trips.js
│ │ │ │ ├── cart-item.js
│ │ │ │ └── logout-button.js
│ │ │ ├── action-button.js
│ │ │ ├── book-trips.js
│ │ │ ├── cart-item.js
│ │ │ ├── index.js
│ │ │ └── logout-button.js
│ │ ├── index.js
│ │ ├── pages
│ │ │ ├── __tests__
│ │ │ │ ├── cart.js
│ │ │ │ ├── launch.js
│ │ │ │ ├── launches.js
│ │ │ │ ├── login.js
│ │ │ │ └── profile.js
│ │ │ ├── cart.js
│ │ │ ├── index.js
│ │ │ ├── launch.js
│ │ │ ├── launches.js
│ │ │ ├── login.js
│ │ │ └── profile.js
│ │ ├── resolvers.js
│ │ ├── styles.js
│ │ └── test-utils.js
│ └── yarn.lock
└── server
│ ├── .env.example
│ ├── apollo.config.js
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── e2e.js.snap
│ │ │ └── integration.js.snap
│ │ ├── __utils.js
│ │ ├── e2e.js
│ │ ├── integration.js
│ │ ├── resolvers.mission.js
│ │ ├── resolvers.mutation.js
│ │ ├── resolvers.query.js
│ │ └── resolvers.user.js
│ ├── common
│ │ └── consts.js
│ ├── datasources
│ │ ├── __tests__
│ │ │ ├── launch.js
│ │ │ └── user.js
│ │ ├── launch.js
│ │ └── user.js
│ ├── index.js
│ ├── resolvers.js
│ ├── schema.js
│ └── utils.js
│ └── store.sqlite
├── google0f9167d144dd34a2.html
├── package-lock.json
└── start
├── client
├── .env.example
├── .gitignore
├── README.md
├── apollo.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── manifest.json
├── src
│ ├── assets
│ │ ├── curve.svg
│ │ ├── icons
│ │ │ ├── cart.svg
│ │ │ ├── exit.svg
│ │ │ ├── home.svg
│ │ │ └── profile.svg
│ │ ├── images
│ │ │ ├── badge-1.png
│ │ │ ├── badge-2.png
│ │ │ ├── badge-3.png
│ │ │ ├── dog-1.png
│ │ │ ├── dog-2.png
│ │ │ ├── dog-3.png
│ │ │ ├── galaxy.jpg
│ │ │ ├── iss.jpg
│ │ │ ├── moon.jpg
│ │ │ └── space.jpg
│ │ ├── logo.svg
│ │ └── rocket.svg
│ ├── components
│ │ ├── __tests__
│ │ │ ├── button.js
│ │ │ ├── footer.js
│ │ │ ├── header.js
│ │ │ ├── launch-detail.js
│ │ │ ├── launch-tile.js
│ │ │ ├── loading.js
│ │ │ ├── login-form.js
│ │ │ ├── menu-item.js
│ │ │ └── page-container.js
│ │ ├── button.js
│ │ ├── footer.js
│ │ ├── header.js
│ │ ├── index.js
│ │ ├── launch-detail.js
│ │ ├── launch-tile.js
│ │ ├── loading.js
│ │ ├── login-form.js
│ │ ├── menu-item.js
│ │ └── page-container.js
│ ├── containers
│ │ ├── __tests__
│ │ │ ├── action-button.js
│ │ │ ├── book-trips.js
│ │ │ ├── cart-item.js
│ │ │ └── logout-button.js
│ │ ├── action-button.js
│ │ ├── book-trips.js
│ │ ├── cart-item.js
│ │ ├── index.js
│ │ └── logout-button.js
│ ├── index.js
│ ├── pages
│ │ ├── __tests__
│ │ │ ├── cart.js
│ │ │ ├── launch.js
│ │ │ ├── launches.js
│ │ │ ├── login.js
│ │ │ └── profile.js
│ │ ├── cart.js
│ │ ├── index.js
│ │ ├── launch.js
│ │ ├── launches.js
│ │ ├── login.js
│ │ └── profile.js
│ ├── resolvers.js
│ ├── styles.js
│ └── test-utils.js
└── yarn.lock
└── server
├── .env.example
├── package-lock.json
├── package.json
├── src
├── __tests__
│ ├── __snapshots__
│ │ ├── e2e.js.snap
│ │ └── integration.js.snap
│ ├── __utils.js
│ ├── e2e.js
│ ├── integration.js
│ ├── resolvers.mission.js
│ ├── resolvers.mutation.js
│ ├── resolvers.query.js
│ └── resolvers.user.js
├── datasources
│ ├── __tests__
│ │ ├── launch.js
│ │ └── user.js
│ ├── launch.js
│ └── user.js
├── index.js
├── resolvers.js
├── schema.js
└── utils.js
└── store.sqlite
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 3
2 |
3 | jobs:
4 | Apollo Client:
5 | docker:
6 | - image: circleci/node:8
7 |
8 | steps:
9 | - checkout
10 | - run: cd final/client && npm ci
11 | - run: cd final/client && npm test
12 | - run: cd final/client && npx apollo client:check
13 |
14 | - run: |
15 | if [ "${CIRCLE_BRANCH}" == "master" ]; then
16 | cd final/client && npx apollo client:push
17 | fi
18 |
19 | Apollo Server:
20 | docker:
21 | - image: circleci/node:8
22 |
23 | steps:
24 | - checkout
25 | - run: cd final/server && npm ci
26 | - run: cd final/server && npm test
27 |
28 | - run:
29 | name: Starting server
30 | command: cd final/server && npm run start:ci
31 | background: true
32 |
33 | - run: sleep 5
34 | - run: cd final/server && npx apollo service:check
35 |
36 | - run: |
37 | if [ "${CIRCLE_BRANCH}" == "master" ]; then
38 | cd final/server && npx apollo service:push
39 | fi
40 |
41 | workflows:
42 | version: 3
43 | Build and Test:
44 | jobs:
45 | - Apollo Client
46 | - Apollo Server
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .history
2 | node_modules/
3 | .DS_Store
4 | .env
5 | .vscode
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 全栈学习教程
2 |
3 | Copyright (c) 2019 Gyges Zean
4 |
5 | This repository, free of charge, to any person obtaining a copy.
6 |
7 | `你有使用,拷贝,修改,合并,发布,分发,订阅或者把它卖出去的权力。任何你想到的你都可以去做。`
8 |
9 | `不过不要触犯法律。`
10 |
11 | ## 教程文档
12 |
13 | - [开始](./doc/get_started.md)
14 | - [构建schema](./doc/build_a_schema.md)
15 | - [连接数据源](./doc/hook_up_datasource.md)
16 | - [编写解析器](./doc/graph_resolvers.md)
17 | - [连接你的API到客户端](./doc/connect_client_api.md)
18 | - [获取查询数据](./doc/fetch_data_with_queries.md)
19 | - [通过mutations更新数据](./doc/update_data_with_mutations.md)
20 | - [管理本地状态](./doc/manage_local_state.md)
21 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-hacker
--------------------------------------------------------------------------------
/doc/connect_client_api.md:
--------------------------------------------------------------------------------
1 | - [主页](../README.md)
2 |
3 | # 连接你的API到客户端
4 | ## 把你的Graph连接到Apollo客户端
5 |
6 | 本章你会学到,如何使用React.js写的web客户端连接到后端服务,调用API。你还将了解如何构建身份验证和分页等基本功能,以及优化工作流的技巧。
7 |
8 | ## 建立好你的开发环境
9 |
10 | 此时在你的根目录下,在terminal里cd 进入client/目录下。
11 |
12 | ```shell
13 | cd start/client && npm install
14 | ```
15 |
16 | 下面我介绍一下,我们前端package.json引入的一些依赖:
17 |
18 | `apollo-clinet`: 一个完全智能的数据缓存管理解决方案,我们将使用Apollo Client 3.0,它包含本地状态管理功能。你可以配置管理你的缓存。
19 | `react-apollo`: 用于`Query`和`Mutation`与view层集成的组件
20 | `graphql-tag`: 标记函数gql,我们使用它来包装查询字符串,为了将它们解析为AST
21 |
22 |
23 | 接下来,让我们配置一下Apollo, 创建一个`apollo.config.js`的文件,并粘贴下列代码进去.
24 | 这个文件主要是配置web应用名和服务名
25 |
26 | ```javascript
27 | module.exports = {
28 | client: {
29 | name: 'Space Explorer [web]',
30 | service: 'space-explorer',
31 | },
32 | };
33 | ```
34 |
35 | ## 创建一个Apollo Client
36 |
37 | 现在我们已经安装了必要的包,让我们创建一个ApolloClient实例。
38 |
39 | 定位到`src/index.js`, 让我们来配置我们的URL,此URL就是指向后端服务的URL。
40 |
41 | ***src/index.js***
42 | ```javascript
43 | import { ApolloClient } from 'apollo-client';
44 | import { InMemoryCache } from 'apollo-cache-inmemory';
45 | import { HttpLink } from 'apollo-link-http';
46 |
47 | const cache = new InMemoryCache();
48 | const link = new HttpLink({
49 | uri: 'http://localhost:4000/'
50 | });
51 |
52 | const client = new ApolloClient({
53 | cache,
54 | link
55 | });
56 | ```
57 | 仅仅需要几行代码,我们的客户端就可以获取数据了。😄🎉🎉🎉🎉🧨🧨🧨
58 |
59 | ## 创建第一个查询
60 |
61 | 在向你展示如何使用Apollo的React集成之前,让我们先用普通的JavaScript发送一个查询。
62 |
63 | 使用`client.query()`来调用并查询graph的API。首先引入下列代码
64 | ```javascript
65 | import gql from "graphql-tag";
66 | ```
67 |
68 | 并将下面的代码添加到index.js的底部
69 |
70 | ```javascript
71 | // ... above is the instantiation of the client object.
72 | client
73 | .query({
74 | query: gql`
75 | query GetLaunch {
76 | launch(id: 56) {
77 | id
78 | mission {
79 | name
80 | }
81 | }
82 | }
83 | `
84 | })
85 | .then(result => console.log(result));
86 | ```
87 |
88 | 使用`npm start`将你的前端应用启动起来,打开浏览器,输入地址`http://localhost:3000/`, 打开开发者工具
89 | 在console里就可以看到你调用的graph API的数据。Apollo Client 提供了原生的JavaScript的调用方式。但如果用框架的话,这样的调用会更方便。
90 |
91 | 
92 |
93 | ## 如何集成GraphQL到react里
94 |
95 | 要将Apollo客户端连接到React,我们将把我们的应用程序包装在从`@ apollo / react-hooks`包导出的`ApolloProvider`组件中,并将client传递到`client` prop。 `ApolloProvider`组件类似于React的上下文提供程序。 它包装了你的React应用程序并将客户端放在上下文中,这使你可以从组件树中的任何位置访问它。
96 |
97 | 打开`src / index.js`并添加以下代码行:
98 | ***src/index.js***
99 |
100 | ```javascript
101 |
102 | import { ApolloProvider } from '@apollo/react-hooks';
103 | import React from 'react';
104 | import ReactDOM from 'react-dom';
105 | import Pages from './pages';
106 |
107 | // previous variable declarations
108 |
109 | ReactDOM.render(
110 |
111 |
112 | , document.getElementById('root')
113 | );
114 |
115 | ```
116 | OK, 现在我们准备使用`userQuery` Hook来创建我们第一个组件。
117 |
118 | - [上一页](./graph_resolvers.md) [下一页](./fetch_data_with_queries.md)
--------------------------------------------------------------------------------
/doc/get_started.md:
--------------------------------------------------------------------------------
1 |
2 | - [主页](../README.md)
3 |
4 | ## 说在前面
5 | ### 从这里开始学习如何构建全栈应用
6 |
7 | 欢迎各位,你可以通过这个教程指南学习如何使用强大的GraphQL构建全栈应用,废话不多说,直接进入主题。
8 |
9 | ### 我们将构建什么
10 |
11 | 一款为SpaceX飞船发射预定座位的软件
12 |
13 | 像下面这样:
14 |
15 |
16 | 
17 |
18 | 这个应用包含:
19 |
20 | - 登陆页
21 | - 即将发布的清单
22 | - 发射平台的细节景观
23 | - 用户的主页信息
24 | - 购物车
25 |
26 | 为了实现这些功能,我们需要连接两个数据源,REST API和SQLite 数据库。不过不用担心,你无需了解这两种技术细节。
27 |
28 | 还必须提到的是,为了构建真正符合规范的应用,我们还必须用到身份验证,分页,以及状态管理。
29 |
30 | ### 先决条件
31 | 本教程需要你熟悉 `JavaScript/ES6` 和 `React`, 如果你要复习一下, Click it [🔗](https://reactjs.org/tutorial/tutorial.html), 还有,你可以学习一下GraphQL [😄Click me](https://graphql.org/learn/queries/)
32 |
33 | #### 系统环境要求
34 | - Node.js v8.x or later
35 | - npm v6.x or later
36 | - git v2.14.1 or later
37 |
38 | 同样也建议你使用 [VS Code](https://code.visualstudio.com/) 编辑器来写代码。以便使用apollo的插件
39 |
40 | #### 克隆APP例子
41 |
42 | 克隆这个仓库:
43 |
44 | ```shell
45 | git clone https://github.com/GZ315200/fullstack_tutorial.git
46 | ```
47 |
48 | 克隆成功后,你可以看到两个文件,final 和 start, final是最终的代码,start是用来练习这个教程的demo,他们分别下面有两个文件,一个是client,另一个是server,server端构建graph API用到的。
49 | client端是用来浏览器访问的静态页面
50 |
51 | `不废话了,开始我们下一章节吧`
52 |
53 | - [下一页](./build_a_schema.md)
--------------------------------------------------------------------------------
/doc/img/console.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/doc/img/console.png
--------------------------------------------------------------------------------
/doc/img/graphQL.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/doc/img/graphQL.png
--------------------------------------------------------------------------------
/doc/img/spacex.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/doc/img/spacex.png
--------------------------------------------------------------------------------
/doc/update_data_with_mutations.md:
--------------------------------------------------------------------------------
1 | - [主页](../README.md)
2 |
3 | # 使用mutations更新数据
4 | ## 学习如何使用useMutation hook更新数据
5 |
6 | 接下来我们将学习如何使用useMutation hook来进行用户的登陆
7 |
8 |
9 | ## 什么是useMutation hook
10 |
11 | useMutation hook和useQuery一样都是来自@apollo/react-hooks。主要不同的是useMutation是用来更改数据
12 |
13 | ## 使用useMutation更新数据
14 |
15 | 第一步构建我们的GraphQL mutation。让我们导航到`src/pages/login.js`,并copy下列代码
16 |
17 | ***src/pages/login.js***
18 |
19 | ```javascript
20 | import React from 'react';
21 | import { useApolloClient, useMutation } from '@apollo/react-hooks';
22 | import gql from 'graphql-tag';
23 |
24 | import { LoginForm, Loading } from '../components';
25 |
26 | const LOGIN_USER = gql`
27 | mutation login($email: String!) {
28 | login(email: $email)
29 | }
30 | `;
31 | ```
32 |
33 | 接着让我们使用useMutation hook更新数据
34 |
35 | ***src/pages/login.js***
36 | ```javascript
37 | export default function Login() {
38 | const [login, { data }] = useMutation(LOGIN_USER);
39 | return ;
40 | }
41 | ```
42 |
43 | 为了给我们的用户创造更好的体验,我们希望在会话之间保持登录状态。 为此,我们需要将登录令牌保存到localStorage。 让我们学习如何使用useMutation的onCompleted处理程序来保持登录状态:
44 |
45 | ### 用useApolloClient暴露Apollo Client
46 |
47 | useApolloClient Hook 可以帮助我们访问客户端。
48 | 首先,让我们调用useApolloClient来获取当前已配置的客户端实例。接下来,我们像通过一个onCompleted的回调useMutation,同样我们在该回调里保存登陆token在localStorage里
49 | 我们在此次调用中也会使用到client.writeData方法将本地数据写入Apollo缓存中,用于标示用户已经登陆了。OK,来看看例子吧:
50 |
51 | ***src/pages/login.js***
52 |
53 | ```javascript
54 | export default function Login() {
55 |
56 | const client = useApolloClient();
57 | const [login, { loading, error }] = useMutation(
58 | LOGIN_USER,
59 | {
60 | onCompleted({ login }) {
61 | localStorage.setItem('token', login.token);
62 | client.writeData({ data: { isLoggedIn: true } });
63 | }
64 | }
65 | );
66 |
67 | if (loading) return ;
68 | if (error) return
An error occurred
;
69 |
70 | return ;
71 | }
72 |
73 | ```
74 |
75 | ### 粘贴token信息到authorization报头去请求信息
76 |
77 | 我们将token信息粘贴带authorization报头内,然后请求后端数据。
78 | 我们将下列代码粘贴到src/index.js内,并替换之前对的client实例化方式,增加携带报头
79 | authorization的请求方式
80 |
81 | ***src/index.js***
82 |
83 | ```javascript
84 | const client = new ApolloClient({
85 | cache,
86 | link: new HttpLink({
87 | uri: 'http://localhost:4000/graphql',
88 |
89 | headers: {
90 | authorization: localStorage.getItem('token'),
91 | },
92 | }),
93 | });
94 |
95 | cache.writeData({
96 | data: {
97 | isLoggedIn: !!localStorage.getItem('token'),
98 | cartItems: [],
99 | },
100 | });
101 | ```
102 |
103 | 指定headers项,并增加localStorage来保存token信息。
104 |
105 |
106 |
107 |
108 | - [上一页](./fetch_data_with_queries.md) [下一页](./manage_local_state.md)
109 |
--------------------------------------------------------------------------------
/final/client/.env.example:
--------------------------------------------------------------------------------
1 | ENGINE_API_KEY=
--------------------------------------------------------------------------------
/final/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/final/client/README.md:
--------------------------------------------------------------------------------
1 | # Apollo Fullstack Tutorial
2 |
3 | ## Client
4 |
--------------------------------------------------------------------------------
/final/client/apollo.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | client: {
3 | name: 'Space Explorer [web]',
4 | service: 'space-explorer',
5 | }
6 | }
--------------------------------------------------------------------------------
/final/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/react-hooks": "3.0.0",
7 | "@reach/router": "^1.2.1",
8 | "apollo-cache-inmemory": "^1.6.2",
9 | "apollo-client": "^2.6.3",
10 | "apollo-link-http": "^1.5.15",
11 | "emotion": "^9.2.12",
12 | "graphql": "^14.4.2",
13 | "graphql-tag": "^2.10.1",
14 | "polished": "^3.4.1",
15 | "react": "^16.9.0-alpha.0",
16 | "react-dom": "^16.9.0-alpha.0",
17 | "react-emotion": "^9.2.12",
18 | "react-scripts": "3.0.1"
19 | },
20 | "scripts": {
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test",
24 | "eject": "react-scripts eject"
25 | },
26 | "eslintConfig": {
27 | "extends": "react-app"
28 | },
29 | "browserslist": [
30 | ">0.2%",
31 | "not dead",
32 | "not ie <= 11",
33 | "not op_mini all"
34 | ],
35 | "devDependencies": {
36 | "@apollo/react-testing": "3.0.0",
37 | "@testing-library/jest-dom": "^4.0.0",
38 | "@testing-library/react": "^8.0.7",
39 | "apollo": "^2.16.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/final/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/public/favicon.ico
--------------------------------------------------------------------------------
/final/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
23 | Launches
24 |
25 |
26 |
27 |
28 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/final/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Launches",
3 | "name": "Launches",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/final/client/src/assets/curve.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/assets/icons/cart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/assets/icons/exit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/assets/icons/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/assets/icons/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/assets/images/badge-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/badge-1.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/badge-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/badge-2.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/badge-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/badge-3.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/dog-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/dog-1.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/dog-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/dog-2.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/dog-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/dog-3.png
--------------------------------------------------------------------------------
/final/client/src/assets/images/galaxy.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/galaxy.jpg
--------------------------------------------------------------------------------
/final/client/src/assets/images/iss.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/iss.jpg
--------------------------------------------------------------------------------
/final/client/src/assets/images/moon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/moon.jpg
--------------------------------------------------------------------------------
/final/client/src/assets/images/space.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/client/src/assets/images/space.jpg
--------------------------------------------------------------------------------
/final/client/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Button from '../button';
5 |
6 | describe('Button', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { renderApollo, cleanup } from '../../test-utils';
4 | import Footer from '../footer';
5 |
6 | describe('Footer', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | renderApollo();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Header from '../header';
5 |
6 | describe('Header', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/launch-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LaunchDetail from '../launch-detail';
5 |
6 | describe('Launch Detail View', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render(
12 | ,
17 | );
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/launch-tile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LaunchTile from '../launch-tile';
5 |
6 | describe('Launch Tile', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render(
12 | ,
19 | );
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Loading from '../loading';
5 |
6 | describe('Loading', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/login-form.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LoginForm from '../login-form';
5 |
6 | describe('Login Form', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/menu-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import MenuItem from '../menu-item';
5 |
6 | describe('Menu Item', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/__tests__/page-container.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import PageContainer from '../page-container';
5 |
6 | describe('Page Container', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/final/client/src/components/button.js:
--------------------------------------------------------------------------------
1 | import styled from 'react-emotion';
2 | import { lighten } from 'polished';
3 |
4 | import { unit, colors } from '../styles';
5 |
6 | const height = 50;
7 | export default styled('button')({
8 | display: 'block',
9 | minWidth: 200,
10 | height,
11 | margin: '0 auto',
12 | padding: `0 ${unit * 4}px`,
13 | border: 'none',
14 | borderRadius: height / 2,
15 | fontFamily: 'inherit',
16 | fontSize: 18,
17 | lineHeight: `${height}px`,
18 | fontWeight: 700,
19 | color: 'white',
20 | textTransform: 'uppercase',
21 | backgroundColor: colors.accent,
22 | cursor: 'pointer',
23 | outline: 'none',
24 | ':hover': {
25 | backgroundColor: lighten(0.1, colors.accent),
26 | },
27 | ':active': {
28 | backgroundColor: lighten(0.2, colors.accent),
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/final/client/src/components/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import MenuItem from './menu-item';
5 | import LogoutButton from '../containers/logout-button';
6 | import { ReactComponent as HomeIcon } from '../assets/icons/home.svg';
7 | import { ReactComponent as CartIcon } from '../assets/icons/cart.svg';
8 | import { ReactComponent as ProfileIcon } from '../assets/icons/profile.svg';
9 | import { colors, unit } from '../styles';
10 |
11 | export default function Footer() {
12 | return (
13 |
14 |
15 |
19 |
23 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | /**
34 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
35 | */
36 |
37 | const Container = styled('footer')({
38 | flexShrink: 0,
39 | marginTop: 'auto',
40 | backgroundColor: 'white',
41 | color: colors.textSecondary,
42 | position: 'sticky',
43 | bottom: 0,
44 | });
45 |
46 | const InnerContainer = styled('div')({
47 | display: 'flex',
48 | alignItems: 'center',
49 | maxWidth: 460,
50 | padding: unit * 2.5,
51 | margin: '0 auto',
52 | });
53 |
--------------------------------------------------------------------------------
/final/client/src/components/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 | import { size } from 'polished';
4 |
5 | import { unit, colors } from '../styles';
6 | import dog1 from '../assets/images/dog-1.png';
7 | import dog2 from '../assets/images/dog-2.png';
8 | import dog3 from '../assets/images/dog-3.png';
9 |
10 | const max = 25; // 25 letters in the alphabet
11 | const offset = 97; // letter A's charcode is 97
12 | const avatars = [dog1, dog2, dog3];
13 | const maxIndex = avatars.length - 1;
14 | function pickAvatarByEmail(email) {
15 | const charCode = email.toLowerCase().charCodeAt(0) - offset;
16 | const percentile = Math.max(0, Math.min(max, charCode)) / max;
17 | return avatars[Math.round(maxIndex * percentile)];
18 | }
19 |
20 | export default function Header({ image, children = 'Space Explorer' }) {
21 | const email = atob(localStorage.getItem('token'));
22 | const avatar = image || pickAvatarByEmail(email);
23 | return (
24 |
25 |
26 |
27 |
{children}
28 | {email}
29 |
30 |
31 | );
32 | }
33 |
34 | /**
35 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
36 | */
37 |
38 | const Container = styled('div')({
39 | display: 'flex',
40 | alignItems: 'center',
41 | marginBottom: unit * 4.5,
42 | });
43 |
44 | const Image = styled('img')(size(134), props => ({
45 | marginRight: unit * 2.5,
46 | borderRadius: props.round && '50%',
47 | }));
48 |
49 | const Subheading = styled('h5')({
50 | marginTop: unit / 2,
51 | color: colors.textSecondary,
52 | });
53 |
--------------------------------------------------------------------------------
/final/client/src/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as Button } from './button';
2 | export { default as Footer } from './footer';
3 | export { default as Header } from './header';
4 | export { default as LaunchDetail } from './launch-detail';
5 | export { default as LaunchTile } from './launch-tile';
6 | export { default as Loading } from './loading';
7 | export { default as LoginForm } from './login-form';
8 | export { default as MenuItem } from './menu-item';
9 | export { default as PageContainer } from './page-container';
10 |
--------------------------------------------------------------------------------
/final/client/src/components/launch-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import { unit } from '../styles';
5 | import { cardClassName, getBackgroundImage } from './launch-tile';
6 |
7 | const LaunchDetail = ({ id, site, rocket }) => (
8 |
13 |
14 | {rocket.name} ({rocket.type})
15 |
16 | {site}
17 |
18 | );
19 |
20 | /**
21 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
22 | */
23 |
24 | const Card = styled('div')(cardClassName, {
25 | height: 365,
26 | marginBottom: unit * 4,
27 | });
28 |
29 | export default LaunchDetail;
30 |
--------------------------------------------------------------------------------
/final/client/src/components/launch-tile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { css } from 'react-emotion';
3 | import { Link } from '@reach/router';
4 |
5 | import galaxy from '../assets/images/galaxy.jpg';
6 | import iss from '../assets/images/iss.jpg';
7 | import moon from '../assets/images/moon.jpg';
8 | import { unit } from '../styles';
9 |
10 | const backgrounds = [galaxy, iss, moon];
11 | export function getBackgroundImage(id) {
12 | return `url(${backgrounds[Number(id) % backgrounds.length]})`;
13 | }
14 |
15 | export default ({ launch }) => {
16 | const { id, mission, rocket } = launch;
17 | return (
18 |
24 | {mission.name}
25 | {rocket.name}
26 |
27 | );
28 | };
29 |
30 | /**
31 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
32 | */
33 |
34 | export const cardClassName = css({
35 | padding: `${unit * 4}px ${unit * 5}px`,
36 | borderRadius: 7,
37 | color: 'white',
38 | backgroundSize: 'cover',
39 | backgroundPosition: 'center',
40 | });
41 |
42 | const padding = unit * 2;
43 | const StyledLink = styled(Link)(cardClassName, {
44 | display: 'block',
45 | height: 193,
46 | marginTop: padding,
47 | textDecoration: 'none',
48 | ':not(:last-child)': {
49 | marginBottom: padding * 2,
50 | },
51 | });
52 |
--------------------------------------------------------------------------------
/final/client/src/components/loading.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'react-emotion';
2 | import { size } from 'polished';
3 |
4 | import { ReactComponent as Logo } from '../assets/logo.svg';
5 | import { colors } from '../styles';
6 |
7 | const spin = keyframes`
8 | to {
9 | transform: rotate(360deg);
10 | }
11 | `;
12 |
13 | const Loading = styled(Logo)(size(64), {
14 | display: 'block',
15 | margin: 'auto',
16 | fill: colors.grey,
17 | path: {
18 | transformOrigin: 'center',
19 | animation: `${spin} 1s linear infinite`,
20 | },
21 | });
22 |
23 | export default Loading;
24 |
--------------------------------------------------------------------------------
/final/client/src/components/login-form.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled, { css } from 'react-emotion';
3 | import { size } from 'polished';
4 |
5 | import Button from './button';
6 | import space from '../assets/images/space.jpg';
7 | import { ReactComponent as Logo } from '../assets/logo.svg';
8 | import { ReactComponent as Curve } from '../assets/curve.svg';
9 | import { ReactComponent as Rocket } from '../assets/rocket.svg';
10 | import { colors, unit } from '../styles';
11 |
12 | export default class LoginForm extends Component {
13 | state = { email: '' };
14 |
15 | onChange = event => {
16 | const email = event.target.value;
17 | this.setState(s => ({ email }));
18 | };
19 |
20 | onSubmit = event => {
21 | event.preventDefault();
22 | this.props.login({ variables: { email: this.state.email } });
23 | };
24 |
25 | render() {
26 | return (
27 |
28 |
32 |
33 | Space Explorer
34 |
35 |
43 |
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | /**
51 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
52 | */
53 |
54 | const Container = styled('div')({
55 | display: 'flex',
56 | flexDirection: 'column',
57 | alignItems: 'center',
58 | flexGrow: 1,
59 | paddingBottom: unit * 6,
60 | color: 'white',
61 | backgroundColor: colors.primary,
62 | backgroundImage: `url(${space})`,
63 | backgroundSize: 'cover',
64 | backgroundPosition: 'center',
65 | });
66 |
67 | const svgClassName = css({
68 | display: 'block',
69 | fill: 'currentColor',
70 | });
71 |
72 | const Header = styled('header')(svgClassName, {
73 | width: '100%',
74 | marginBottom: unit * 5,
75 | padding: unit * 2.5,
76 | position: 'relative',
77 | });
78 |
79 | const StyledLogo = styled(Logo)(size(56), {
80 | display: 'block',
81 | margin: '0 auto',
82 | position: 'relative',
83 | });
84 |
85 | const StyledCurve = styled(Curve)(size('100%'), {
86 | fill: colors.primary,
87 | position: 'absolute',
88 | top: 0,
89 | left: 0,
90 | });
91 |
92 | const Heading = styled('h1')({
93 | margin: `${unit * 3}px 0 ${unit * 6}px`,
94 | });
95 |
96 | const StyledRocket = styled(Rocket)(svgClassName, {
97 | width: 250,
98 | });
99 |
100 | const StyledForm = styled('form')({
101 | width: '100%',
102 | maxWidth: 406,
103 | padding: unit * 3.5,
104 | borderRadius: 3,
105 | boxShadow: '6px 6px 1px rgba(0, 0, 0, 0.25)',
106 | color: colors.text,
107 | backgroundColor: 'white',
108 | });
109 |
110 | const StyledInput = styled('input')({
111 | width: '100%',
112 | marginBottom: unit * 2,
113 | padding: `${unit * 1.25}px ${unit * 2.5}px`,
114 | border: `1px solid ${colors.grey}`,
115 | fontSize: 16,
116 | outline: 'none',
117 | ':focus': {
118 | borderColor: colors.primary,
119 | },
120 | });
121 |
--------------------------------------------------------------------------------
/final/client/src/components/menu-item.js:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'react-emotion';
2 | import { Link } from '@reach/router';
3 | import { colors, unit } from '../styles';
4 |
5 | export const menuItemClassName = css({
6 | flexGrow: 1,
7 | width: 0,
8 | fontFamily: 'inherit',
9 | fontSize: 20,
10 | color: 'inherit',
11 | letterSpacing: 1.5,
12 | textTransform: 'uppercase',
13 | textAlign: 'center',
14 | svg: {
15 | display: 'block',
16 | width: 60,
17 | margin: `0 auto ${unit}px`,
18 | fill: colors.secondary,
19 | },
20 | });
21 |
22 | const MenuItem = styled(Link)(menuItemClassName, {
23 | textDecoration: 'none',
24 | });
25 |
26 | export default MenuItem;
27 |
--------------------------------------------------------------------------------
/final/client/src/components/page-container.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import { unit, colors } from '../styles';
5 |
6 | export default function PageContainer(props) {
7 | return (
8 |
9 |
10 | {props.children}
11 |
12 | );
13 | }
14 |
15 | /**
16 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
17 | */
18 |
19 | const Bar = styled('div')({
20 | flexShrink: 0,
21 | height: 12,
22 | backgroundColor: colors.primary,
23 | });
24 |
25 | const Container = styled('div')({
26 | display: 'flex',
27 | flexDirection: 'column',
28 | flexGrow: 1,
29 | width: '100%',
30 | maxWidth: 600,
31 | margin: '0 auto',
32 | padding: unit * 3,
33 | paddingBottom: unit * 5,
34 | });
35 |
--------------------------------------------------------------------------------
/final/client/src/containers/__tests__/action-button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { InMemoryCache } from 'apollo-cache-inmemory';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import ActionButton, {
13 | GET_LAUNCH_DETAILS,
14 | CANCEL_TRIP,
15 | TOGGLE_CART_MUTATION,
16 | } from '../action-button';
17 | import { GET_CART_ITEMS } from '../../pages/cart';
18 |
19 | describe('action button', () => {
20 | // automatically unmount and cleanup DOM after the test is finished.
21 | afterEach(cleanup);
22 |
23 | it('renders without error', () => {
24 | const { getByTestId } = renderApollo();
25 | expect(getByTestId('action-button')).toBeTruthy();
26 | });
27 |
28 | it('shows correct label', () => {
29 | const { getByText, container } = renderApollo();
30 | getByText(/add to cart/i);
31 |
32 | // rerender with different props to same container
33 | renderApollo(, { container });
34 | getByText(/remove from cart/i);
35 |
36 | // rerender with different props to same container
37 | renderApollo(, { container });
38 | getByText(/cancel this trip/i);
39 | });
40 |
41 | /**
42 | * This test is a bit tricky, since the button doesn't _render_
43 | * anything based on the response from the mutation.
44 | *
45 | * We test this by only mocking one mutation at a time. If the component
46 | * tried to execute any mutation not mocked, it would throw an
47 | * error
48 | */
49 | xit('fires correct mutation with variables', async () => {
50 | // const cache = new InMemoryCache();
51 | // cache.writeQuery({
52 | // query: GET_CART_ITEMS,
53 | // data: { cartItems: [1] },
54 | // });
55 |
56 | // if we only provide 1 mock, any other queries would cause error
57 | let mocks = [
58 | {
59 | request: { query: TOGGLE_CART_MUTATION, variables: { launchId: 1 } },
60 | result: { data: { addOrRemoveFromCart: true } },
61 | },
62 | ];
63 |
64 | const { getByTestId, container, debug } = renderApollo(
65 | ,
66 | {
67 | mocks,
68 | // cache
69 | },
70 | );
71 | fireEvent.click(getByTestId('action-button'));
72 | await waitForElement(() => getByTestId('action-button'));
73 |
74 | // mocks = [
75 | // {
76 | // request: {
77 | // query: CANCEL_TRIP,
78 | // variables: { launchId: 1 },
79 | // },
80 | // result: {
81 | // data: {
82 | // cancelTrip: {
83 | // success: true,
84 | // message: '',
85 | // launches: [{ id: 1, isBooked: false }],
86 | // },
87 | // },
88 | // },
89 | // },
90 | // ];
91 |
92 | // renderApollo(, { mocks, container });
93 | // fireEvent.click(getByTestId('action-button'));
94 | // await waitForElement(() => getByTestId('action-button'));
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/final/client/src/containers/__tests__/book-trips.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | renderApollo,
5 | cleanup,
6 | getByTestId,
7 | fireEvent,
8 | waitForElement,
9 | render,
10 | } from '../../test-utils';
11 | import BookTrips, { BOOK_TRIPS, GET_LAUNCH } from '../book-trips';
12 | import { GET_CART_ITEMS } from '../../pages/cart';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | id: 1,
20 | name: 'tester',
21 | },
22 | mission: {
23 | name: 'test mission',
24 | missionPatch: '/',
25 | },
26 | };
27 |
28 | describe('book trips', () => {
29 | // automatically unmount and cleanup DOM after the test is finished.
30 | afterEach(cleanup);
31 |
32 | it('renders without error', () => {
33 | const { getByTestId } = renderApollo();
34 | expect(getByTestId('book-button')).toBeTruthy();
35 | });
36 |
37 | it('completes mutation and shows message', async () => {
38 | let mocks = [
39 | {
40 | request: { query: BOOK_TRIPS, variables: { launchIds: [1] } },
41 | result: {
42 | data: {
43 | bookTrips: [{ success: true, message: 'success!', launches: [] }],
44 | },
45 | },
46 | },
47 | {
48 | // we need this query for refetchQueries
49 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
50 | result: { data: { launch: mockLaunch } },
51 | },
52 | ];
53 | const { getByText, container, getByTestId } = renderApollo(
54 | ,
55 | { mocks, addTypename: false },
56 | );
57 |
58 | fireEvent.click(getByTestId('book-button'));
59 |
60 | // Let's wait until our mocked mutation resolves and
61 | // the component re-renders.
62 | // getByTestId throws an error if it cannot find an element with the given ID
63 | // and waitForElement will wait until the callback doesn't throw an error
64 | const successText = await waitForElement(() => getByTestId('message'));
65 | });
66 |
67 | // >>>> TODO
68 | it('correctly updates cache', () => {});
69 | });
70 |
--------------------------------------------------------------------------------
/final/client/src/containers/__tests__/cart-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | renderApollo,
5 | cleanup,
6 | getByTestId,
7 | fireEvent,
8 | waitForElement,
9 | render,
10 | } from '../../test-utils';
11 | import CartItem, { GET_LAUNCH } from '../cart-item';
12 |
13 | const mockLaunch = {
14 | __typename: 'Launch',
15 | id: 1,
16 | isBooked: true,
17 | rocket: {
18 | id: 1,
19 | name: 'tester',
20 | },
21 | mission: {
22 | name: 'test mission',
23 | missionPatch: '/',
24 | },
25 | };
26 |
27 | xdescribe('cart item', () => {
28 | // automatically unmount and cleanup DOM after the test is finished.
29 | afterEach(cleanup);
30 |
31 | it('queries item and renders without error', () => {
32 | let mocks = [
33 | {
34 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
35 | result: { data: { launch: mockLaunch } },
36 | },
37 | ];
38 |
39 | // since we know the name of the mission, and know that name
40 | // will be rendered at some point, we can use getByText
41 | const { getByText, debug } = renderApollo(, {
42 | mocks,
43 | addTypename: false,
44 | });
45 |
46 | // check the loading state
47 | getByText(/loading/i);
48 |
49 | return waitForElement(() => getByText(/test mission/i));
50 | });
51 |
52 | it('renders with error state', () => {
53 | let mocks = [
54 | {
55 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
56 | error: new Error('aw shucks'),
57 | },
58 | ];
59 |
60 | // since we know the error message, we can use getByText
61 | // to recognize the error
62 | const { getByText, debug } = renderApollo(, {
63 | mocks,
64 | addTypename: false,
65 | });
66 |
67 | waitForElement(() => getByText(/error: aw shucks/i));
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/final/client/src/containers/__tests__/logout-button.js:
--------------------------------------------------------------------------------
1 | // TODO
2 | it('', () => {});
3 |
--------------------------------------------------------------------------------
/final/client/src/containers/action-button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useMutation } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { GET_LAUNCH_DETAILS } from '../pages/launch';
6 | import Button from '../components/button';
7 |
8 | // export all queries used in this file for testing
9 | export { GET_LAUNCH_DETAILS };
10 |
11 | export const TOGGLE_CART = gql`
12 | mutation addOrRemoveFromCart($launchId: ID!) {
13 | addOrRemoveFromCart(id: $launchId) @client
14 | }
15 | `;
16 |
17 | export const CANCEL_TRIP = gql`
18 | mutation cancel($launchId: ID!) {
19 | cancelTrip(launchId: $launchId) {
20 | success
21 | message
22 | launches {
23 | id
24 | isBooked
25 | }
26 | }
27 | }
28 | `;
29 |
30 | export default function ActionButton({ isBooked, id, isInCart }) {
31 | const [mutate, { loading, error }] = useMutation(
32 | isBooked ? CANCEL_TRIP : TOGGLE_CART,
33 | {
34 | variables: { launchId: id },
35 | refetchQueries: [
36 | {
37 | query: GET_LAUNCH_DETAILS,
38 | variables: { launchId: id },
39 | },
40 | ]
41 | }
42 | );
43 |
44 | if (loading) return Loading...
;
45 | if (error) return An error occurred
;
46 |
47 | return (
48 |
49 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/final/client/src/containers/book-trips.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useMutation } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import Button from '../components/button';
6 | import { GET_LAUNCH } from './cart-item';
7 |
8 | export { GET_LAUNCH };
9 | export const BOOK_TRIPS = gql`
10 | mutation BookTrips($launchIds: [ID]!) {
11 | bookTrips(launchIds: $launchIds) {
12 | success
13 | message
14 | launches {
15 | id
16 | isBooked
17 | }
18 | }
19 | }
20 | `;
21 |
22 | export default function BookTrips({ cartItems }) {
23 | const [bookTrips, { data }] = useMutation(
24 | BOOK_TRIPS,
25 | {
26 | variables: { launchIds: cartItems },
27 | refetchQueries: cartItems.map(launchId => ({
28 | query: GET_LAUNCH,
29 | variables: { launchId },
30 | })),
31 | update(cache) {
32 | cache.writeData({ data: { cartItems: [] } });
33 | }
34 | }
35 | );
36 | console.log('bookTrips => ', bookTrips)
37 | console.log('data => ', bookTrips)
38 | return data && data.bookTrips && !data.bookTrips.success
39 | ? {data.bookTrips.message}
40 | : (
41 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/final/client/src/containers/cart-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import LaunchTile from '../components/launch-tile';
6 | import { LAUNCH_TILE_DATA } from '../pages/launches';
7 |
8 | export const GET_LAUNCH = gql`
9 | query GetLaunch($launchId: ID!) {
10 | launch(id: $launchId) {
11 | ...LaunchTile
12 | }
13 | }
14 | ${LAUNCH_TILE_DATA}
15 | `;
16 |
17 | export default function CartItem({ launchId }) {
18 | const { data, loading, error } = useQuery(
19 | GET_LAUNCH,
20 | { variables: { launchId } }
21 | );
22 | if (loading) return Loading...
;
23 | if (error) return ERROR: {error.message}
;
24 | return data && ;
25 | }
26 |
--------------------------------------------------------------------------------
/final/client/src/containers/index.js:
--------------------------------------------------------------------------------
1 | export { default as ActionButton } from './action-button';
2 | export { default as BookTrips } from './book-trips';
3 | export { default as CartItem } from './cart-item';
4 | export { default as LogoutButton } from './logout-button';
5 |
--------------------------------------------------------------------------------
/final/client/src/containers/logout-button.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "react-emotion";
3 | import { useApolloClient } from "@apollo/react-hooks";
4 |
5 | import { menuItemClassName } from "../components/menu-item";
6 | import { ReactComponent as ExitIcon } from "../assets/icons/exit.svg";
7 |
8 | export default function LogoutButton() {
9 | const client = useApolloClient();
10 | return (
11 | {
13 | client.writeData({ data: { isLoggedIn: false } });
14 | localStorage.clear();
15 | }}
16 | >
17 |
18 | Logout
19 |
20 | );
21 | }
22 |
23 | const StyledButton = styled("button")(menuItemClassName, {
24 | background: "none",
25 | border: "none",
26 | padding: 0
27 | });
28 |
--------------------------------------------------------------------------------
/final/client/src/index.js:
--------------------------------------------------------------------------------
1 | import { ApolloClient } from "apollo-client";
2 | import { InMemoryCache } from "apollo-cache-inmemory";
3 | import { HttpLink } from "apollo-link-http";
4 | import { ApolloProvider, useQuery } from '@apollo/react-hooks';import React from "react";
5 | import ReactDOM from "react-dom";
6 | import Pages from "./pages";
7 | import gql from "graphql-tag";
8 | import { resolvers, typeDefs } from "./resolvers";
9 | import Login from "./pages/login";
10 | import injectStyles from "./styles";
11 |
12 | const cache = new InMemoryCache();
13 | const link = new HttpLink({
14 | uri: "http://localhost:4000/graphql",
15 | headers: {
16 | authorization: localStorage.getItem("token"),
17 | 'client-name': 'Space Explorer [web]',
18 | 'client-version': '1.0.0',
19 | }
20 | });
21 | const client = new ApolloClient({
22 | cache,
23 | link,
24 | typeDefs,
25 | resolvers
26 | });
27 | cache.writeData({
28 | data: {
29 | isLoggedIn: !!localStorage.getItem("token"),
30 | cartItems: []
31 | }
32 | });
33 | const IS_LOGGED_IN = gql`
34 | query IsUserLoggedIn {
35 | isLoggedIn @client
36 | }
37 | `;
38 |
39 | function IsLoggedIn() {
40 | const { data } = useQuery(IS_LOGGED_IN); return data.isLoggedIn ? : ;
41 | }
42 |
43 | injectStyles();
44 | // 测试后端服务是否正常
45 | // client.query({
46 | // query: gql`
47 | // query GetLaunch {
48 | // launch(id: 56) {
49 | // id
50 | // mission {
51 | // name
52 | // }
53 | // }
54 | // }
55 | // `
56 | // })
57 | // .then(result => console.log(result));
58 |
59 | ReactDOM.render(
60 |
61 |
62 | ,
63 | document.getElementById("root")
64 | );
65 |
--------------------------------------------------------------------------------
/final/client/src/pages/__tests__/cart.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { InMemoryCache } from 'apollo-cache-inmemory';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Cart, { GET_CART_ITEMS } from '../cart';
13 |
14 | xdescribe('Cart Page', () => {
15 | // automatically unmount and cleanup DOM after the test is finished.
16 | afterEach(cleanup);
17 |
18 | it('renders with message for empty carts', () => {
19 | // TODO: why is this necessary
20 | const cache = new InMemoryCache();
21 | cache.writeQuery({
22 | query: GET_CART_ITEMS,
23 | data: { cartItems: [] },
24 | });
25 |
26 | let mocks = [
27 | {
28 | request: { query: GET_CART_ITEMS },
29 | result: { data: { cartItems: [] } },
30 | },
31 | ];
32 | const { getByTestId } = renderApollo(, { mocks, cache });
33 | return waitForElement(() => getByTestId('empty-message'));
34 | });
35 |
36 | it('renders cart', () => {
37 | // TODO: why is this necessary
38 | const cache = new InMemoryCache();
39 | cache.writeQuery({
40 | query: GET_CART_ITEMS,
41 | data: { cartItems: [1] },
42 | });
43 |
44 | let mocks = [
45 | {
46 | request: { query: GET_CART_ITEMS },
47 | result: { data: { cartItems: [1] } },
48 | },
49 | ];
50 | const { getByTestId } = renderApollo(, { mocks, cache: undefined });
51 | return waitForElement(() => getByTestId('empty-message'));
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/final/client/src/pages/__tests__/launch.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Launch, { GET_LAUNCH_DETAILS } from '../launch';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | __typename: 'Rocket',
20 | id: 1,
21 | name: 'tester',
22 | type: 'test',
23 | },
24 | mission: {
25 | __typename: 'Mission',
26 | id: 1,
27 | name: 'test mission',
28 | missionPatch: '/',
29 | },
30 | site: 'earth',
31 | isInCart: false,
32 | };
33 |
34 | // TODO: un-skip after local state fixes
35 | xdescribe('Launch Page', () => {
36 | // automatically unmount and cleanup DOM after the test is finished.
37 | afterEach(cleanup);
38 |
39 | it('renders launch', async () => {
40 | const mocks = [
41 | {
42 | request: { query: GET_LAUNCH_DETAILS, variables: { launchId: 1 } },
43 | result: { data: { launch: mockLaunch } },
44 | },
45 | ];
46 | const { getByText } = await renderApollo(, {
47 | mocks,
48 | resolvers: {}
49 | });
50 | await waitForElement(() => getByText(/test mission/i));
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/final/client/src/pages/__tests__/launches.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Launches, { GET_LAUNCHES } from '../launches';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | __typename: 'Rocket',
20 | id: 1,
21 | name: 'tester',
22 | type: 'test',
23 | },
24 | mission: {
25 | __typename: 'Mission',
26 | id: 1,
27 | name: 'test mission',
28 | missionPatch: '/',
29 | },
30 | site: 'earth',
31 | isInCart: false,
32 | };
33 |
34 | // TODO: un-skip after local state fixes
35 | xdescribe('Launches Page', () => {
36 | // automatically unmount and cleanup DOM after the test is finished.
37 | afterEach(cleanup);
38 |
39 | it('renders launches', async () => {
40 | const mocks = [
41 | {
42 | request: { query: GET_LAUNCHES },
43 | result: {
44 | data: {
45 | isLoggedIn: true,
46 | launches: { cursor: '123', hasMore: true, launches: [mockLaunch] },
47 | },
48 | },
49 | },
50 | ];
51 | const { getByText } = await renderApollo(, {
52 | mocks,
53 | });
54 | await waitForElement(() => getByText(/test mission/i));
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/final/client/src/pages/__tests__/login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 | import { InMemoryCache } from 'apollo-cache-inmemory';
4 | import gql from 'graphql-tag';
5 |
6 | import {
7 | renderApollo,
8 | cleanup,
9 | getByTestId,
10 | fireEvent,
11 | waitForElement,
12 | render,
13 | } from '../../test-utils';
14 | import Login, { LOGIN_USER } from '../login';
15 |
16 | describe('Login Page', () => {
17 | // automatically unmount and cleanup DOM after the test is finished.
18 | afterEach(cleanup);
19 |
20 | it('renders login page', async () => {
21 | renderApollo();
22 | });
23 |
24 | it('fires login mutation and updates cache after done', async () => {
25 | const cache = new InMemoryCache();
26 | const mocks = [
27 | {
28 | request: { query: LOGIN_USER, variables: { email: 'a@a.a' } },
29 | result: { data: { login: 'abc' } },
30 | },
31 | ];
32 |
33 | const { getByText, getByTestId } = await renderApollo(, {
34 | mocks,
35 | cache,
36 | });
37 |
38 | fireEvent.change(getByTestId('login-input'), {
39 | target: { value: 'a@a.a' },
40 | });
41 |
42 | fireEvent.click(getByText(/log in/i));
43 |
44 | // login is done if loader is gone
45 | await waitForElement(() => getByText(/log in/i));
46 |
47 | // check to make sure the cache's contents have been updated
48 | const { isLoggedIn } = cache.readQuery({
49 | query: gql`
50 | {
51 | isLoggedIn @client
52 | }
53 | `,
54 | });
55 |
56 | expect(isLoggedIn).toBeTruthy();
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/final/client/src/pages/__tests__/profile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 | import { InMemoryCache } from 'apollo-cache-inmemory';
4 | import gql from 'graphql-tag';
5 |
6 | import {
7 | renderApollo,
8 | cleanup,
9 | getByTestId,
10 | fireEvent,
11 | waitForElement,
12 | render,
13 | } from '../../test-utils';
14 | import Profile, { GET_MY_TRIPS } from '../profile';
15 |
16 | const mockLaunch = {
17 | __typename: 'Launch',
18 | id: 1,
19 | isBooked: true,
20 | rocket: {
21 | __typename: 'Rocket',
22 | id: 1,
23 | name: 'tester',
24 | },
25 | mission: {
26 | __typename: 'Mission',
27 | id: 1,
28 | name: 'test mission',
29 | missionPatch: '/',
30 | },
31 | };
32 |
33 | const mockMe = {
34 | __typename: 'User',
35 | id: 1,
36 | email: 'a@a.a',
37 | trips: [mockLaunch],
38 | };
39 |
40 | xdescribe('Profile Page', () => {
41 | // automatically unmount and cleanup DOM after the test is finished.
42 | afterEach(cleanup);
43 |
44 | it('renders profile page', async () => {
45 | const mocks = [
46 | {
47 | request: { query: GET_MY_TRIPS },
48 | result: { data: { me: mockMe } },
49 | },
50 | ];
51 |
52 | const { getByText } = renderApollo(, { mocks, resolvers: {} });
53 |
54 | // if the profile renders, it will have the list of missions booked
55 | await waitForElement(() => getByText(/test mission/i));
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/final/client/src/pages/cart.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { Header, Loading } from '../components';
6 | import { CartItem, BookTrips } from '../containers';
7 |
8 | export const GET_CART_ITEMS = gql`
9 | query GetCartItems {
10 | cartItems @client
11 | }
12 | `;
13 |
14 | export default function Cart() {
15 | const { data, loading, error } = useQuery(GET_CART_ITEMS);
16 | if (loading) return ;
17 | if (error) return ERROR: {error.message}
;
18 | return (
19 |
20 |
21 | {!data.cartItems || !data.cartItems.length ? (
22 | No items in your cart
23 | ) : (
24 |
25 | {data.cartItems.map(launchId => (
26 |
27 | ))}
28 |
29 |
30 | )}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/final/client/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { Router } from '@reach/router';
3 |
4 | import Launch from './launch';
5 | import Launches from './launches';
6 | import Cart from './cart';
7 | import Profile from './profile';
8 | import { Footer, PageContainer } from '../components';
9 |
10 | export default function Pages() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/final/client/src/pages/launch.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { LAUNCH_TILE_DATA } from './launches';
6 | import { Loading, Header, LaunchDetail } from '../components';
7 | import { ActionButton } from '../containers';
8 |
9 | export const GET_LAUNCH_DETAILS = gql`
10 | query LaunchDetails($launchId: ID!) {
11 | launch(id: $launchId) {
12 | isInCart @client
13 | site
14 | rocket {
15 | type
16 | }
17 | ...LaunchTile
18 | }
19 | }
20 | ${LAUNCH_TILE_DATA}
21 | `;
22 |
23 | export default function Launch({ launchId }) {
24 | const { data, loading, error } = useQuery(
25 | GET_LAUNCH_DETAILS,
26 | { variables: { launchId } },
27 | );
28 |
29 | if (loading) return ;
30 | if (error) return ERROR: {error.message}
;
31 |
32 | return (
33 |
34 |
35 | {data.launch.mission.name}
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/final/client/src/pages/launches.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { LaunchTile, Header, Button, Loading } from '../components';
6 |
7 | export const LAUNCH_TILE_DATA = gql`
8 | fragment LaunchTile on Launch {
9 | __typename
10 | id
11 | isBooked
12 | rocket {
13 | id
14 | name
15 | }
16 | mission {
17 | name
18 | missionPatch
19 | }
20 | }
21 | `;
22 |
23 | export const GET_LAUNCHES = gql`
24 | query GetLaunchList($after: String) {
25 | launches(after: $after) {
26 | cursor
27 | hasMore
28 | launches {
29 | ...LaunchTile
30 | }
31 | }
32 | }
33 | ${LAUNCH_TILE_DATA}
34 | `;
35 |
36 | export default function Launches() {
37 | const { data, loading, error, fetchMore } = useQuery(GET_LAUNCHES);
38 | if (loading) return ;
39 | if (error) return ERROR
;
40 |
41 | return (
42 |
43 |
44 | {data.launches &&
45 | data.launches.launches &&
46 | data.launches.launches.map(launch => (
47 |
48 | ))}
49 | {data.launches &&
50 | data.launches.hasMore && (
51 |
75 | )}
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/final/client/src/pages/login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useApolloClient, useMutation } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { LoginForm, Loading } from '../components';
6 |
7 | export const LOGIN_USER = gql`
8 | mutation login($email: String!) {
9 | login(email: $email) {
10 | success,
11 | message,
12 | token,
13 | }
14 | }
15 | `;
16 |
17 | export default function Login() {
18 | const client = useApolloClient();
19 | const [login, { loading, error }] = useMutation(
20 | LOGIN_USER,
21 | {
22 | onCompleted({ login }) {
23 | const { success, message } = login
24 | if (success) {
25 | localStorage.setItem('token', login.token);
26 | client.writeData({ data: { isLoggedIn: true } });
27 | } else {
28 | alert(message)
29 | }
30 | }
31 | }
32 | );
33 |
34 | if (loading) return ;
35 | if (error) return An error occurred
;
36 |
37 | return ;
38 | }
39 |
--------------------------------------------------------------------------------
/final/client/src/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { useQuery } from '@apollo/react-hooks';
3 | import gql from 'graphql-tag';
4 |
5 | import { Loading, Header, LaunchTile } from '../components';
6 | import { LAUNCH_TILE_DATA } from './launches';
7 |
8 | export const GET_MY_TRIPS = gql`
9 | query GetMyTrips {
10 | me {
11 | id
12 | email
13 | trips {
14 | ...LaunchTile
15 | }
16 | }
17 | }
18 | ${LAUNCH_TILE_DATA}
19 | `;
20 |
21 | export default function Profile() {
22 | const { data, loading, error } = useQuery(
23 | GET_MY_TRIPS,
24 | { fetchPolicy: "network-only" }
25 | );
26 | if (loading) return ;
27 | if (error) return ERROR: {error.message}
;
28 |
29 | return (
30 |
31 |
32 | {data.me && data.me.trips.length ? (
33 | data.me.trips.map(launch => (
34 |
35 | ))
36 | ) : (
37 | You haven't booked any trips
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/final/client/src/resolvers.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import { GET_CART_ITEMS } from './pages/cart';
3 |
4 | export const typeDefs = gql`
5 | extend type Query {
6 | isLoggedIn: Boolean!
7 | cartItems: [ID!]!
8 | }
9 |
10 | extend type Launch {
11 | isInCart: Boolean!
12 | }
13 |
14 | extend type Mutation {
15 | addOrRemoveFromCart(id: ID!): [Launch]
16 | }
17 | `;
18 |
19 | export const resolvers = {
20 | Launch: {
21 | isInCart: (launch, _, { cache }) => {
22 | const { cartItems } = cache.readQuery({ query: GET_CART_ITEMS });
23 | return cartItems.includes(launch.id);
24 | },
25 | },
26 | Mutation: {
27 | addOrRemoveFromCart: (_, { id }, { cache }) => {
28 | const { cartItems } = cache.readQuery({ query: GET_CART_ITEMS });
29 | const data = {
30 | cartItems: cartItems.includes(id)
31 | ? cartItems.filter(i => i !== id)
32 | : [...cartItems, id],
33 | };
34 | cache.writeQuery({ query: GET_CART_ITEMS, data });
35 | return data.cartItems;
36 | },
37 | },
38 | };
39 |
40 |
--------------------------------------------------------------------------------
/final/client/src/styles.js:
--------------------------------------------------------------------------------
1 | import { injectGlobal } from 'react-emotion';
2 |
3 | export const unit = 8;
4 | export const colors = {
5 | primary: '#220a82',
6 | secondary: '#14cbc4',
7 | accent: '#e535ab',
8 | background: '#f7f8fa',
9 | grey: '#d8d9e0',
10 | text: '#343c5a',
11 | textSecondary: '#747790'
12 | };
13 |
14 | export default () => injectGlobal({
15 | [['html', 'body']]: {
16 | height: '100%',
17 | },
18 | body: {
19 | margin: 0,
20 | padding: 0,
21 | fontFamily: "'Source Sans Pro', sans-serif",
22 | backgroundColor: colors.background,
23 | color: colors.text,
24 | },
25 | '#root': {
26 | display: 'flex',
27 | flexDirection: 'column',
28 | minHeight: '100%',
29 | },
30 | '*': {
31 | boxSizing: 'border-box',
32 | },
33 | [['h1', 'h2', 'h3', 'h4', 'h5', 'h6']]: {
34 | margin: 0,
35 | fontWeight: 600,
36 | },
37 | h1: {
38 | fontSize: 48,
39 | lineHeight: 1,
40 | },
41 | h2: {
42 | fontSize: 40,
43 | },
44 | h3: {
45 | fontSize: 36,
46 | },
47 | h5: {
48 | fontSize: 16,
49 | textTransform: 'uppercase',
50 | letterSpacing: 4,
51 | }
52 | });
53 |
--------------------------------------------------------------------------------
/final/client/src/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | // this adds custom jest matchers from jest-dom
4 | import '@testing-library/jest-dom/extend-expect';
5 | import { MockedProvider } from '@apollo/react-testing';
6 |
7 | const renderApollo = (
8 | node,
9 | { mocks, addTypename, defaultOptions, cache, ...options } = {},
10 | ) => {
11 | return render(
12 |
18 | {node}
19 | ,
20 | options,
21 | );
22 | };
23 |
24 | export * from '@testing-library/react';
25 | export { renderApollo };
26 |
--------------------------------------------------------------------------------
/final/server/.env.example:
--------------------------------------------------------------------------------
1 | ENGINE_API_KEY=
--------------------------------------------------------------------------------
/final/server/apollo.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | service: {
3 | name: 'space-explorer',
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/final/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fullstack_tutorial_server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "test": "jest",
8 | "start": "nodemon src/index.js",
9 | "start:ci": "node src/index.js"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "apollo-datasource": "^0.1.3",
15 | "apollo-datasource-rest": "^0.1.5",
16 | "apollo-server": "2.6.1",
17 | "apollo-server-testing": "2.6.1",
18 | "graphql": "^14.2.1",
19 | "isemail": "^3.1.3",
20 | "nodemon": "^1.18.4",
21 | "sequelize": "^4.39.0",
22 | "sqlite3": "^4.0.3"
23 | },
24 | "devDependencies": {
25 | "apollo-link": "^1.2.3",
26 | "apollo-link-http": "^1.5.5",
27 | "config": "^3.2.4",
28 | "jest": "^23.6.0",
29 | "nock": "^10.0.2",
30 | "node-fetch": "^2.2.1"
31 | },
32 | "jest": {
33 | "testPathIgnorePatterns": [
34 | "/node_modules/",
35 | "/__utils"
36 | ]
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/__snapshots__/e2e.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Server - e2e gets a single launch 1`] = `
4 | Object {
5 | "data": Object {
6 | "launch": Object {
7 | "id": "30",
8 | "isBooked": false,
9 | "mission": Object {
10 | "name": "Thaicom 8",
11 | },
12 | "rocket": Object {
13 | "type": "FT",
14 | },
15 | },
16 | },
17 | }
18 | `;
19 |
20 | exports[`Server - e2e gets list of launches 1`] = `
21 | Object {
22 | "data": Object {
23 | "launches": Object {
24 | "cursor": "1517433900",
25 | "launches": Array [
26 | Object {
27 | "mission": Object {
28 | "missionPatch": "https://images2.imgbox.com/42/0a/LAupFe3L_o.png",
29 | "name": "SES-16 / GovSat-1",
30 | },
31 | },
32 | ],
33 | },
34 | },
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/__snapshots__/integration.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Mutations books trips 1`] = `
4 | Object {
5 | "data": Object {
6 | "bookTrips": Object {
7 | "launches": Array [
8 | Object {
9 | "id": "1",
10 | "isBooked": true,
11 | },
12 | Object {
13 | "id": "2",
14 | "isBooked": true,
15 | },
16 | ],
17 | "message": "trips booked successfully",
18 | "success": true,
19 | },
20 | },
21 | "errors": undefined,
22 | "extensions": undefined,
23 | "http": Object {
24 | "headers": Headers {
25 | Symbol(map): Object {},
26 | },
27 | },
28 | }
29 | `;
30 |
31 | exports[`Queries fetches list of launches 1`] = `
32 | Object {
33 | "data": Object {
34 | "launches": Object {
35 | "cursor": "1143239400",
36 | "hasMore": false,
37 | "launches": Array [
38 | Object {
39 | "id": "1",
40 | "isBooked": true,
41 | "mission": Object {
42 | "missionPatch": "https://images2.imgbox.com/40/e3/GypSkayF_o.png",
43 | "name": "FalconSat",
44 | },
45 | "rocket": Object {
46 | "name": "Falcon 1",
47 | },
48 | },
49 | ],
50 | },
51 | },
52 | "errors": undefined,
53 | "extensions": undefined,
54 | "http": Object {
55 | "headers": Headers {
56 | Symbol(map): Object {},
57 | },
58 | },
59 | }
60 | `;
61 |
62 | exports[`Queries fetches single launch 1`] = `
63 | Object {
64 | "data": Object {
65 | "launch": Object {
66 | "id": "1",
67 | "isBooked": true,
68 | "mission": Object {
69 | "name": "FalconSat",
70 | },
71 | "rocket": Object {
72 | "type": "Merlin A",
73 | },
74 | },
75 | },
76 | "errors": undefined,
77 | "extensions": undefined,
78 | "http": Object {
79 | "headers": Headers {
80 | Symbol(map): Object {},
81 | },
82 | },
83 | }
84 | `;
85 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/__utils.js:
--------------------------------------------------------------------------------
1 | const { HttpLink } = require('apollo-link-http');
2 | const fetch = require('node-fetch');
3 | const { execute, toPromise } = require('apollo-link');
4 |
5 | module.exports.toPromise = toPromise;
6 |
7 | const {
8 | dataSources,
9 | context: defaultContext,
10 | typeDefs,
11 | resolvers,
12 | ApolloServer,
13 | LaunchAPI,
14 | UserAPI,
15 | store,
16 | } = require('../');
17 |
18 | /**
19 | * Integration testing utils
20 | */
21 | const constructTestServer = ({ context = defaultContext } = {}) => {
22 | const userAPI = new UserAPI({ store });
23 | const launchAPI = new LaunchAPI();
24 |
25 | const server = new ApolloServer({
26 | typeDefs,
27 | resolvers,
28 | dataSources: () => ({ userAPI, launchAPI }),
29 | context,
30 | });
31 |
32 | return { server, userAPI, launchAPI };
33 | };
34 |
35 | module.exports.constructTestServer = constructTestServer;
36 |
37 | /**
38 | * e2e Testing Utils
39 | */
40 |
41 | const startTestServer = async server => {
42 | // if using apollo-server-express...
43 | // const app = express();
44 | // server.applyMiddleware({ app });
45 | // const httpServer = await app.listen(0);
46 |
47 | const httpServer = await server.listen({ port: 0 });
48 |
49 | const link = new HttpLink({
50 | uri: `http://localhost:${httpServer.port}`,
51 | fetch,
52 | });
53 |
54 | const executeOperation = ({ query, variables = {} }) =>
55 | execute(link, { query, variables });
56 |
57 | return {
58 | link,
59 | stop: () => httpServer.server.close(),
60 | graphql: executeOperation,
61 | };
62 | };
63 |
64 | module.exports.startTestServer = startTestServer;
65 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/e2e.js:
--------------------------------------------------------------------------------
1 | // import our production apollo-server instance
2 | const { server } = require('../');
3 | const gql = require('graphql-tag');
4 |
5 | const { startTestServer, toPromise } = require('./__utils');
6 |
7 | const LAUNCH_LIST_QUERY = gql`
8 | query myLaunches($pageSize: Int, $after: String) {
9 | launches(pageSize: $pageSize, after: $after) {
10 | cursor
11 | launches {
12 | mission {
13 | name
14 | missionPatch
15 | }
16 | }
17 | }
18 | }
19 | `;
20 |
21 | const GET_LAUNCH = gql`
22 | query launch($id: ID!) {
23 | launch(id: $id) {
24 | id
25 | isBooked
26 | rocket {
27 | type
28 | }
29 | mission {
30 | name
31 | }
32 | }
33 | }
34 | `;
35 |
36 | describe('Server - e2e', () => {
37 | let stop, graphql;
38 |
39 | beforeEach(async () => {
40 | const testServer = await startTestServer(server);
41 | stop = testServer.stop;
42 | graphql = testServer.graphql;
43 | });
44 |
45 | afterEach(() => stop());
46 |
47 | it('gets list of launches', async () => {
48 | const res = await toPromise(
49 | graphql({
50 | query: LAUNCH_LIST_QUERY,
51 | variables: { pageSize: 1, after: '1517949900' },
52 | }),
53 | );
54 |
55 | expect(res).toMatchSnapshot();
56 | });
57 |
58 | it('gets a single launch', async () => {
59 | const res = await toPromise(
60 | graphql({ query: GET_LAUNCH, variables: { id: 30 } }),
61 | );
62 |
63 | expect(res).toMatchSnapshot();
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/integration.js:
--------------------------------------------------------------------------------
1 | const { createTestClient } = require('apollo-server-testing');
2 | const gql = require('graphql-tag');
3 | const nock = require('nock');
4 |
5 | const { constructTestServer } = require('./__utils');
6 |
7 | // the mocked REST API data
8 | const { mockLaunchResponse } = require('../datasources/__tests__/launch');
9 | // the mocked SQL DataSource store
10 | const { mockStore } = require('../datasources/__tests__/user');
11 |
12 | const GET_LAUNCHES = gql`
13 | query launchList($after: String) {
14 | launches(after: $after) {
15 | cursor
16 | hasMore
17 | launches {
18 | id
19 | isBooked
20 | rocket {
21 | name
22 | }
23 | mission {
24 | name
25 | missionPatch
26 | }
27 | }
28 | }
29 | }
30 | `;
31 |
32 | const GET_LAUNCH = gql`
33 | query launch($id: ID!) {
34 | launch(id: $id) {
35 | id
36 | isBooked
37 | rocket {
38 | type
39 | }
40 | mission {
41 | name
42 | }
43 | }
44 | }
45 | `;
46 |
47 | const LOGIN = gql`
48 | mutation login($email: String!) {
49 | login(email: $email)
50 | }
51 | `;
52 |
53 | const BOOK_TRIPS = gql`
54 | mutation BookTrips($launchIds: [ID]!) {
55 | bookTrips(launchIds: $launchIds) {
56 | success
57 | message
58 | launches {
59 | id
60 | isBooked
61 | }
62 | }
63 | }
64 | `;
65 |
66 | describe('Queries', () => {
67 | it('fetches list of launches', async () => {
68 | // create an instance of ApolloServer that mocks out context, while reusing
69 | // existing dataSources, resolvers, and typeDefs.
70 | // This function returns the server instance as well as our dataSource
71 | // instances, so we can overwrite the underlying fetchers
72 | const { server, launchAPI, userAPI } = constructTestServer({
73 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
74 | });
75 |
76 | // mock the datasources' underlying fetch methods, whether that's a REST
77 | // lookup in the RESTDataSource or the store query in the Sequelize datasource
78 | launchAPI.get = jest.fn(() => [mockLaunchResponse]);
79 | userAPI.store = mockStore;
80 | userAPI.store.trips.findAll.mockReturnValueOnce([
81 | { dataValues: { launchId: 1 } },
82 | ]);
83 |
84 | // use our test server as input to the createTestClient fn
85 | // This will give us an interface, similar to apolloClient.query
86 | // to run queries against our instance of ApolloServer
87 | const { query } = createTestClient(server);
88 | const res = await query({ query: GET_LAUNCHES });
89 | expect(res).toMatchSnapshot();
90 | });
91 |
92 | it('fetches single launch', async () => {
93 | const { server, launchAPI, userAPI } = constructTestServer({
94 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
95 | });
96 |
97 | launchAPI.get = jest.fn(() => [mockLaunchResponse]);
98 | userAPI.store = mockStore;
99 | userAPI.store.trips.findAll.mockReturnValueOnce([
100 | { dataValues: { launchId: 1 } },
101 | ]);
102 |
103 | const { query } = createTestClient(server);
104 | const res = await query({ query: GET_LAUNCH, variables: { id: 1 } });
105 | expect(res).toMatchSnapshot();
106 | });
107 | });
108 |
109 | describe('Mutations', () => {
110 | it('returns login token', async () => {
111 | const { server, launchAPI, userAPI } = constructTestServer({
112 | context: () => {},
113 | });
114 |
115 | userAPI.store = mockStore;
116 | userAPI.store.users.findOrCreate.mockReturnValueOnce([
117 | { id: 1, email: 'a@a.a' },
118 | ]);
119 |
120 | const { mutate } = createTestClient(server);
121 | const res = await mutate({
122 | mutation: LOGIN,
123 | variables: { email: 'a@a.a' },
124 | });
125 | expect(res.data.login).toEqual('YUBhLmE=');
126 | });
127 |
128 | it('books trips', async () => {
129 | const { server, launchAPI, userAPI } = constructTestServer({
130 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
131 | });
132 |
133 | // mock the underlying fetches
134 | launchAPI.get = jest.fn();
135 |
136 | // look up the launches from the launch API
137 | launchAPI.get
138 | .mockReturnValueOnce([mockLaunchResponse])
139 | .mockReturnValueOnce([{ ...mockLaunchResponse, flight_number: 2 }]);
140 |
141 | // book the trip in the store
142 | userAPI.store = mockStore;
143 | userAPI.store.trips.findOrCreate
144 | .mockReturnValueOnce([{ get: () => ({ launchId: 1 }) }])
145 | .mockReturnValueOnce([{ get: () => ({ launchId: 2 }) }]);
146 |
147 | // check if user is booked
148 | userAPI.store.trips.findAll.mockReturnValue([{}]);
149 |
150 | const { mutate } = createTestClient(server);
151 | const res = await mutate({
152 | mutation: BOOK_TRIPS,
153 | variables: { launchIds: ['1', '2'] },
154 | });
155 | expect(res).toMatchSnapshot();
156 | });
157 | });
158 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/resolvers.mission.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | const mockMission = {
4 | name: 'foo',
5 | missionPatchLarge: 'LG',
6 | missionPatchSmall: 'SM',
7 | };
8 |
9 | describe('[Mission.missionPatch]', () => {
10 | it('chooses the right sized patch', () => {
11 | const { missionPatch } = resolvers.Mission;
12 |
13 | // default -- no arg passed
14 | const resDefault = missionPatch(mockMission);
15 | const resSmall = missionPatch(mockMission, { size: 'SMALL' });
16 | const resLarge = missionPatch(mockMission, { size: 'LARGE' });
17 |
18 | expect(resDefault).toEqual('LG');
19 | expect(resSmall).toEqual('SM');
20 | expect(resLarge).toEqual('LG');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/resolvers.mutation.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | const mockContext = {
4 | dataSources: {
5 | userAPI: {
6 | bookTrips: jest.fn(),
7 | cancelTrip: jest.fn(),
8 | findOrCreateUser: jest.fn(),
9 | },
10 | launchAPI: {
11 | getLaunchesByIds: jest.fn(),
12 | getLaunchById: jest.fn(),
13 | },
14 | },
15 | user: { id: 1, email: 'a@a.a' },
16 | };
17 |
18 | describe('[Mutation.bookTrips]', () => {
19 | const { bookTrips } = mockContext.dataSources.userAPI;
20 | const { getLaunchesByIds } = mockContext.dataSources.launchAPI;
21 |
22 | it('returns true if booking succeeds', async () => {
23 | bookTrips.mockReturnValueOnce([{ launchId: 999 }]);
24 | getLaunchesByIds.mockReturnValueOnce([{ id: 999, cursor: 'foo' }]);
25 |
26 | // check the resolver response
27 | const res = await resolvers.Mutation.bookTrips(
28 | null,
29 | { launchIds: [123] },
30 | mockContext,
31 | );
32 | expect(res).toEqual({
33 | launches: [{ cursor: 'foo', id: 999 }],
34 | message: 'trips booked successfully',
35 | success: true,
36 | });
37 |
38 | // check if the dataSource was called with correct args
39 | expect(bookTrips).toBeCalledWith({ launchIds: [123] });
40 | });
41 |
42 | it('returns false if booking fails', async () => {
43 | bookTrips.mockReturnValueOnce([]);
44 |
45 | // check the resolver response
46 | const res = await resolvers.Mutation.bookTrips(
47 | null,
48 | { launchIds: [123] },
49 | mockContext,
50 | );
51 |
52 | expect(res.message).toBeDefined();
53 | expect(res.success).toBeFalsy();
54 | });
55 | });
56 |
57 | describe('[Mutation.cancelTrip]', () => {
58 | const { cancelTrip } = mockContext.dataSources.userAPI;
59 | const { getLaunchById } = mockContext.dataSources.launchAPI;
60 |
61 | it('returns true if cancelling succeeds', async () => {
62 | cancelTrip.mockReturnValueOnce(true);
63 | getLaunchById.mockReturnValueOnce({ id: 999, cursor: 'foo' });
64 |
65 | // check the resolver response
66 | const res = await resolvers.Mutation.cancelTrip(
67 | null,
68 | { launchId: 123 },
69 | mockContext,
70 | );
71 | expect(res).toEqual({
72 | success: true,
73 | message: 'trip cancelled',
74 | launches: [{ id: 999, cursor: 'foo' }],
75 | });
76 |
77 | // check if the dataSource was called with correct args
78 | expect(cancelTrip).toBeCalledWith({ launchId: 123 });
79 | });
80 |
81 | it('returns false if cancelling fails', async () => {
82 | cancelTrip.mockReturnValueOnce(false);
83 |
84 | // check the resolver response
85 | const res = await resolvers.Mutation.cancelTrip(
86 | null,
87 | { launchId: 123 },
88 | mockContext,
89 | );
90 | expect(res.message).toBeDefined();
91 | expect(res.success).toBeFalsy();
92 | });
93 | });
94 |
95 | describe('[Mutation.login]', () => {
96 | const { findOrCreateUser } = mockContext.dataSources.userAPI;
97 |
98 | it('returns base64 encoded email if successful', async () => {
99 | const args = { email: 'a@a.a' };
100 | findOrCreateUser.mockReturnValueOnce(true);
101 | const base64Email = new Buffer(mockContext.user.email).toString('base64');
102 |
103 | // check the resolver response
104 | const res = await resolvers.Mutation.login(null, args, mockContext);
105 | expect(res).toEqual('YUBhLmE=');
106 |
107 | // check if the dataSource was called with correct args
108 | expect(findOrCreateUser).toBeCalledWith(args);
109 | });
110 |
111 | it('returns nothing if login fails', async () => {
112 | const args = { email: 'a@a.a' };
113 | // simulate failed lookup/creation
114 | findOrCreateUser.mockReturnValueOnce(false);
115 |
116 | // check the resolver response
117 | const res = await resolvers.Mutation.login(null, args, mockContext);
118 | expect(res).toBeFalsy();
119 | });
120 | });
121 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/resolvers.query.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | describe('[Query.launches]', () => {
4 | const mockContext = {
5 | dataSources: {
6 | launchAPI: { getAllLaunches: jest.fn() },
7 | },
8 | };
9 | // just for easy access
10 | const { getAllLaunches } = mockContext.dataSources.launchAPI;
11 |
12 | it('calls lookup from launch api', async () => {
13 | // NOTE: these results get reversed in the resolver
14 | getAllLaunches.mockReturnValueOnce([{ id: 999, cursor: 'foo' }]);
15 |
16 | // check the resolver response
17 | const res = await resolvers.Query.launches(null, {}, mockContext);
18 | expect(res).toEqual({
19 | cursor: 'foo',
20 | hasMore: false,
21 | launches: [{ id: 999, cursor: 'foo' }],
22 | });
23 | });
24 |
25 | it('respects pageSize arg', async () => {
26 | // NOTE: these results get reversed in the resolver
27 | getAllLaunches.mockReturnValue([
28 | { id: 1, cursor: 'foo' },
29 | { id: 999, cursor: 'bar' },
30 | ]);
31 |
32 | // check the resolver response
33 | const res = await resolvers.Query.launches(
34 | null,
35 | { pageSize: 1 },
36 | mockContext,
37 | );
38 | expect(res).toEqual({
39 | cursor: 'bar',
40 | hasMore: true,
41 | launches: [{ id: 999, cursor: 'bar' }],
42 | });
43 | });
44 |
45 | it('respects cursor arg', async () => {
46 | // NOTE: these results get reversed in the resolver
47 | getAllLaunches.mockReturnValueOnce([
48 | { id: 1, cursor: 'a' },
49 | { id: 999, cursor: 'b' },
50 | ]);
51 |
52 | // check the resolver response
53 | const res = await resolvers.Query.launches(
54 | null,
55 | { after: 'b' },
56 | mockContext,
57 | );
58 |
59 | expect(res).toEqual({
60 | hasMore: false,
61 | cursor: 'a',
62 | launches: [{ id: 1, cursor: 'a' }],
63 | });
64 | });
65 |
66 | it('respects both pageSize and cursor', async () => {
67 | // NOTE: these results get reversed in the resolver
68 | getAllLaunches.mockReturnValue([
69 | { id: 1, cursor: 'a' },
70 | { id: 999, cursor: 'b' },
71 | { id: 123, cursor: 'c' },
72 | ]);
73 |
74 | // check the resolver response
75 | const res = await resolvers.Query.launches(
76 | null,
77 | { after: 'c', pageSize: 1 },
78 | mockContext,
79 | );
80 |
81 | expect(res).toEqual({
82 | cursor: 'b',
83 | hasMore: true,
84 | launches: [{ id: 999, cursor: 'b' }],
85 | });
86 | });
87 | });
88 |
89 | describe('[Query.launch]', () => {
90 | const mockContext = {
91 | dataSources: {
92 | launchAPI: { getLaunchById: jest.fn() },
93 | },
94 | };
95 |
96 | it('calls lookup from launch api', async () => {
97 | const getLaunchById = mockContext.dataSources.launchAPI.getLaunchById;
98 | getLaunchById.mockReturnValueOnce({
99 | id: 999,
100 | });
101 |
102 | // check the resolver response
103 | const res = await resolvers.Query.launch(null, { id: 999 }, mockContext);
104 | expect(res).toEqual({ id: 999 });
105 |
106 | // make sure the dataSources were called properly
107 | expect(getLaunchById).toBeCalledWith({ launchId: 999 });
108 | });
109 | });
110 |
111 | describe('[Query.me]', () => {
112 | const mockContext = {
113 | dataSources: {
114 | userAPI: { findOrCreateUser: jest.fn() },
115 | },
116 | user: {},
117 | };
118 |
119 | it('returns null if no user in context', async () => {
120 | expect(await resolvers.Query.me(null, null, mockContext)).toBeFalsy();
121 | });
122 |
123 | it('returns user from userAPI', async () => {
124 | mockContext.user.email = 'a@a.a';
125 | const findOrCreateUser = mockContext.dataSources.userAPI.findOrCreateUser;
126 | findOrCreateUser.mockReturnValueOnce({ id: 999 });
127 |
128 | // check return value of resolver
129 | const res = await resolvers.Query.me(null, null, mockContext);
130 | expect(res).toEqual({
131 | id: 999,
132 | });
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/final/server/src/__tests__/resolvers.user.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | describe('[User.trips]', () => {
4 | const mockContext = {
5 | dataSources: {
6 | userAPI: { getLaunchIdsByUser: jest.fn() },
7 | launchAPI: { getLaunchesByIds: jest.fn() },
8 | },
9 | user: { id: 1 },
10 | };
11 | const { getLaunchIdsByUser } = mockContext.dataSources.userAPI;
12 | const { getLaunchesByIds } = mockContext.dataSources.launchAPI;
13 |
14 | it('uses user id from context to lookup trips', async () => {
15 | getLaunchIdsByUser.mockReturnValueOnce([999]);
16 | getLaunchesByIds.mockReturnValueOnce([{ id: 999 }]);
17 |
18 | // check the resolver response
19 | const res = await resolvers.User.trips(null, null, mockContext);
20 | expect(res).toEqual([{ id: 999 }]);
21 |
22 | // make sure the dataSources were called properly
23 | expect(getLaunchIdsByUser).toBeCalled();
24 | expect(getLaunchesByIds).toBeCalledWith({ launchIds: [999] });
25 | });
26 |
27 | it('returns empty array if no response', async () => {
28 | getLaunchIdsByUser.mockReturnValueOnce([]);
29 | getLaunchesByIds.mockReturnValueOnce([]);
30 |
31 | // check the resolver response
32 | const res = await resolvers.User.trips(null, null, mockContext);
33 | expect(res).toEqual([]);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/final/server/src/common/consts.js:
--------------------------------------------------------------------------------
1 | // import { get } from 'config';
2 |
3 |
4 | // export const DB_NAME = get('DB_NAME')
5 | // export const PORT = get('PORT')
6 | // export const HOST = get('HOST')
7 | // export const USERNAME = get('USERNAME')
8 | // export const PASSWORD = get('PASSWORD')
--------------------------------------------------------------------------------
/final/server/src/datasources/__tests__/user.js:
--------------------------------------------------------------------------------
1 | const UserAPI = require('../user');
2 |
3 | const mockStore = {
4 | users: {
5 | findOrCreate: jest.fn(),
6 | findAll: jest.fn(),
7 | },
8 | trips: {
9 | findOrCreate: jest.fn(),
10 | destroy: jest.fn(),
11 | findAll: jest.fn(),
12 | },
13 | };
14 | module.exports.mockStore = mockStore;
15 |
16 | const ds = new UserAPI({ store: mockStore });
17 | ds.initialize({ context: { user: { id: 1, email: 'a@a.a' } } });
18 |
19 | describe('[UserAPI.findOrCreateUser]', () => {
20 | it('returns null for invalid emails', async () => {
21 | const res = await ds.findOrCreateUser({ email: 'boo!' });
22 | expect(res).toEqual(null);
23 | });
24 |
25 | it('looks up/creates user in store', async () => {
26 | mockStore.users.findOrCreate.mockReturnValueOnce([{ id: 1 }]);
27 |
28 | // check the result of the fn
29 | const res = await ds.findOrCreateUser({ email: 'a@a.a' });
30 | expect(res).toEqual({ id: 1 });
31 |
32 | // make sure store is called properly
33 | expect(mockStore.users.findOrCreate).toBeCalledWith({
34 | where: { email: 'a@a.a' },
35 | });
36 | });
37 |
38 | it('returns null if no user found/created', async () => {
39 | // store lookup is not mocked to return anything, so this
40 | // simulates a failed lookup
41 |
42 | const res = await ds.findOrCreateUser({ email: 'a@a.a' });
43 | expect(res).toEqual(null);
44 | });
45 | });
46 |
47 | describe('[UserAPI.bookTrip]', () => {
48 | it('calls store creator and returns result', async () => {
49 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'heya' }]);
50 |
51 | // check the result of the fn
52 | const res = await ds.bookTrip({ launchId: 1 });
53 | expect(res).toBeTruthy();
54 |
55 | // make sure store is called properly
56 | expect(mockStore.trips.findOrCreate).toBeCalledWith({
57 | where: { launchId: 1, userId: 1 },
58 | });
59 | });
60 | });
61 |
62 | describe('[UserAPI.bookTrips]', () => {
63 | it('returns multiple lookups from bookTrip', async () => {
64 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'heya' }]);
65 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'okay' }]);
66 |
67 | const res = await ds.bookTrips({ launchIds: [1, 2] });
68 | expect(res).toEqual(['heya', 'okay']);
69 | });
70 | });
71 |
72 | describe('[UserAPI.cancelTrip]', () => {
73 | it('calls store destroy and returns result', async () => {
74 | const args = { userId: 1, launchId: 1 };
75 | mockStore.trips.destroy.mockReturnValueOnce('heya');
76 |
77 | // check the result of the fn
78 | const res = await ds.cancelTrip(args);
79 | expect(res).toEqual(true);
80 |
81 | // make sure store is called properly
82 | expect(mockStore.trips.destroy).toBeCalledWith({ where: args });
83 | });
84 | });
85 |
86 | describe('[UserAPI.getLaunchIdsByUser]', () => {
87 | it('looks up launches by user', async () => {
88 | const args = { userId: 1 };
89 | const launches = [
90 | { dataValues: { launchId: 1 } },
91 | { dataValues: { launchId: 2 } },
92 | ];
93 | mockStore.trips.findAll.mockReturnValueOnce(launches);
94 |
95 | // check the result of the fn
96 | const res = await ds.getLaunchIdsByUser(args);
97 | expect(res).toEqual([1, 2]);
98 |
99 | // make sure store is called properly
100 | expect(mockStore.trips.findAll).toBeCalledWith({ where: args });
101 | });
102 |
103 | it('returns empty array if nothing found', async () => {
104 | const args = { userId: 1 };
105 | // store lookup is not mocked to return anything, so this
106 | // simulates a failed lookup
107 |
108 | // check the result of the fn
109 | const res = await ds.getLaunchIdsByUser(args);
110 | expect(res).toEqual([]);
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/final/server/src/datasources/launch.js:
--------------------------------------------------------------------------------
1 | const { RESTDataSource } = require("apollo-datasource-rest");
2 |
3 | class LaunchAPI extends RESTDataSource {
4 | constructor() {
5 | super();
6 | this.baseURL = "https://api.spacexdata.com/v2/";
7 | }
8 |
9 | async getAllLaunches() {
10 | const response = await this.get("launches");
11 | return Array.isArray(response)
12 | ? response.map(launch => this.launchReducer(launch))
13 | : [];
14 | }
15 | launchReducer(launch) {
16 | return {
17 | id: launch.flight_number || 0,
18 | cursor: `${launch.launch_date_unix}`,
19 | site: launch.launch_site && launch.launch_site.site_name,
20 | mission: {
21 | name: launch.mission_name,
22 | missionPatchSmall: launch.links.mission_patch_small,
23 | missionPatchLarge: launch.links.mission_patch
24 | },
25 | rocket: {
26 | id: launch.rocket.rocket_id,
27 | name: launch.rocket.rocket_name,
28 | type: launch.rocket.rocket_type
29 | }
30 | };
31 | }
32 |
33 | async getLaunchById({ launchId }) {
34 | const res = await this.get('launches', { flight_number: launchId });
35 | return this.launchReducer(res[0]);
36 | }
37 |
38 | getLaunchesByIds({ launchIds }) {
39 | return Promise.all(
40 | launchIds.map(launchId => this.getLaunchById({ launchId }))
41 | )
42 | }
43 |
44 | }
45 |
46 | module.exports = LaunchAPI;
47 |
--------------------------------------------------------------------------------
/final/server/src/datasources/user.js:
--------------------------------------------------------------------------------
1 | const { DataSource } = require("apollo-datasource");
2 | const isEmail = require("isemail");
3 |
4 | class UserAPI extends DataSource {
5 | constructor({ store }) {
6 | super();
7 | this.store = store;
8 | }
9 |
10 | /**
11 | * This is a function that gets called by ApolloServer when being setup.
12 | * This function gets called with the datasource config including things
13 | * like caches and context. We'll assign this.context to the request context
14 | * here, so we can know about the user making requests
15 | */
16 | initialize(config) {
17 | this.context = config.context;
18 | }
19 |
20 | /**
21 | * User can be called with an argument that includes email, but it doesn't
22 | * have to be. If the user is already on the context, it will use that user
23 | * instead
24 | */
25 | async findOrCreateUser({ email: emailArg } = {}) {
26 | const email =
27 | this.context && this.context.user ? this.context.user.email : emailArg;
28 | if (!email || !isEmail.validate(email)) return null;
29 |
30 | const users = await this.store.users.findOrCreate({ where: { email } });
31 | return users && users[0] ? users[0] : null;
32 | }
33 |
34 | async findUserByEmail({ email: emailArg } = {}) {
35 | const email =
36 | this.context && this.context.user ? this.context.user.email : emailArg;
37 | if (!email || !isEmail.validate(email)) return null;
38 | const user = await this.store.users.findOne({ where: {email} });
39 | return user != null
40 | }
41 |
42 | async bookTrips({ launchIds }) {
43 | if (this.context && this.context.user != null) {
44 | const userId = this.context.user.id;
45 | if (!userId) return;
46 | }
47 | let results = [];
48 | // for each launch id, try to book the trip and add it to the results array
49 | // if successful
50 | for (const launchId of launchIds) {
51 | const res = await this.bookTrip({ launchId });
52 | if (res) results.push(res);
53 | }
54 |
55 | return results;
56 | }
57 |
58 | async bookTrip({ launchId }) {
59 | let userId;
60 | if (this.context && this.context.user != null) {
61 | userId = this.context.user.id;
62 | if (!userId) return;
63 | }
64 | const res = await this.store.trips.findOrCreate({
65 | where: { userId, launchId }
66 | });
67 | return res && res.length ? res[0].get() : false;
68 | }
69 |
70 | async cancelTrip({ launchId }) {
71 | const userId = this.context.user.id;
72 | return !!this.store.trips.destroy({ where: { userId, launchId } });
73 | }
74 |
75 | async getLaunchIdsByUser() {
76 | const userId = this.context.user.id;
77 | const found = await this.store.trips.findAll({
78 | where: { userId }
79 | });
80 | return found && found.length
81 | ? found.map(l => l.dataValues.launchId).filter(l => !!l)
82 | : [];
83 | }
84 |
85 | async isBookedOnLaunch({ launchId }) {
86 | if (!this.context || !this.context.user) return false;
87 | const userId = this.context.user.id;
88 | const found = await this.store.trips.findAll({
89 | where: { userId, launchId }
90 | });
91 | return found && found.length > 0;
92 | }
93 | }
94 |
95 | module.exports = UserAPI;
96 |
--------------------------------------------------------------------------------
/final/server/src/index.js:
--------------------------------------------------------------------------------
1 | // require('dotenv').config();
2 | const { ApolloServer } = require("apollo-server");
3 | const isEmail = require("isemail");
4 |
5 | const typeDefs = require("./schema");
6 | const resolvers = require("./resolvers");
7 | const { createStore } = require("./utils");
8 |
9 | const LaunchAPI = require("./datasources/launch");
10 | const UserAPI = require("./datasources/user");
11 |
12 | // creates a sequelize connection once. NOT for every request
13 | const store = createStore();
14 |
15 | // set up any dataSources our resolvers need
16 | const dataSources = () => ({
17 | launchAPI: new LaunchAPI(),
18 | userAPI: new UserAPI({ store })
19 | });
20 |
21 | // the function that sets up the global context for each resolver, using the req
22 | const context = async ({ req }) => {
23 | // simple auth check on every request
24 | const auth = (req.headers && req.headers.authorization) || "";
25 | const email = Buffer.from(auth, "base64").toString("ascii");
26 |
27 | // if the email isn't formatted validly, return null for user
28 | if (!isEmail.validate(email)) return { user: null };
29 | // find a user by their email
30 | const users = await store.users.findOne({ where: { email } });
31 | const user = users && users[0] ? users[0] : null;
32 |
33 | return { user: { ...user.dataValues } };
34 | };
35 |
36 | // Set up Apollo Server
37 | const server = new ApolloServer({
38 | typeDefs,
39 | resolvers,
40 | dataSources,
41 | context
42 | });
43 |
44 | // Start our server if we're not in a test env.
45 | // if we're in a test env, we'll manually start it in a test
46 | server
47 | .listen({ port: 4000 })
48 | .then(({ url }) => console.log(`🚀 app running at ${url}`));
49 |
50 | // export all the important pieces for integration/e2e tests to use
51 | module.exports = {
52 | dataSources,
53 | context,
54 | typeDefs,
55 | resolvers,
56 | ApolloServer,
57 | LaunchAPI,
58 | UserAPI,
59 | store,
60 | server
61 | };
62 |
--------------------------------------------------------------------------------
/final/server/src/resolvers.js:
--------------------------------------------------------------------------------
1 | const { paginateResults } = require('./utils');
2 |
3 | module.exports = {
4 | Query: {
5 | launches: async (_, { pageSize = 20, after }, { dataSources }) => {
6 | const allLaunches = await dataSources.launchAPI.getAllLaunches();
7 | // we want these in reverse chronological order
8 | allLaunches.reverse();
9 |
10 | const launches = paginateResults({
11 | after,
12 | pageSize,
13 | results: allLaunches,
14 | });
15 |
16 | return {
17 | launches,
18 | cursor: launches.length ? launches[launches.length - 1].cursor : null,
19 | // if the cursor of the end of the paginated results is the same as the
20 | // last item in _all_ results, then there are no more results after this
21 | hasMore: launches.length
22 | ? launches[launches.length - 1].cursor !==
23 | allLaunches[allLaunches.length - 1].cursor
24 | : false,
25 | };
26 | },
27 | launch: (_, { id }, { dataSources }) =>
28 | dataSources.launchAPI.getLaunchById({ launchId: id }),
29 | me: async (_, __, { dataSources }) =>
30 | dataSources.userAPI.findOrCreateUser(),
31 | },
32 | Mutation: {
33 | bookTrips: async (_, { launchIds }, { dataSources }) => {
34 | const results = await dataSources.userAPI.bookTrips({ launchIds });
35 | const launches = await dataSources.launchAPI.getLaunchesByIds({
36 | launchIds,
37 | });
38 |
39 | return {
40 | success: results && results.length === launchIds.length,
41 | message:
42 | results.length === launchIds.length
43 | ? 'trips booked successfully'
44 | : `the following launches couldn't be booked: ${launchIds.filter(
45 | id => !results.includes(id),
46 | )}`,
47 | launches,
48 | };
49 | },
50 | cancelTrip: async (_, { launchId }, { dataSources }) => {
51 | const result = dataSources.userAPI.cancelTrip({ launchId });
52 |
53 | if (!result)
54 | return {
55 | success: false,
56 | message: 'failed to cancel trip',
57 | };
58 |
59 | const launch = await dataSources.launchAPI.getLaunchById({ launchId });
60 | return {
61 | success: true,
62 | message: 'trip cancelled',
63 | launches: [launch],
64 | };
65 | },
66 | login: async (_, { email }, { dataSources }) => {
67 | const user = await dataSources.userAPI.findUserByEmail({ email });
68 | if (!user)
69 | return {
70 | success: false,
71 | message: 'the email is not valid account, pelease contact with admin.',
72 | };
73 | if (user) return {
74 | success: true,
75 | token: Buffer.from(email).toString('base64'),
76 | }
77 | },
78 | },
79 | Launch: {
80 | isBooked: async (launch, _, { dataSources }) =>
81 | dataSources.userAPI.isBookedOnLaunch({ launchId: launch.id }),
82 | },
83 | Mission: {
84 | // make sure the default size is 'large' in case user doesn't specify
85 | missionPatch: (mission, { size } = { size: 'LARGE' }) => {
86 | return size === 'SMALL'
87 | ? mission.missionPatchSmall
88 | : mission.missionPatchLarge;
89 | },
90 | },
91 | User: {
92 | trips: async (_, __, { dataSources }) => {
93 | // get ids of launches by user
94 | const launchIds = await dataSources.userAPI.getLaunchIdsByUser();
95 |
96 | if (!launchIds.length) return [];
97 |
98 | // look up those launches by their ids
99 | return (
100 | dataSources.launchAPI.getLaunchesByIds({
101 | launchIds,
102 | }) || []
103 | );
104 | },
105 | },
106 | };
107 |
--------------------------------------------------------------------------------
/final/server/src/schema.js:
--------------------------------------------------------------------------------
1 | const { gql } = require('apollo-server');
2 |
3 | const typeDefs = gql`
4 | type Query {
5 | launches(
6 | """
7 | The number of results to show. Must be >= 1. Default = 20
8 | """
9 | pageSize: Int
10 | """
11 | If you add a cursor here, it will only return results _after_ this cursor
12 | """
13 | after: String
14 | ): LaunchConnection!
15 | launch(id: ID!): Launch
16 | me: User
17 | }
18 |
19 | type Mutation {
20 | # if false, signup failed -- check errors
21 | bookTrips(launchIds: [ID]!): TripUpdateResponse!
22 |
23 | # if false, cancellation failed -- check errors
24 | cancelTrip(launchId: ID!): TripUpdateResponse!
25 |
26 | login(email: String): LoginResponse! # login token
27 | }
28 |
29 | type LoginResponse {
30 | success: Boolean!
31 | message: String
32 | token: String
33 | }
34 |
35 | type TripUpdateResponse {
36 | success: Boolean!
37 | message: String
38 | launches: [Launch]
39 | }
40 |
41 | """
42 | Simple wrapper around our list of launches that contains a cursor to the
43 | last item in the list. Pass this cursor to the launches query to fetch results
44 | after these.
45 | """
46 | type LaunchConnection {
47 | cursor: String!
48 | hasMore: Boolean!
49 | launches: [Launch]!
50 | }
51 |
52 | type Launch {
53 | id: ID!
54 | site: String
55 | mission: Mission
56 | rocket: Rocket
57 | isBooked: Boolean!
58 | }
59 |
60 | type Rocket {
61 | id: ID!
62 | name: String
63 | type: String
64 | }
65 |
66 | type User {
67 | id: ID!
68 | email: String!
69 | trips: [Launch]!
70 | }
71 |
72 | type Mission {
73 | name: String
74 | missionPatch(size: PatchSize): String
75 | }
76 |
77 | enum PatchSize {
78 | SMALL
79 | LARGE
80 | }
81 | `;
82 |
83 | module.exports = typeDefs;
84 |
--------------------------------------------------------------------------------
/final/server/src/utils.js:
--------------------------------------------------------------------------------
1 | // import { DB_NAME, PORT, HOST, USERNAME, PASSWORD } from './common/consts';
2 | const SQL = require('sequelize');
3 |
4 | module.exports.paginateResults = ({
5 | after: cursor,
6 | pageSize = 20,
7 | results,
8 | // can pass in a function to calculate an item's cursor
9 | getCursor = () => null,
10 | }) => {
11 | if (pageSize < 1) return [];
12 |
13 | if (!cursor) return results.slice(0, pageSize);
14 | const cursorIndex = results.findIndex(item => {
15 | // if an item has a `cursor` on it, use that, otherwise try to generate one
16 | let itemCursor = item.cursor ? item.cursor : getCursor(item);
17 |
18 | // if there's still not a cursor, return false by default
19 | return itemCursor ? cursor === itemCursor : false;
20 | });
21 |
22 | return cursorIndex >= 0
23 | ? cursorIndex === results.length - 1 // don't let us overflow
24 | ? []
25 | : results.slice(
26 | cursorIndex + 1,
27 | Math.min(results.length, cursorIndex + 1 + pageSize),
28 | )
29 | : results.slice(0, pageSize);
30 | };
31 |
32 | module.exports.createStore = () => {
33 | const Op = SQL.Op;
34 | const operatorsAliases = {
35 | $in: Op.in,
36 | };
37 |
38 | const db = new SQL('database', 'username', 'password', {
39 | dialect: 'sqlite',
40 | storage: './store.sqlite',
41 | operatorsAliases,
42 | logging: false,
43 | });
44 |
45 | // const mysqlDb = new SQL(`${DB_NAME}`, `${USERNAME}`, `${PASSWORD}`, {
46 | // dialect: 'mysql',
47 | // host: `${HOST}`,
48 | // port: `${PORT}`,
49 | // // operatorsAliases,
50 | // logging: false,
51 | // })
52 |
53 | const users = db.define('user', {
54 | id: {
55 | type: SQL.INTEGER,
56 | primaryKey: true,
57 | autoIncrement: true,
58 | },
59 | createdAt: SQL.DATE,
60 | updatedAt: SQL.DATE,
61 | email: SQL.STRING,
62 | token: SQL.STRING,
63 | });
64 |
65 | const trips = db.define('trip', {
66 | id: {
67 | type: SQL.INTEGER,
68 | primaryKey: true,
69 | autoIncrement: true,
70 | },
71 | createdAt: SQL.DATE,
72 | updatedAt: SQL.DATE,
73 | launchId: SQL.INTEGER,
74 | userId: SQL.INTEGER,
75 | });
76 |
77 | return { users, trips };
78 | };
79 |
--------------------------------------------------------------------------------
/final/server/store.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/final/server/store.sqlite
--------------------------------------------------------------------------------
/google0f9167d144dd34a2.html:
--------------------------------------------------------------------------------
1 | google-site-verification: google0f9167d144dd34a2.html
--------------------------------------------------------------------------------
/start/client/.env.example:
--------------------------------------------------------------------------------
1 | ENGINE_API_KEY=
--------------------------------------------------------------------------------
/start/client/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/start/client/README.md:
--------------------------------------------------------------------------------
1 | # Apollo Fullstack Tutorial
2 |
3 | ## Client
4 |
--------------------------------------------------------------------------------
/start/client/apollo.config.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/apollo.config.js
--------------------------------------------------------------------------------
/start/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/react-hooks": "3.0.0",
7 | "@reach/router": "^1.2.1",
8 | "apollo-cache-inmemory": "^1.6.2",
9 | "apollo-client": "^2.6.3",
10 | "apollo-link-http": "^1.5.15",
11 | "emotion": "^9.2.12",
12 | "graphql": "^14.4.2",
13 | "graphql-tag": "^2.10.1",
14 | "polished": "^3.4.1",
15 | "react": "^16.9.0-alpha.0",
16 | "react-dom": "^16.9.0-alpha.0",
17 | "react-emotion": "^9.2.12",
18 | "react-scripts": "3.0.1"
19 | },
20 | "scripts": {
21 | "start": "react-scripts start",
22 | "build": "react-scripts build",
23 | "test": "react-scripts test",
24 | "eject": "react-scripts eject"
25 | },
26 | "eslintConfig": {
27 | "extends": "react-app"
28 | },
29 | "browserslist": [
30 | ">0.2%",
31 | "not dead",
32 | "not ie <= 11",
33 | "not op_mini all"
34 | ],
35 | "devDependencies": {
36 | "@apollo/react-testing": "3.0.0",
37 | "@testing-library/jest-dom": "^4.0.0",
38 | "@testing-library/react": "^8.0.7",
39 | "apollo": "^2.16.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/start/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/public/favicon.ico
--------------------------------------------------------------------------------
/start/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
23 | Launches
24 |
25 |
26 |
27 |
28 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/start/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Launches",
3 | "name": "Launches",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/start/client/src/assets/curve.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/assets/icons/cart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/assets/icons/exit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/assets/icons/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/assets/icons/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/assets/images/badge-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/badge-1.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/badge-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/badge-2.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/badge-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/badge-3.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/dog-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/dog-1.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/dog-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/dog-2.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/dog-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/dog-3.png
--------------------------------------------------------------------------------
/start/client/src/assets/images/galaxy.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/galaxy.jpg
--------------------------------------------------------------------------------
/start/client/src/assets/images/iss.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/iss.jpg
--------------------------------------------------------------------------------
/start/client/src/assets/images/moon.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/moon.jpg
--------------------------------------------------------------------------------
/start/client/src/assets/images/space.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/assets/images/space.jpg
--------------------------------------------------------------------------------
/start/client/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Button from '../button';
5 |
6 | describe('Button', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { renderApollo, cleanup } from '../../test-utils';
4 | import Footer from '../footer';
5 |
6 | describe('Footer', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | renderApollo();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Header from '../header';
5 |
6 | describe('Header', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/launch-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LaunchDetail from '../launch-detail';
5 |
6 | describe('Launch Detail View', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render(
12 | ,
17 | );
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/launch-tile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LaunchTile from '../launch-tile';
5 |
6 | describe('Launch Tile', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render(
12 | ,
19 | );
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import Loading from '../loading';
5 |
6 | describe('Loading', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/login-form.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import LoginForm from '../login-form';
5 |
6 | describe('Login Form', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/menu-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import MenuItem from '../menu-item';
5 |
6 | describe('Menu Item', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/__tests__/page-container.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { render, cleanup } from '../../test-utils';
4 | import PageContainer from '../page-container';
5 |
6 | describe('Page Container', () => {
7 | // automatically unmount and cleanup DOM after the test is finished.
8 | afterEach(cleanup);
9 |
10 | it('renders without error', () => {
11 | render();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/start/client/src/components/button.js:
--------------------------------------------------------------------------------
1 | import styled from 'react-emotion';
2 | import { lighten } from 'polished';
3 |
4 | import { unit, colors } from '../styles';
5 |
6 | const height = 50;
7 | export default styled('button')({
8 | display: 'block',
9 | minWidth: 200,
10 | height,
11 | margin: '0 auto',
12 | padding: `0 ${unit * 4}px`,
13 | border: 'none',
14 | borderRadius: height / 2,
15 | fontFamily: 'inherit',
16 | fontSize: 18,
17 | lineHeight: `${height}px`,
18 | fontWeight: 700,
19 | color: 'white',
20 | textTransform: 'uppercase',
21 | backgroundColor: colors.accent,
22 | cursor: 'pointer',
23 | outline: 'none',
24 | ':hover': {
25 | backgroundColor: lighten(0.1, colors.accent),
26 | },
27 | ':active': {
28 | backgroundColor: lighten(0.2, colors.accent),
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/start/client/src/components/footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import MenuItem from './menu-item';
5 | import LogoutButton from '../containers/logout-button';
6 | import { ReactComponent as HomeIcon } from '../assets/icons/home.svg';
7 | import { ReactComponent as CartIcon } from '../assets/icons/cart.svg';
8 | import { ReactComponent as ProfileIcon } from '../assets/icons/profile.svg';
9 | import { colors, unit } from '../styles';
10 |
11 | export default function Footer() {
12 | return (
13 |
14 |
15 |
19 |
23 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | /**
34 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
35 | */
36 |
37 | const Container = styled('footer')({
38 | flexShrink: 0,
39 | marginTop: 'auto',
40 | backgroundColor: 'white',
41 | color: colors.textSecondary,
42 | position: 'sticky',
43 | bottom: 0,
44 | });
45 |
46 | const InnerContainer = styled('div')({
47 | display: 'flex',
48 | alignItems: 'center',
49 | maxWidth: 460,
50 | padding: unit * 2.5,
51 | margin: '0 auto',
52 | });
53 |
--------------------------------------------------------------------------------
/start/client/src/components/header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 | import { size } from 'polished';
4 |
5 | import { unit, colors } from '../styles';
6 | import dog1 from '../assets/images/dog-1.png';
7 | import dog2 from '../assets/images/dog-2.png';
8 | import dog3 from '../assets/images/dog-3.png';
9 |
10 | const max = 25; // 25 letters in the alphabet
11 | const offset = 97; // letter A's charcode is 97
12 | const avatars = [dog1, dog2, dog3];
13 | const maxIndex = avatars.length - 1;
14 | function pickAvatarByEmail(email) {
15 | const charCode = email.toLowerCase().charCodeAt(0) - offset;
16 | const percentile = Math.max(0, Math.min(max, charCode)) / max;
17 | return avatars[Math.round(maxIndex * percentile)];
18 | }
19 |
20 | export default function Header({ image, children = 'Space Explorer' }) {
21 | const email = atob(localStorage.getItem('token'));
22 | const avatar = image || pickAvatarByEmail(email);
23 | return (
24 |
25 |
26 |
27 |
{children}
28 | {email}
29 |
30 |
31 | );
32 | }
33 |
34 | /**
35 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
36 | */
37 |
38 | const Container = styled('div')({
39 | display: 'flex',
40 | alignItems: 'center',
41 | marginBottom: unit * 4.5,
42 | });
43 |
44 | const Image = styled('img')(size(134), props => ({
45 | marginRight: unit * 2.5,
46 | borderRadius: props.round && '50%',
47 | }));
48 |
49 | const Subheading = styled('h5')({
50 | marginTop: unit / 2,
51 | color: colors.textSecondary,
52 | });
53 |
--------------------------------------------------------------------------------
/start/client/src/components/index.js:
--------------------------------------------------------------------------------
1 | export { default as Button } from './button';
2 | export { default as Footer } from './footer';
3 | export { default as Header } from './header';
4 | export { default as LaunchDetail } from './launch-detail';
5 | export { default as LaunchTile } from './launch-tile';
6 | export { default as Loading } from './loading';
7 | export { default as LoginForm } from './login-form';
8 | export { default as MenuItem } from './menu-item';
9 | export { default as PageContainer } from './page-container';
10 |
--------------------------------------------------------------------------------
/start/client/src/components/launch-detail.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import { unit } from '../styles';
5 | import { cardClassName, getBackgroundImage } from './launch-tile';
6 |
7 | const LaunchDetail = ({ id, site, rocket }) => (
8 |
13 |
14 | {rocket.name} ({rocket.type})
15 |
16 | {site}
17 |
18 | );
19 |
20 | /**
21 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
22 | */
23 |
24 | const Card = styled('div')(cardClassName, {
25 | height: 365,
26 | marginBottom: unit * 4,
27 | });
28 |
29 | export default LaunchDetail;
30 |
--------------------------------------------------------------------------------
/start/client/src/components/launch-tile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { css } from 'react-emotion';
3 | import { Link } from '@reach/router';
4 |
5 | import galaxy from '../assets/images/galaxy.jpg';
6 | import iss from '../assets/images/iss.jpg';
7 | import moon from '../assets/images/moon.jpg';
8 | import { unit } from '../styles';
9 |
10 | const backgrounds = [galaxy, iss, moon];
11 | export function getBackgroundImage(id) {
12 | return `url(${backgrounds[Number(id) % backgrounds.length]})`;
13 | }
14 |
15 | export default ({ launch }) => {
16 | const { id, mission, rocket } = launch;
17 | return (
18 |
24 | {mission.name}
25 | {rocket.name}
26 |
27 | );
28 | };
29 |
30 | /**
31 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
32 | */
33 |
34 | export const cardClassName = css({
35 | padding: `${unit * 4}px ${unit * 5}px`,
36 | borderRadius: 7,
37 | color: 'white',
38 | backgroundSize: 'cover',
39 | backgroundPosition: 'center',
40 | });
41 |
42 | const padding = unit * 2;
43 | const StyledLink = styled(Link)(cardClassName, {
44 | display: 'block',
45 | height: 193,
46 | marginTop: padding,
47 | textDecoration: 'none',
48 | ':not(:last-child)': {
49 | marginBottom: padding * 2,
50 | },
51 | });
52 |
--------------------------------------------------------------------------------
/start/client/src/components/loading.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'react-emotion';
2 | import { size } from 'polished';
3 |
4 | import { ReactComponent as Logo } from '../assets/logo.svg';
5 | import { colors } from '../styles';
6 |
7 | const spin = keyframes`
8 | to {
9 | transform: rotate(360deg);
10 | }
11 | `;
12 |
13 | const Loading = styled(Logo)(size(64), {
14 | display: 'block',
15 | margin: 'auto',
16 | fill: colors.grey,
17 | path: {
18 | transformOrigin: 'center',
19 | animation: `${spin} 1s linear infinite`,
20 | },
21 | });
22 |
23 | export default Loading;
24 |
--------------------------------------------------------------------------------
/start/client/src/components/login-form.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled, { css } from 'react-emotion';
3 | import { size } from 'polished';
4 |
5 | import Button from './button';
6 | import space from '../assets/images/space.jpg';
7 | import { ReactComponent as Logo } from '../assets/logo.svg';
8 | import { ReactComponent as Curve } from '../assets/curve.svg';
9 | import { ReactComponent as Rocket } from '../assets/rocket.svg';
10 | import { colors, unit } from '../styles';
11 |
12 | export default class LoginForm extends Component {
13 | state = { email: '' };
14 |
15 | onChange = event => {
16 | const email = event.target.value;
17 | this.setState(s => ({ email }));
18 | };
19 |
20 | onSubmit = event => {
21 | event.preventDefault();
22 | this.props.login({ variables: { email: this.state.email } });
23 | };
24 |
25 | render() {
26 | return (
27 |
28 |
32 |
33 | Space Explorer
34 |
35 |
43 |
44 |
45 |
46 | );
47 | }
48 | }
49 |
50 | /**
51 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
52 | */
53 |
54 | const Container = styled('div')({
55 | display: 'flex',
56 | flexDirection: 'column',
57 | alignItems: 'center',
58 | flexGrow: 1,
59 | paddingBottom: unit * 6,
60 | color: 'white',
61 | backgroundColor: colors.primary,
62 | backgroundImage: `url(${space})`,
63 | backgroundSize: 'cover',
64 | backgroundPosition: 'center',
65 | });
66 |
67 | const svgClassName = css({
68 | display: 'block',
69 | fill: 'currentColor',
70 | });
71 |
72 | const Header = styled('header')(svgClassName, {
73 | width: '100%',
74 | marginBottom: unit * 5,
75 | padding: unit * 2.5,
76 | position: 'relative',
77 | });
78 |
79 | const StyledLogo = styled(Logo)(size(56), {
80 | display: 'block',
81 | margin: '0 auto',
82 | position: 'relative',
83 | });
84 |
85 | const StyledCurve = styled(Curve)(size('100%'), {
86 | fill: colors.primary,
87 | position: 'absolute',
88 | top: 0,
89 | left: 0,
90 | });
91 |
92 | const Heading = styled('h1')({
93 | margin: `${unit * 3}px 0 ${unit * 6}px`,
94 | });
95 |
96 | const StyledRocket = styled(Rocket)(svgClassName, {
97 | width: 250,
98 | });
99 |
100 | const StyledForm = styled('form')({
101 | width: '100%',
102 | maxWidth: 406,
103 | padding: unit * 3.5,
104 | borderRadius: 3,
105 | boxShadow: '6px 6px 1px rgba(0, 0, 0, 0.25)',
106 | color: colors.text,
107 | backgroundColor: 'white',
108 | });
109 |
110 | const StyledInput = styled('input')({
111 | width: '100%',
112 | marginBottom: unit * 2,
113 | padding: `${unit * 1.25}px ${unit * 2.5}px`,
114 | border: `1px solid ${colors.grey}`,
115 | fontSize: 16,
116 | outline: 'none',
117 | ':focus': {
118 | borderColor: colors.primary,
119 | },
120 | });
121 |
--------------------------------------------------------------------------------
/start/client/src/components/menu-item.js:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'react-emotion';
2 | import { Link } from '@reach/router';
3 | import { colors, unit } from '../styles';
4 |
5 | export const menuItemClassName = css({
6 | flexGrow: 1,
7 | width: 0,
8 | fontFamily: 'inherit',
9 | fontSize: 20,
10 | color: 'inherit',
11 | letterSpacing: 1.5,
12 | textTransform: 'uppercase',
13 | textAlign: 'center',
14 | svg: {
15 | display: 'block',
16 | width: 60,
17 | margin: `0 auto ${unit}px`,
18 | fill: colors.secondary,
19 | },
20 | });
21 |
22 | const MenuItem = styled(Link)(menuItemClassName, {
23 | textDecoration: 'none',
24 | });
25 |
26 | export default MenuItem;
27 |
--------------------------------------------------------------------------------
/start/client/src/components/page-container.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import styled from 'react-emotion';
3 |
4 | import { unit, colors } from '../styles';
5 |
6 | export default function PageContainer(props) {
7 | return (
8 |
9 |
10 | {props.children}
11 |
12 | );
13 | }
14 |
15 | /**
16 | * STYLED COMPONENTS USED IN THIS FILE ARE BELOW HERE
17 | */
18 |
19 | const Bar = styled('div')({
20 | flexShrink: 0,
21 | height: 12,
22 | backgroundColor: colors.primary,
23 | });
24 |
25 | const Container = styled('div')({
26 | display: 'flex',
27 | flexDirection: 'column',
28 | flexGrow: 1,
29 | width: '100%',
30 | maxWidth: 600,
31 | margin: '0 auto',
32 | padding: unit * 3,
33 | paddingBottom: unit * 5,
34 | });
35 |
--------------------------------------------------------------------------------
/start/client/src/containers/__tests__/action-button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { InMemoryCache } from 'apollo-cache-inmemory';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import ActionButton, {
13 | GET_LAUNCH_DETAILS,
14 | CANCEL_TRIP,
15 | TOGGLE_CART_MUTATION,
16 | } from '../action-button';
17 | import { GET_CART_ITEMS } from '../../pages/cart';
18 |
19 | describe('action button', () => {
20 | // automatically unmount and cleanup DOM after the test is finished.
21 | afterEach(cleanup);
22 |
23 | it('renders without error', () => {
24 | const { getByTestId } = renderApollo();
25 | expect(getByTestId('action-button')).toBeTruthy();
26 | });
27 |
28 | it('shows correct label', () => {
29 | const { getByText, container } = renderApollo();
30 | getByText(/add to cart/i);
31 |
32 | // rerender with different props to same container
33 | renderApollo(, { container });
34 | getByText(/remove from cart/i);
35 |
36 | // rerender with different props to same container
37 | renderApollo(, { container });
38 | getByText(/cancel this trip/i);
39 | });
40 |
41 | /**
42 | * This test is a bit tricky, since the button doesn't _render_
43 | * anything based on the response from the mutation.
44 | *
45 | * We test this by only mocking one mutation at a time. If the component
46 | * tried to execute any mutation not mocked, it would throw an
47 | * error
48 | */
49 | xit('fires correct mutation with variables', async () => {
50 | // const cache = new InMemoryCache();
51 | // cache.writeQuery({
52 | // query: GET_CART_ITEMS,
53 | // data: { cartItems: [1] },
54 | // });
55 |
56 | // if we only provide 1 mock, any other queries would cause error
57 | let mocks = [
58 | {
59 | request: { query: TOGGLE_CART_MUTATION, variables: { launchId: 1 } },
60 | result: { data: { addOrRemoveFromCart: true } },
61 | },
62 | ];
63 |
64 | const { getByTestId, container, debug } = renderApollo(
65 | ,
66 | {
67 | mocks,
68 | // cache
69 | },
70 | );
71 | fireEvent.click(getByTestId('action-button'));
72 | await waitForElement(() => getByTestId('action-button'));
73 |
74 | // mocks = [
75 | // {
76 | // request: {
77 | // query: CANCEL_TRIP,
78 | // variables: { launchId: 1 },
79 | // },
80 | // result: {
81 | // data: {
82 | // cancelTrip: {
83 | // success: true,
84 | // message: '',
85 | // launches: [{ id: 1, isBooked: false }],
86 | // },
87 | // },
88 | // },
89 | // },
90 | // ];
91 |
92 | // renderApollo(, { mocks, container });
93 | // fireEvent.click(getByTestId('action-button'));
94 | // await waitForElement(() => getByTestId('action-button'));
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/start/client/src/containers/__tests__/book-trips.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | renderApollo,
5 | cleanup,
6 | getByTestId,
7 | fireEvent,
8 | waitForElement,
9 | render,
10 | } from '../../test-utils';
11 | import BookTrips, { BOOK_TRIPS, GET_LAUNCH } from '../book-trips';
12 | import { GET_CART_ITEMS } from '../../pages/cart';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | id: 1,
20 | name: 'tester',
21 | },
22 | mission: {
23 | name: 'test mission',
24 | missionPatch: '/',
25 | },
26 | };
27 |
28 | describe('book trips', () => {
29 | // automatically unmount and cleanup DOM after the test is finished.
30 | afterEach(cleanup);
31 |
32 | it('renders without error', () => {
33 | const { getByTestId } = renderApollo();
34 | expect(getByTestId('book-button')).toBeTruthy();
35 | });
36 |
37 | it('completes mutation and shows message', async () => {
38 | let mocks = [
39 | {
40 | request: { query: BOOK_TRIPS, variables: { launchIds: [1] } },
41 | result: {
42 | data: {
43 | bookTrips: [{ success: true, message: 'success!', launches: [] }],
44 | },
45 | },
46 | },
47 | {
48 | // we need this query for refetchQueries
49 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
50 | result: { data: { launch: mockLaunch } },
51 | },
52 | ];
53 | const { getByText, container, getByTestId } = renderApollo(
54 | ,
55 | { mocks, addTypename: false },
56 | );
57 |
58 | fireEvent.click(getByTestId('book-button'));
59 |
60 | // Let's wait until our mocked mutation resolves and
61 | // the component re-renders.
62 | // getByTestId throws an error if it cannot find an element with the given ID
63 | // and waitForElement will wait until the callback doesn't throw an error
64 | const successText = await waitForElement(() => getByTestId('message'));
65 | });
66 |
67 | // >>>> TODO
68 | it('correctly updates cache', () => {});
69 | });
70 |
--------------------------------------------------------------------------------
/start/client/src/containers/__tests__/cart-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | renderApollo,
5 | cleanup,
6 | getByTestId,
7 | fireEvent,
8 | waitForElement,
9 | render,
10 | } from '../../test-utils';
11 | import CartItem, { GET_LAUNCH } from '../cart-item';
12 |
13 | const mockLaunch = {
14 | __typename: 'Launch',
15 | id: 1,
16 | isBooked: true,
17 | rocket: {
18 | id: 1,
19 | name: 'tester',
20 | },
21 | mission: {
22 | name: 'test mission',
23 | missionPatch: '/',
24 | },
25 | };
26 |
27 | xdescribe('cart item', () => {
28 | // automatically unmount and cleanup DOM after the test is finished.
29 | afterEach(cleanup);
30 |
31 | it('queries item and renders without error', () => {
32 | let mocks = [
33 | {
34 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
35 | result: { data: { launch: mockLaunch } },
36 | },
37 | ];
38 |
39 | // since we know the name of the mission, and know that name
40 | // will be rendered at some point, we can use getByText
41 | const { getByText, debug } = renderApollo(, {
42 | mocks,
43 | addTypename: false,
44 | });
45 |
46 | // check the loading state
47 | getByText(/loading/i);
48 |
49 | return waitForElement(() => getByText(/test mission/i));
50 | });
51 |
52 | it('renders with error state', () => {
53 | let mocks = [
54 | {
55 | request: { query: GET_LAUNCH, variables: { launchId: 1 } },
56 | error: new Error('aw shucks'),
57 | },
58 | ];
59 |
60 | // since we know the error message, we can use getByText
61 | // to recognize the error
62 | const { getByText, debug } = renderApollo(, {
63 | mocks,
64 | addTypename: false,
65 | });
66 |
67 | waitForElement(() => getByText(/error: aw shucks/i));
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/start/client/src/containers/__tests__/logout-button.js:
--------------------------------------------------------------------------------
1 | // TODO
2 | it('', () => {});
3 |
--------------------------------------------------------------------------------
/start/client/src/containers/action-button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function ActionButton() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/containers/book-trips.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function BookTrips() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/containers/cart-item.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function CartItem() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/containers/index.js:
--------------------------------------------------------------------------------
1 | export { default as ActionButton } from './action-button';
2 | export { default as BookTrips } from './book-trips';
3 | export { default as CartItem } from './cart-item';
4 | export { default as LogoutButton } from './logout-button';
5 |
--------------------------------------------------------------------------------
/start/client/src/containers/logout-button.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function LogoutButton() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/index.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/index.js
--------------------------------------------------------------------------------
/start/client/src/pages/__tests__/cart.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { InMemoryCache } from 'apollo-cache-inmemory';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Cart, { GET_CART_ITEMS } from '../cart';
13 |
14 | xdescribe('Cart Page', () => {
15 | // automatically unmount and cleanup DOM after the test is finished.
16 | afterEach(cleanup);
17 |
18 | it('renders with message for empty carts', () => {
19 | // TODO: why is this necessary
20 | const cache = new InMemoryCache();
21 | cache.writeQuery({
22 | query: GET_CART_ITEMS,
23 | data: { cartItems: [] },
24 | });
25 |
26 | let mocks = [
27 | {
28 | request: { query: GET_CART_ITEMS },
29 | result: { data: { cartItems: [] } },
30 | },
31 | ];
32 | const { getByTestId } = renderApollo(, { mocks, cache });
33 | return waitForElement(() => getByTestId('empty-message'));
34 | });
35 |
36 | it('renders cart', () => {
37 | // TODO: why is this necessary
38 | const cache = new InMemoryCache();
39 | cache.writeQuery({
40 | query: GET_CART_ITEMS,
41 | data: { cartItems: [1] },
42 | });
43 |
44 | let mocks = [
45 | {
46 | request: { query: GET_CART_ITEMS },
47 | result: { data: { cartItems: [1] } },
48 | },
49 | ];
50 | const { getByTestId } = renderApollo(, { mocks, cache: undefined });
51 | return waitForElement(() => getByTestId('empty-message'));
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/start/client/src/pages/__tests__/launch.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Launch, { GET_LAUNCH_DETAILS } from '../launch';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | __typename: 'Rocket',
20 | id: 1,
21 | name: 'tester',
22 | type: 'test',
23 | },
24 | mission: {
25 | __typename: 'Mission',
26 | id: 1,
27 | name: 'test mission',
28 | missionPatch: '/',
29 | },
30 | site: 'earth',
31 | isInCart: false,
32 | };
33 |
34 | // TODO: un-skip after local state fixes
35 | xdescribe('Launch Page', () => {
36 | // automatically unmount and cleanup DOM after the test is finished.
37 | afterEach(cleanup);
38 |
39 | it('renders launch', async () => {
40 | const mocks = [
41 | {
42 | request: { query: GET_LAUNCH_DETAILS, variables: { launchId: 1 } },
43 | result: { data: { launch: mockLaunch } },
44 | },
45 | ];
46 | const { getByText } = await renderApollo(, {
47 | mocks,
48 | resolvers: {}
49 | });
50 | await waitForElement(() => getByText(/test mission/i));
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/start/client/src/pages/__tests__/launches.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 |
4 | import {
5 | renderApollo,
6 | cleanup,
7 | getByTestId,
8 | fireEvent,
9 | waitForElement,
10 | render,
11 | } from '../../test-utils';
12 | import Launches, { GET_LAUNCHES } from '../launches';
13 |
14 | const mockLaunch = {
15 | __typename: 'Launch',
16 | id: 1,
17 | isBooked: true,
18 | rocket: {
19 | __typename: 'Rocket',
20 | id: 1,
21 | name: 'tester',
22 | type: 'test',
23 | },
24 | mission: {
25 | __typename: 'Mission',
26 | id: 1,
27 | name: 'test mission',
28 | missionPatch: '/',
29 | },
30 | site: 'earth',
31 | isInCart: false,
32 | };
33 |
34 | // TODO: un-skip after local state fixes
35 | xdescribe('Launches Page', () => {
36 | // automatically unmount and cleanup DOM after the test is finished.
37 | afterEach(cleanup);
38 |
39 | it('renders launches', async () => {
40 | const mocks = [
41 | {
42 | request: { query: GET_LAUNCHES },
43 | result: {
44 | data: {
45 | isLoggedIn: true,
46 | launches: { cursor: '123', hasMore: true, launches: [mockLaunch] },
47 | },
48 | },
49 | },
50 | ];
51 | const { getByText } = await renderApollo(, {
52 | mocks,
53 | });
54 | await waitForElement(() => getByText(/test mission/i));
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/start/client/src/pages/__tests__/login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 | import { InMemoryCache } from 'apollo-cache-inmemory';
4 | import gql from 'graphql-tag';
5 |
6 | import {
7 | renderApollo,
8 | cleanup,
9 | getByTestId,
10 | fireEvent,
11 | waitForElement,
12 | render,
13 | } from '../../test-utils';
14 | import Login, { LOGIN_USER } from '../login';
15 |
16 | describe('Login Page', () => {
17 | // automatically unmount and cleanup DOM after the test is finished.
18 | afterEach(cleanup);
19 |
20 | it('renders login page', async () => {
21 | renderApollo();
22 | });
23 |
24 | it('fires login mutation and updates cache after done', async () => {
25 | const cache = new InMemoryCache();
26 | const mocks = [
27 | {
28 | request: { query: LOGIN_USER, variables: { email: 'a@a.a' } },
29 | result: { data: { login: 'abc' } },
30 | },
31 | ];
32 |
33 | const { getByText, getByTestId } = await renderApollo(, {
34 | mocks,
35 | cache,
36 | });
37 |
38 | fireEvent.change(getByTestId('login-input'), {
39 | target: { value: 'a@a.a' },
40 | });
41 |
42 | fireEvent.click(getByText(/log in/i));
43 |
44 | // login is done if loader is gone
45 | await waitForElement(() => getByText(/log in/i));
46 |
47 | // check to make sure the cache's contents have been updated
48 | const { isLoggedIn } = cache.readQuery({
49 | query: gql`
50 | {
51 | isLoggedIn @client
52 | }
53 | `,
54 | });
55 |
56 | expect(isLoggedIn).toBeTruthy();
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/start/client/src/pages/__tests__/profile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { print } from 'graphql';
3 | import { InMemoryCache } from 'apollo-cache-inmemory';
4 | import gql from 'graphql-tag';
5 |
6 | import {
7 | renderApollo,
8 | cleanup,
9 | getByTestId,
10 | fireEvent,
11 | waitForElement,
12 | render,
13 | } from '../../test-utils';
14 | import Profile, { GET_MY_TRIPS } from '../profile';
15 |
16 | const mockLaunch = {
17 | __typename: 'Launch',
18 | id: 1,
19 | isBooked: true,
20 | rocket: {
21 | __typename: 'Rocket',
22 | id: 1,
23 | name: 'tester',
24 | },
25 | mission: {
26 | __typename: 'Mission',
27 | id: 1,
28 | name: 'test mission',
29 | missionPatch: '/',
30 | },
31 | };
32 |
33 | const mockMe = {
34 | __typename: 'User',
35 | id: 1,
36 | email: 'a@a.a',
37 | trips: [mockLaunch],
38 | };
39 |
40 | xdescribe('Profile Page', () => {
41 | // automatically unmount and cleanup DOM after the test is finished.
42 | afterEach(cleanup);
43 |
44 | it('renders profile page', async () => {
45 | const mocks = [
46 | {
47 | request: { query: GET_MY_TRIPS },
48 | result: { data: { me: mockMe } },
49 | },
50 | ];
51 |
52 | const { getByText } = renderApollo(, { mocks, resolvers: {} });
53 |
54 | // if the profile renders, it will have the list of missions booked
55 | await waitForElement(() => getByText(/test mission/i));
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/start/client/src/pages/cart.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function Cart() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { Router } from '@reach/router';
3 |
4 | import Launch from './launch';
5 | import Launches from './launches';
6 | import Cart from './cart';
7 | import Profile from './profile';
8 | import { Footer, PageContainer } from '../components';
9 |
10 | export default function Pages() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/start/client/src/pages/launch.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function Launch() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/pages/launches.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function Launches() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/pages/login.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function Login() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/pages/profile.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function Profile() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/start/client/src/resolvers.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/client/src/resolvers.js
--------------------------------------------------------------------------------
/start/client/src/styles.js:
--------------------------------------------------------------------------------
1 | import { injectGlobal } from 'react-emotion';
2 |
3 | export const unit = 8;
4 | export const colors = {
5 | primary: '#220a82',
6 | secondary: '#14cbc4',
7 | accent: '#e535ab',
8 | background: '#f7f8fa',
9 | grey: '#d8d9e0',
10 | text: '#343c5a',
11 | textSecondary: '#747790'
12 | };
13 |
14 | export default () => injectGlobal({
15 | [['html', 'body']]: {
16 | height: '100%',
17 | },
18 | body: {
19 | margin: 0,
20 | padding: 0,
21 | fontFamily: "'Source Sans Pro', sans-serif",
22 | backgroundColor: colors.background,
23 | color: colors.text,
24 | },
25 | '#root': {
26 | display: 'flex',
27 | flexDirection: 'column',
28 | minHeight: '100%',
29 | },
30 | '*': {
31 | boxSizing: 'border-box',
32 | },
33 | [['h1', 'h2', 'h3', 'h4', 'h5', 'h6']]: {
34 | margin: 0,
35 | fontWeight: 600,
36 | },
37 | h1: {
38 | fontSize: 48,
39 | lineHeight: 1,
40 | },
41 | h2: {
42 | fontSize: 40,
43 | },
44 | h3: {
45 | fontSize: 36,
46 | },
47 | h5: {
48 | fontSize: 16,
49 | textTransform: 'uppercase',
50 | letterSpacing: 4,
51 | }
52 | });
53 |
--------------------------------------------------------------------------------
/start/client/src/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | // this adds custom jest matchers from jest-dom
4 | import '@testing-library/jest-dom/extend-expect';
5 | import { MockedProvider } from '@apollo/react-testing';
6 |
7 | const renderApollo = (
8 | node,
9 | { mocks, addTypename, defaultOptions, cache, ...options } = {},
10 | ) => {
11 | return render(
12 |
18 | {node}
19 | ,
20 | options,
21 | );
22 | };
23 |
24 | export * from '@testing-library/react';
25 | export { renderApollo };
26 |
--------------------------------------------------------------------------------
/start/server/.env.example:
--------------------------------------------------------------------------------
1 | ENGINE_API_KEY=
--------------------------------------------------------------------------------
/start/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fullstack_tutorial_server",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "test": "jest",
8 | "start": "nodemon src/index.js",
9 | "start:ci": "node src/index.js"
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "apollo-datasource": "^0.1.3",
15 | "apollo-datasource-rest": "^0.1.5",
16 | "apollo-server": "2.6.1",
17 | "apollo-server-testing": "2.6.1",
18 | "graphql": "^14.2.1",
19 | "isemail": "^3.1.3",
20 | "nodemon": "^1.18.4",
21 | "sequelize": "^4.39.0",
22 | "sqlite3": "^4.0.3"
23 | },
24 | "devDependencies": {
25 | "apollo-link": "^1.2.3",
26 | "apollo-link-http": "^1.5.5",
27 | "jest": "^23.6.0",
28 | "nock": "^10.0.2",
29 | "node-fetch": "^2.2.1"
30 | },
31 | "jest": {
32 | "testPathIgnorePatterns": [
33 | "/node_modules/",
34 | "/__utils"
35 | ]
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/__snapshots__/e2e.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Server - e2e gets a single launch 1`] = `
4 | Object {
5 | "data": Object {
6 | "launch": Object {
7 | "id": "30",
8 | "isBooked": false,
9 | "mission": Object {
10 | "name": "Thaicom 8",
11 | },
12 | "rocket": Object {
13 | "type": "FT",
14 | },
15 | },
16 | },
17 | }
18 | `;
19 |
20 | exports[`Server - e2e gets list of launches 1`] = `
21 | Object {
22 | "data": Object {
23 | "launches": Object {
24 | "cursor": "1517433900",
25 | "launches": Array [
26 | Object {
27 | "mission": Object {
28 | "missionPatch": "https://images2.imgbox.com/42/0a/LAupFe3L_o.png",
29 | "name": "SES-16 / GovSat-1",
30 | },
31 | },
32 | ],
33 | },
34 | },
35 | }
36 | `;
37 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/__snapshots__/integration.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Mutations books trips 1`] = `
4 | Object {
5 | "data": Object {
6 | "bookTrips": Object {
7 | "launches": Array [
8 | Object {
9 | "id": "1",
10 | "isBooked": true,
11 | },
12 | Object {
13 | "id": "2",
14 | "isBooked": true,
15 | },
16 | ],
17 | "message": "trips booked successfully",
18 | "success": true,
19 | },
20 | },
21 | "errors": undefined,
22 | "extensions": undefined,
23 | "http": Object {
24 | "headers": Headers {
25 | Symbol(map): Object {},
26 | },
27 | },
28 | }
29 | `;
30 |
31 | exports[`Queries fetches list of launches 1`] = `
32 | Object {
33 | "data": Object {
34 | "launches": Object {
35 | "cursor": "1143239400",
36 | "hasMore": false,
37 | "launches": Array [
38 | Object {
39 | "id": "1",
40 | "isBooked": true,
41 | "mission": Object {
42 | "missionPatch": "https://images2.imgbox.com/40/e3/GypSkayF_o.png",
43 | "name": "FalconSat",
44 | },
45 | "rocket": Object {
46 | "name": "Falcon 1",
47 | },
48 | },
49 | ],
50 | },
51 | },
52 | "errors": undefined,
53 | "extensions": undefined,
54 | "http": Object {
55 | "headers": Headers {
56 | Symbol(map): Object {},
57 | },
58 | },
59 | }
60 | `;
61 |
62 | exports[`Queries fetches single launch 1`] = `
63 | Object {
64 | "data": Object {
65 | "launch": Object {
66 | "id": "1",
67 | "isBooked": true,
68 | "mission": Object {
69 | "name": "FalconSat",
70 | },
71 | "rocket": Object {
72 | "type": "Merlin A",
73 | },
74 | },
75 | },
76 | "errors": undefined,
77 | "extensions": undefined,
78 | "http": Object {
79 | "headers": Headers {
80 | Symbol(map): Object {},
81 | },
82 | },
83 | }
84 | `;
85 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/__utils.js:
--------------------------------------------------------------------------------
1 | const { HttpLink } = require('apollo-link-http');
2 | const fetch = require('node-fetch');
3 | const { execute, toPromise } = require('apollo-link');
4 |
5 | module.exports.toPromise = toPromise;
6 |
7 | const {
8 | dataSources,
9 | context: defaultContext,
10 | typeDefs,
11 | resolvers,
12 | ApolloServer,
13 | LaunchAPI,
14 | UserAPI,
15 | store,
16 | } = require('../');
17 |
18 | /**
19 | * Integration testing utils
20 | */
21 | const constructTestServer = ({ context = defaultContext } = {}) => {
22 | const userAPI = new UserAPI({ store });
23 | const launchAPI = new LaunchAPI();
24 |
25 | const server = new ApolloServer({
26 | typeDefs,
27 | resolvers,
28 | dataSources: () => ({ userAPI, launchAPI }),
29 | context,
30 | });
31 |
32 | return { server, userAPI, launchAPI };
33 | };
34 |
35 | module.exports.constructTestServer = constructTestServer;
36 |
37 | /**
38 | * e2e Testing Utils
39 | */
40 |
41 | const startTestServer = async server => {
42 | // if using apollo-server-express...
43 | // const app = express();
44 | // server.applyMiddleware({ app });
45 | // const httpServer = await app.listen(0);
46 |
47 | const httpServer = await server.listen({ port: 0 });
48 |
49 | const link = new HttpLink({
50 | uri: `http://localhost:${httpServer.port}`,
51 | fetch,
52 | });
53 |
54 | const executeOperation = ({ query, variables = {} }) =>
55 | execute(link, { query, variables });
56 |
57 | return {
58 | link,
59 | stop: () => httpServer.server.close(),
60 | graphql: executeOperation,
61 | };
62 | };
63 |
64 | module.exports.startTestServer = startTestServer;
65 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/e2e.js:
--------------------------------------------------------------------------------
1 | // import our production apollo-server instance
2 | const { server } = require('../');
3 | const gql = require('graphql-tag');
4 |
5 | const { startTestServer, toPromise } = require('./__utils');
6 |
7 | const LAUNCH_LIST_QUERY = gql`
8 | query myLaunches($pageSize: Int, $after: String) {
9 | launches(pageSize: $pageSize, after: $after) {
10 | cursor
11 | launches {
12 | mission {
13 | name
14 | missionPatch
15 | }
16 | }
17 | }
18 | }
19 | `;
20 |
21 | const GET_LAUNCH = gql`
22 | query launch($id: ID!) {
23 | launch(id: $id) {
24 | id
25 | isBooked
26 | rocket {
27 | type
28 | }
29 | mission {
30 | name
31 | }
32 | }
33 | }
34 | `;
35 |
36 | describe('Server - e2e', () => {
37 | let stop, graphql;
38 |
39 | beforeEach(async () => {
40 | const testServer = await startTestServer(server);
41 | stop = testServer.stop;
42 | graphql = testServer.graphql;
43 | });
44 |
45 | afterEach(() => stop());
46 |
47 | it('gets list of launches', async () => {
48 | const res = await toPromise(
49 | graphql({
50 | query: LAUNCH_LIST_QUERY,
51 | variables: { pageSize: 1, after: '1517949900' },
52 | }),
53 | );
54 |
55 | expect(res).toMatchSnapshot();
56 | });
57 |
58 | it('gets a single launch', async () => {
59 | const res = await toPromise(
60 | graphql({ query: GET_LAUNCH, variables: { id: 30 } }),
61 | );
62 |
63 | expect(res).toMatchSnapshot();
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/integration.js:
--------------------------------------------------------------------------------
1 | const { createTestClient } = require('apollo-server-testing');
2 | const gql = require('graphql-tag');
3 | const nock = require('nock');
4 |
5 | const { constructTestServer } = require('./__utils');
6 |
7 | // the mocked REST API data
8 | const { mockLaunchResponse } = require('../datasources/__tests__/launch');
9 | // the mocked SQL DataSource store
10 | const { mockStore } = require('../datasources/__tests__/user');
11 |
12 | const GET_LAUNCHES = gql`
13 | query launchList($after: String) {
14 | launches(after: $after) {
15 | cursor
16 | hasMore
17 | launches {
18 | id
19 | isBooked
20 | rocket {
21 | name
22 | }
23 | mission {
24 | name
25 | missionPatch
26 | }
27 | }
28 | }
29 | }
30 | `;
31 |
32 | const GET_LAUNCH = gql`
33 | query launch($id: ID!) {
34 | launch(id: $id) {
35 | id
36 | isBooked
37 | rocket {
38 | type
39 | }
40 | mission {
41 | name
42 | }
43 | }
44 | }
45 | `;
46 |
47 | const LOGIN = gql`
48 | mutation login($email: String!) {
49 | login(email: $email)
50 | }
51 | `;
52 |
53 | const BOOK_TRIPS = gql`
54 | mutation BookTrips($launchIds: [ID]!) {
55 | bookTrips(launchIds: $launchIds) {
56 | success
57 | message
58 | launches {
59 | id
60 | isBooked
61 | }
62 | }
63 | }
64 | `;
65 |
66 | describe('Queries', () => {
67 | it('fetches list of launches', async () => {
68 | // create an instance of ApolloServer that mocks out context, while reusing
69 | // existing dataSources, resolvers, and typeDefs.
70 | // This function returns the server instance as well as our dataSource
71 | // instances, so we can overwrite the underlying fetchers
72 | const { server, launchAPI, userAPI } = constructTestServer({
73 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
74 | });
75 |
76 | // mock the datasources' underlying fetch methods, whether that's a REST
77 | // lookup in the RESTDataSource or the store query in the Sequelize datasource
78 | launchAPI.get = jest.fn(() => [mockLaunchResponse]);
79 | userAPI.store = mockStore;
80 | userAPI.store.trips.findAll.mockReturnValueOnce([
81 | { dataValues: { launchId: 1 } },
82 | ]);
83 |
84 | // use our test server as input to the createTestClient fn
85 | // This will give us an interface, similar to apolloClient.query
86 | // to run queries against our instance of ApolloServer
87 | const { query } = createTestClient(server);
88 | const res = await query({ query: GET_LAUNCHES });
89 | expect(res).toMatchSnapshot();
90 | });
91 |
92 | it('fetches single launch', async () => {
93 | const { server, launchAPI, userAPI } = constructTestServer({
94 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
95 | });
96 |
97 | launchAPI.get = jest.fn(() => [mockLaunchResponse]);
98 | userAPI.store = mockStore;
99 | userAPI.store.trips.findAll.mockReturnValueOnce([
100 | { dataValues: { launchId: 1 } },
101 | ]);
102 |
103 | const { query } = createTestClient(server);
104 | const res = await query({ query: GET_LAUNCH, variables: { id: 1 } });
105 | expect(res).toMatchSnapshot();
106 | });
107 | });
108 |
109 | describe('Mutations', () => {
110 | it('returns login token', async () => {
111 | const { server, launchAPI, userAPI } = constructTestServer({
112 | context: () => {},
113 | });
114 |
115 | userAPI.store = mockStore;
116 | userAPI.store.users.findOrCreate.mockReturnValueOnce([
117 | { id: 1, email: 'a@a.a' },
118 | ]);
119 |
120 | const { mutate } = createTestClient(server);
121 | const res = await mutate({
122 | mutation: LOGIN,
123 | variables: { email: 'a@a.a' },
124 | });
125 | expect(res.data.login).toEqual('YUBhLmE=');
126 | });
127 |
128 | it('books trips', async () => {
129 | const { server, launchAPI, userAPI } = constructTestServer({
130 | context: () => ({ user: { id: 1, email: 'a@a.a' } }),
131 | });
132 |
133 | // mock the underlying fetches
134 | launchAPI.get = jest.fn();
135 |
136 | // look up the launches from the launch API
137 | launchAPI.get
138 | .mockReturnValueOnce([mockLaunchResponse])
139 | .mockReturnValueOnce([{ ...mockLaunchResponse, flight_number: 2 }]);
140 |
141 | // book the trip in the store
142 | userAPI.store = mockStore;
143 | userAPI.store.trips.findOrCreate
144 | .mockReturnValueOnce([{ get: () => ({ launchId: 1 }) }])
145 | .mockReturnValueOnce([{ get: () => ({ launchId: 2 }) }]);
146 |
147 | // check if user is booked
148 | userAPI.store.trips.findAll.mockReturnValue([{}]);
149 |
150 | const { mutate } = createTestClient(server);
151 | const res = await mutate({
152 | mutation: BOOK_TRIPS,
153 | variables: { launchIds: ['1', '2'] },
154 | });
155 | expect(res).toMatchSnapshot();
156 | });
157 | });
158 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/resolvers.mission.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | const mockMission = {
4 | name: 'foo',
5 | missionPatchLarge: 'LG',
6 | missionPatchSmall: 'SM',
7 | };
8 |
9 | describe('[Mission.missionPatch]', () => {
10 | it('chooses the right sized patch', () => {
11 | const { missionPatch } = resolvers.Mission;
12 |
13 | // default -- no arg passed
14 | const resDefault = missionPatch(mockMission);
15 | const resSmall = missionPatch(mockMission, { size: 'SMALL' });
16 | const resLarge = missionPatch(mockMission, { size: 'LARGE' });
17 |
18 | expect(resDefault).toEqual('LG');
19 | expect(resSmall).toEqual('SM');
20 | expect(resLarge).toEqual('LG');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/resolvers.mutation.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | const mockContext = {
4 | dataSources: {
5 | userAPI: {
6 | bookTrips: jest.fn(),
7 | cancelTrip: jest.fn(),
8 | findOrCreateUser: jest.fn(),
9 | },
10 | launchAPI: {
11 | getLaunchesByIds: jest.fn(),
12 | getLaunchById: jest.fn(),
13 | },
14 | },
15 | user: { id: 1, email: 'a@a.a' },
16 | };
17 |
18 | describe('[Mutation.bookTrips]', () => {
19 | const { bookTrips } = mockContext.dataSources.userAPI;
20 | const { getLaunchesByIds } = mockContext.dataSources.launchAPI;
21 |
22 | it('returns true if booking succeeds', async () => {
23 | bookTrips.mockReturnValueOnce([{ launchId: 999 }]);
24 | getLaunchesByIds.mockReturnValueOnce([{ id: 999, cursor: 'foo' }]);
25 |
26 | // check the resolver response
27 | const res = await resolvers.Mutation.bookTrips(
28 | null,
29 | { launchIds: [123] },
30 | mockContext,
31 | );
32 | expect(res).toEqual({
33 | launches: [{ cursor: 'foo', id: 999 }],
34 | message: 'trips booked successfully',
35 | success: true,
36 | });
37 |
38 | // check if the dataSource was called with correct args
39 | expect(bookTrips).toBeCalledWith({ launchIds: [123] });
40 | });
41 |
42 | it('returns false if booking fails', async () => {
43 | bookTrips.mockReturnValueOnce([]);
44 |
45 | // check the resolver response
46 | const res = await resolvers.Mutation.bookTrips(
47 | null,
48 | { launchIds: [123] },
49 | mockContext,
50 | );
51 |
52 | expect(res.message).toBeDefined();
53 | expect(res.success).toBeFalsy();
54 | });
55 | });
56 |
57 | describe('[Mutation.cancelTrip]', () => {
58 | const { cancelTrip } = mockContext.dataSources.userAPI;
59 | const { getLaunchById } = mockContext.dataSources.launchAPI;
60 |
61 | it('returns true if cancelling succeeds', async () => {
62 | cancelTrip.mockReturnValueOnce(true);
63 | getLaunchById.mockReturnValueOnce({ id: 999, cursor: 'foo' });
64 |
65 | // check the resolver response
66 | const res = await resolvers.Mutation.cancelTrip(
67 | null,
68 | { launchId: 123 },
69 | mockContext,
70 | );
71 | expect(res).toEqual({
72 | success: true,
73 | message: 'trip cancelled',
74 | launches: [{ id: 999, cursor: 'foo' }],
75 | });
76 |
77 | // check if the dataSource was called with correct args
78 | expect(cancelTrip).toBeCalledWith({ launchId: 123 });
79 | });
80 |
81 | it('returns false if cancelling fails', async () => {
82 | cancelTrip.mockReturnValueOnce(false);
83 |
84 | // check the resolver response
85 | const res = await resolvers.Mutation.cancelTrip(
86 | null,
87 | { launchId: 123 },
88 | mockContext,
89 | );
90 | expect(res.message).toBeDefined();
91 | expect(res.success).toBeFalsy();
92 | });
93 | });
94 |
95 | describe('[Mutation.login]', () => {
96 | const { findOrCreateUser } = mockContext.dataSources.userAPI;
97 |
98 | it('returns base64 encoded email if successful', async () => {
99 | const args = { email: 'a@a.a' };
100 | findOrCreateUser.mockReturnValueOnce(true);
101 | const base64Email = new Buffer(mockContext.user.email).toString('base64');
102 |
103 | // check the resolver response
104 | const res = await resolvers.Mutation.login(null, args, mockContext);
105 | expect(res).toEqual('YUBhLmE=');
106 |
107 | // check if the dataSource was called with correct args
108 | expect(findOrCreateUser).toBeCalledWith(args);
109 | });
110 |
111 | it('returns nothing if login fails', async () => {
112 | const args = { email: 'a@a.a' };
113 | // simulate failed lookup/creation
114 | findOrCreateUser.mockReturnValueOnce(false);
115 |
116 | // check the resolver response
117 | const res = await resolvers.Mutation.login(null, args, mockContext);
118 | expect(res).toBeFalsy();
119 | });
120 | });
121 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/resolvers.query.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | describe('[Query.launches]', () => {
4 | const mockContext = {
5 | dataSources: {
6 | launchAPI: { getAllLaunches: jest.fn() },
7 | },
8 | };
9 | // just for easy access
10 | const { getAllLaunches } = mockContext.dataSources.launchAPI;
11 |
12 | it('calls lookup from launch api', async () => {
13 | // NOTE: these results get reversed in the resolver
14 | getAllLaunches.mockReturnValueOnce([{ id: 999, cursor: 'foo' }]);
15 |
16 | // check the resolver response
17 | const res = await resolvers.Query.launches(null, {}, mockContext);
18 | expect(res).toEqual({
19 | cursor: 'foo',
20 | hasMore: false,
21 | launches: [{ id: 999, cursor: 'foo' }],
22 | });
23 | });
24 |
25 | it('respects pageSize arg', async () => {
26 | // NOTE: these results get reversed in the resolver
27 | getAllLaunches.mockReturnValue([
28 | { id: 1, cursor: 'foo' },
29 | { id: 999, cursor: 'bar' },
30 | ]);
31 |
32 | // check the resolver response
33 | const res = await resolvers.Query.launches(
34 | null,
35 | { pageSize: 1 },
36 | mockContext,
37 | );
38 | expect(res).toEqual({
39 | cursor: 'bar',
40 | hasMore: true,
41 | launches: [{ id: 999, cursor: 'bar' }],
42 | });
43 | });
44 |
45 | it('respects cursor arg', async () => {
46 | // NOTE: these results get reversed in the resolver
47 | getAllLaunches.mockReturnValueOnce([
48 | { id: 1, cursor: 'a' },
49 | { id: 999, cursor: 'b' },
50 | ]);
51 |
52 | // check the resolver response
53 | const res = await resolvers.Query.launches(
54 | null,
55 | { after: 'b' },
56 | mockContext,
57 | );
58 |
59 | expect(res).toEqual({
60 | hasMore: false,
61 | cursor: 'a',
62 | launches: [{ id: 1, cursor: 'a' }],
63 | });
64 | });
65 |
66 | it('respects both pageSize and cursor', async () => {
67 | // NOTE: these results get reversed in the resolver
68 | getAllLaunches.mockReturnValue([
69 | { id: 1, cursor: 'a' },
70 | { id: 999, cursor: 'b' },
71 | { id: 123, cursor: 'c' },
72 | ]);
73 |
74 | // check the resolver response
75 | const res = await resolvers.Query.launches(
76 | null,
77 | { after: 'c', pageSize: 1 },
78 | mockContext,
79 | );
80 |
81 | expect(res).toEqual({
82 | cursor: 'b',
83 | hasMore: true,
84 | launches: [{ id: 999, cursor: 'b' }],
85 | });
86 | });
87 | });
88 |
89 | describe('[Query.launch]', () => {
90 | const mockContext = {
91 | dataSources: {
92 | launchAPI: { getLaunchById: jest.fn() },
93 | },
94 | };
95 |
96 | it('calls lookup from launch api', async () => {
97 | const getLaunchById = mockContext.dataSources.launchAPI.getLaunchById;
98 | getLaunchById.mockReturnValueOnce({
99 | id: 999,
100 | });
101 |
102 | // check the resolver response
103 | const res = await resolvers.Query.launch(null, { id: 999 }, mockContext);
104 | expect(res).toEqual({ id: 999 });
105 |
106 | // make sure the dataSources were called properly
107 | expect(getLaunchById).toBeCalledWith({ launchId: 999 });
108 | });
109 | });
110 |
111 | describe('[Query.me]', () => {
112 | const mockContext = {
113 | dataSources: {
114 | userAPI: { findOrCreateUser: jest.fn() },
115 | },
116 | user: {},
117 | };
118 |
119 | it('returns null if no user in context', async () => {
120 | expect(await resolvers.Query.me(null, null, mockContext)).toBeFalsy();
121 | });
122 |
123 | it('returns user from userAPI', async () => {
124 | mockContext.user.email = 'a@a.a';
125 | const findOrCreateUser = mockContext.dataSources.userAPI.findOrCreateUser;
126 | findOrCreateUser.mockReturnValueOnce({ id: 999 });
127 |
128 | // check return value of resolver
129 | const res = await resolvers.Query.me(null, null, mockContext);
130 | expect(res).toEqual({
131 | id: 999,
132 | });
133 | });
134 | });
135 |
--------------------------------------------------------------------------------
/start/server/src/__tests__/resolvers.user.js:
--------------------------------------------------------------------------------
1 | const resolvers = require('../resolvers');
2 |
3 | describe('[User.trips]', () => {
4 | const mockContext = {
5 | dataSources: {
6 | userAPI: { getLaunchIdsByUser: jest.fn() },
7 | launchAPI: { getLaunchesByIds: jest.fn() },
8 | },
9 | user: { id: 1 },
10 | };
11 | const { getLaunchIdsByUser } = mockContext.dataSources.userAPI;
12 | const { getLaunchesByIds } = mockContext.dataSources.launchAPI;
13 |
14 | it('uses user id from context to lookup trips', async () => {
15 | getLaunchIdsByUser.mockReturnValueOnce([999]);
16 | getLaunchesByIds.mockReturnValueOnce([{ id: 999 }]);
17 |
18 | // check the resolver response
19 | const res = await resolvers.User.trips(null, null, mockContext);
20 | expect(res).toEqual([{ id: 999 }]);
21 |
22 | // make sure the dataSources were called properly
23 | expect(getLaunchIdsByUser).toBeCalled();
24 | expect(getLaunchesByIds).toBeCalledWith({ launchIds: [999] });
25 | });
26 |
27 | it('returns empty array if no response', async () => {
28 | getLaunchIdsByUser.mockReturnValueOnce([]);
29 | getLaunchesByIds.mockReturnValueOnce([]);
30 |
31 | // check the resolver response
32 | const res = await resolvers.User.trips(null, null, mockContext);
33 | expect(res).toEqual([]);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/start/server/src/datasources/__tests__/user.js:
--------------------------------------------------------------------------------
1 | const UserAPI = require('../user');
2 |
3 | const mockStore = {
4 | users: {
5 | findOrCreate: jest.fn(),
6 | findAll: jest.fn(),
7 | },
8 | trips: {
9 | findOrCreate: jest.fn(),
10 | destroy: jest.fn(),
11 | findAll: jest.fn(),
12 | },
13 | };
14 | module.exports.mockStore = mockStore;
15 |
16 | const ds = new UserAPI({ store: mockStore });
17 | ds.initialize({ context: { user: { id: 1, email: 'a@a.a' } } });
18 |
19 | describe('[UserAPI.findOrCreateUser]', () => {
20 | it('returns null for invalid emails', async () => {
21 | const res = await ds.findOrCreateUser({ email: 'boo!' });
22 | expect(res).toEqual(null);
23 | });
24 |
25 | it('looks up/creates user in store', async () => {
26 | mockStore.users.findOrCreate.mockReturnValueOnce([{ id: 1 }]);
27 |
28 | // check the result of the fn
29 | const res = await ds.findOrCreateUser({ email: 'a@a.a' });
30 | expect(res).toEqual({ id: 1 });
31 |
32 | // make sure store is called properly
33 | expect(mockStore.users.findOrCreate).toBeCalledWith({
34 | where: { email: 'a@a.a' },
35 | });
36 | });
37 |
38 | it('returns null if no user found/created', async () => {
39 | // store lookup is not mocked to return anything, so this
40 | // simulates a failed lookup
41 |
42 | const res = await ds.findOrCreateUser({ email: 'a@a.a' });
43 | expect(res).toEqual(null);
44 | });
45 | });
46 |
47 | describe('[UserAPI.bookTrip]', () => {
48 | it('calls store creator and returns result', async () => {
49 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'heya' }]);
50 |
51 | // check the result of the fn
52 | const res = await ds.bookTrip({ launchId: 1 });
53 | expect(res).toBeTruthy();
54 |
55 | // make sure store is called properly
56 | expect(mockStore.trips.findOrCreate).toBeCalledWith({
57 | where: { launchId: 1, userId: 1 },
58 | });
59 | });
60 | });
61 |
62 | describe('[UserAPI.bookTrips]', () => {
63 | it('returns multiple lookups from bookTrip', async () => {
64 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'heya' }]);
65 | mockStore.trips.findOrCreate.mockReturnValueOnce([{ get: () => 'okay' }]);
66 |
67 | const res = await ds.bookTrips({ launchIds: [1, 2] });
68 | expect(res).toEqual(['heya', 'okay']);
69 | });
70 | });
71 |
72 | describe('[UserAPI.cancelTrip]', () => {
73 | it('calls store destroy and returns result', async () => {
74 | const args = { userId: 1, launchId: 1 };
75 | mockStore.trips.destroy.mockReturnValueOnce('heya');
76 |
77 | // check the result of the fn
78 | const res = await ds.cancelTrip(args);
79 | expect(res).toEqual(true);
80 |
81 | // make sure store is called properly
82 | expect(mockStore.trips.destroy).toBeCalledWith({ where: args });
83 | });
84 | });
85 |
86 | describe('[UserAPI.getLaunchIdsByUser]', () => {
87 | it('looks up launches by user', async () => {
88 | const args = { userId: 1 };
89 | const launches = [
90 | { dataValues: { launchId: 1 } },
91 | { dataValues: { launchId: 2 } },
92 | ];
93 | mockStore.trips.findAll.mockReturnValueOnce(launches);
94 |
95 | // check the result of the fn
96 | const res = await ds.getLaunchIdsByUser(args);
97 | expect(res).toEqual([1, 2]);
98 |
99 | // make sure store is called properly
100 | expect(mockStore.trips.findAll).toBeCalledWith({ where: args });
101 | });
102 |
103 | it('returns empty array if nothing found', async () => {
104 | const args = { userId: 1 };
105 | // store lookup is not mocked to return anything, so this
106 | // simulates a failed lookup
107 |
108 | // check the result of the fn
109 | const res = await ds.getLaunchIdsByUser(args);
110 | expect(res).toEqual([]);
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/start/server/src/datasources/launch.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/server/src/datasources/launch.js
--------------------------------------------------------------------------------
/start/server/src/datasources/user.js:
--------------------------------------------------------------------------------
1 | const { DataSource } = require('apollo-datasource');
2 | const isEmail = require('isemail');
3 |
4 | class UserAPI extends DataSource {
5 | constructor({ store }) {
6 | super();
7 | this.store = store;
8 | }
9 |
10 | /**
11 | * This is a function that gets called by ApolloServer when being setup.
12 | * This function gets called with the datasource config including things
13 | * like caches and context. We'll assign this.context to the request context
14 | * here, so we can know about the user making requests
15 | */
16 | initialize(config) {
17 | this.context = config.context;
18 | }
19 |
20 | /**
21 | * User can be called with an argument that includes email, but it doesn't
22 | * have to be. If the user is already on the context, it will use that user
23 | * instead
24 | */
25 | async findOrCreateUser({ email: emailArg } = {}) {
26 | const email =
27 | this.context && this.context.user ? this.context.user.email : emailArg;
28 | if (!email || !isEmail.validate(email)) return null;
29 |
30 | const users = await this.store.users.findOrCreate({ where: { email } });
31 | return users && users[0] ? users[0] : null;
32 | }
33 |
34 | async bookTrips({ launchIds }) {
35 | const userId = this.context.user.id;
36 | if (!userId) return;
37 |
38 | let results = [];
39 |
40 | // for each launch id, try to book the trip and add it to the results array
41 | // if successful
42 | for (const launchId of launchIds) {
43 | const res = await this.bookTrip({ launchId });
44 | if (res) results.push(res);
45 | }
46 |
47 | return results;
48 | }
49 |
50 | async bookTrip({ launchId }) {
51 | const userId = this.context.user.id;
52 | const res = await this.store.trips.findOrCreate({
53 | where: { userId, launchId },
54 | });
55 | return res && res.length ? res[0].get() : false;
56 | }
57 |
58 | async cancelTrip({ launchId }) {
59 | const userId = this.context.user.id;
60 | return !!this.store.trips.destroy({ where: { userId, launchId } });
61 | }
62 |
63 | async getLaunchIdsByUser() {
64 | const userId = this.context.user.id;
65 | const found = await this.store.trips.findAll({
66 | where: { userId },
67 | });
68 | return found && found.length
69 | ? found.map(l => l.dataValues.launchId).filter(l => !!l)
70 | : [];
71 | }
72 |
73 | async isBookedOnLaunch({ launchId }) {
74 | if (!this.context || !this.context.user) return false;
75 | const userId = this.context.user.id;
76 | const found = await this.store.trips.findAll({
77 | where: { userId, launchId },
78 | });
79 | return found && found.length > 0;
80 | }
81 | }
82 |
83 | module.exports = UserAPI;
84 |
--------------------------------------------------------------------------------
/start/server/src/index.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/server/src/index.js
--------------------------------------------------------------------------------
/start/server/src/resolvers.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/server/src/resolvers.js
--------------------------------------------------------------------------------
/start/server/src/schema.js:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/server/src/schema.js
--------------------------------------------------------------------------------
/start/server/src/utils.js:
--------------------------------------------------------------------------------
1 | const SQL = require('sequelize');
2 |
3 | module.exports.paginateResults = ({
4 | after: cursor,
5 | pageSize = 20,
6 | results,
7 | // can pass in a function to calculate an item's cursor
8 | getCursor = () => null,
9 | }) => {
10 | if (pageSize < 1) return [];
11 |
12 | if (!cursor) return results.slice(0, pageSize);
13 | const cursorIndex = results.findIndex(item => {
14 | // if an item has a `cursor` on it, use that, otherwise try to generate one
15 | let itemCursor = item.cursor ? item.cursor : getCursor(item);
16 |
17 | // if there's still not a cursor, return false by default
18 | return itemCursor ? cursor === itemCursor : false;
19 | });
20 |
21 | return cursorIndex >= 0
22 | ? cursorIndex === results.length - 1 // don't let us overflow
23 | ? []
24 | : results.slice(
25 | cursorIndex + 1,
26 | Math.min(results.length, cursorIndex + 1 + pageSize),
27 | )
28 | : results.slice(0, pageSize);
29 | };
30 |
31 | module.exports.createStore = () => {
32 | const Op = SQL.Op;
33 | const operatorsAliases = {
34 | $in: Op.in,
35 | };
36 |
37 | const db = new SQL('database', 'username', 'password', {
38 | dialect: 'sqlite',
39 | storage: './store.sqlite',
40 | operatorsAliases,
41 | logging: false,
42 | });
43 |
44 | const users = db.define('user', {
45 | id: {
46 | type: SQL.INTEGER,
47 | primaryKey: true,
48 | autoIncrement: true,
49 | },
50 | createdAt: SQL.DATE,
51 | updatedAt: SQL.DATE,
52 | email: SQL.STRING,
53 | token: SQL.STRING,
54 | });
55 |
56 | const trips = db.define('trip', {
57 | id: {
58 | type: SQL.INTEGER,
59 | primaryKey: true,
60 | autoIncrement: true,
61 | },
62 | createdAt: SQL.DATE,
63 | updatedAt: SQL.DATE,
64 | launchId: SQL.INTEGER,
65 | userId: SQL.INTEGER,
66 | });
67 |
68 | return { users, trips };
69 | };
70 |
--------------------------------------------------------------------------------
/start/server/store.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GZ315200/fullstack_tutorial/afed967a6e28c7968219f3399fa9783fad6e7e99/start/server/store.sqlite
--------------------------------------------------------------------------------