Webサービスのe2eテスト　 〜メール認証編〜

## はじめに
この記事は [Goodpatch Advent Calendar 2019](https://qiita.com/advent-calendar/2019/goodpatch) の22日目です．

私が現在担当しているWebサービスの開発において、Puppeteerを用いたe2eテストを用いてQAの効率化を図っています。
この記事では Node.js と Gmail API を使い、アカウント作成時のメール認証を自動化する方法について共有したいと思います。

注：この記事ではPuppeteerには触れません! 

<!-- 

## アカウント登録からテストしたい

-->

## 環境準備

メールをNode.jsで取得するためには、Gmail APIの設定と、各種ファイルの取得・生成が必要です。
基本的には[Node.js Quickstart](https://developers.google.com/gmail/api/quickstart/nodejs)に従って作業します。

### フォルダの準備
あらかじめ、各種ファイルを保存するフォルダの準備しておきます。

例として、以下のような構造にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/14fbd267-9156-8998-0b4d-1b9fb7f927ef.png)

### Gmail Credentialの取得と配置

あらかじめ、利用するGmailのアカウントでログインしておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/9a3a29e6-2fad-8321-8dd9-7ce38c5586f8.png)

ログイン後、以下のページを開きます
[Node.js Quickstart | Gmail API](https://developers.google.com/gmail/api/quickstart/nodejs)

ボタンを押して、APIをEnableにします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/bb4a22e8-8b18-0323-6fae-9501b0a84ed2.png)

モーダルが表示されるので、ボタンを押して`credensial.json`をダウンロードし、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/8ef70c3f-5b74-6781-705c-0baef8c9db95.png)

`e2e/env` フォルダに保存します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/48771be9-9e69-e34e-8770-16fc89d73388.png)


### トークンの取得

[Node.js Quickstart | Gmail API](https://developers.google.com/gmail/api/quickstart/nodejs) の Step2 に従って、以下をインストールします。

```bash
$ npm install googleapis@39 --save
```

[Node.js Quickstart | Gmail API](https://developers.google.com/gmail/api/quickstart/nodejs)
中のStep 3 のコードをコピーし、 `e2e/scripts/get-token.js` と名付けて保存します。

フォルダ構造を合わせるため、`credentials.js`のファイルパスと、トークンの出力先パス `TOKEN_PATH`を以下のように修正します。

```js
const TOKEN_PATH = __dirname + '/../env/token.json';

// Load client secrets from a local file.
fs.readFile(__dirname + '/../env/credentials.json', (err, content) => {
```

保存後、以下のコマンドを実行します。

```bash
$ node e2e/scripts/get-token
```

すると、以下ようにURLがあらわれるので、言われた通りこのページを開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/5f0dd62a-2a99-d156-7550-978ca4175f58.png)

ログインするアカウントを選択すると、以下の画面がでて来るので、`詳細を表示`し `Quickstartに移動`します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/bbeaba50-561a-cfe9-f8f2-cd7f12a3894d.png)

権限の付与を許可します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/8ea8266d-0ff4-6658-7dd2-f7b1167828f9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/4268ea18-3d32-dd34-efcd-b2929e70b038.png)

コードが表示されるので、コピーし、 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/2aa08fa4-888c-c65e-1cee-76ff6d9d7e66.png)

ターミナルに戻って `Enter the code from that page here: ` のあとに貼り付け、Enterします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/319527/7c155120-ee99-e674-4d8d-270cfcb769aa.png)


すると、Gmailで利用しているラベルの一覧が表示され、`e2e/env` フォルダに token.jsファイルが生成されます。

これで、Node.jsからGmailを利用する準備は完了です。


## Gmailから目的のメールを取得する

準備が長かった気がしますが、ここからが本番です。

数多くのメールの中から、認証リンクを含んだメールを探し、本文からリンク取得します。

### 取得すべきメール
取得すべきメールは以下のようなものです。
APIのフィルタ機能と、本文への正規表現を用いた検索を使ってこのメールを探します。

- 未読状態である。
- Webサービスのメール送信用アドレスから送信されている。  
(ここでは`hoge@piyo.jp`とします)
- 登録したメールアドレスに送信されている。  
(登録に利用したメールアドレスを引数として渡す)
- 本文に認証リンクを含む。  
(ここでは `https://hoge.piyo.jp/mail/XXXXXX` のフォーマットであるとします)

### 環境準備

追加で以下のpackageをインストールします。

```bash
$ npm i google-auth-library -s
```

### 最終的なコード

先に最終的なコードを貼っておきます。
以下で説明していきます。

```js
const fs = require('fs')
const { promisify } = require('util')
const { google } = require('googleapis')
const { OAuth2Client } = require('google-auth-library')
const gmail = google.gmail('v1')
const TOKEN_PATH = __dirname + '/../env/token.json'
const SECRET_PATH = __dirname + '/../env/credentials.json'
const sleep = msec => new Promise(resolve => setTimeout(resolve, msec))
const MAX_RETRY = 10

//Promise 化
const readFileAsync = promisify(fs.readFile)

const getMessageList = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.list(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
const getMessage = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.get(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}

const getRegisterToken = async ({ email }) => {
  //クレデンシャル情報の取得
  const content = await readFileAsync(SECRET_PATH) //クライアントシークレットのファイルを指定
  const credentials = JSON.parse(content) //クレデンシャル

  //認証
  const clientSecret = credentials.installed.client_secret
  const clientId = credentials.installed.client_id
  const redirectUrl = credentials.installed.redirect_uris[0]
  const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUrl)
  const token = await readFileAsync(TOKEN_PATH)
  oauth2Client.credentials = JSON.parse(token)

  //API経由でシートにアクセス
  try {
    const getToken = async () => {
      // メッセージリスト取得
      const data = await getMessageList({
        auth: oauth2Client,
        userId: 'me',
        q: `is:unread from:piyo@hoge.jp to:${email}`,
      })
      if (!data.messages || data.messages.length === 0) {
        console.log('no message')
        return
      }
      const message = await getMessage({
        auth: oauth2Client,
        userId: 'me',
        id: data.messages[0].id,
      })
      const text = Buffer.from(
        message.payload.parts[1].body.data,
        'base64'
      ).toString('utf8')
      const regex = new RegExp(
        /https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/
      )
      const result = text.match(regex)
      if (!result) {
        console.log('not matched')
        return
      }
      console.log('matched', result[1])
      return result[1]
    }

    let retry = MAX_RETRY
    let token = null
    while (retry > 0) {
      token = await getToken()
      if (token) {
        break
      }
      retry--
      await sleep(10000)
    }
    console.log('token', token)
    return token
  } catch (err) {
    return ''
  }
}

module.exports = { getRegisterToken }

```

### Promise化

async/awaitで書きたいので、各種関数をPromiseでラップします。
`promisify`が使えるものについては、promisifyを使い、そうでないものはベタに書いていきます。

```js
//promisifyでプロミス化
const readFileAsync = promisify(fs.readFile)

const getMessageList = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.list(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
const getMessage = params => {
  return new Promise((resolve, reject) => {
    gmail.users.messages.get(params, (error, response) => {
      if (error) {
        reject(error)
        return
      }
      resolve(response.data)
    })
  })
}
```

### 認証データの準備

認証データをファイルから読み出し、OAuth2の認証クライアントを生成します。

```js
  //クレデンシャル情報の取得
  const content = await readFileAsync(SECRET_PATH) //クライアントシークレットのファイルを指定
  const credentials = JSON.parse(content) //クレデンシャル

  //認証
  const clientSecret = credentials.installed.client_secret
  const clientId = credentials.installed.client_id
  const redirectUrl = credentials.installed.redirect_uris[0]
  const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUrl)
  const token = await readFileAsync(TOKEN_PATH)
  oauth2Client.credentials = JSON.parse(token)
```

### メッセージリストの取得
メッセージリストを検索クエリを付与してフィルタリングし、取得します。


| 指定 | 意味 |
|:--|:--|
| is:unread | 未読 |
| from:hoge@piyo.jp | hoge@piyo.jp から送信されている |
| to:${email} | ${email} へ送信されている |

```js
      // メッセージリスト取得
      const data = await getMessageList({
        auth: oauth2Client,
        userId: 'me',
        q: `is:unread from:piyo@hoge.jp to:${email}`,
      })
```

### メール本文の取得

メール本文を取得します。
ここでは、メッセージリストで複数候補があっても1つ目のメールのみを取得しています。

```js
      const message = await getMessage({
        auth: oauth2Client,
        userId: 'me',
        id: data.messages[0].id, //1つ目のメールを指定
      })
```

### デコード

Gmailでは本文は、UTF-8の文字列バイトデータがBase64エンコードされたものになっています。
そのため、Base64からバイト配列に変換し、UTF-8に変換、といったデコードが必要です。

```js
      const text = Buffer.from(
        message.payload.parts[1].body.data,
        'base64'
      ).toString('utf8')
```

### 文字列の抽出
ここまできたら正規表現で文字列を検索・抽出してあげるだけです!

```js
      const regex = new RegExp(
        /https:\/\/hoge\.piyo\.jp\/mail\/([A-Za-z0-9]+)/
      )
      const result = text.match(regex)

```

### リトライ
メールが直に送信されるとは限らないので、リトライの仕組みを入れています。
ここでは、10秒毎に最大10回リトライするようにしています。

```js 
    let retry = MAX_RETRY
    let token = null
    while (retry > 0) {
      token = await getToken()
      if (token) {
        break
      }
      retry--
      await sleep(10000)
    }
```


## 最後に

まだ追記するべき点がありますので、後ほど更新します!

時間切れにてここまで。何かのお役に立ちますように〜〜〜!