From cea8b0d8a0a865065cba3d2d8ec078c19fcdd702 Mon Sep 17 00:00:00 2001 From: Jonathan Simon Date: Mon, 3 Dec 2018 13:21:30 -0800 Subject: [PATCH] Add PostgreSQL connection pooling sample. (#926) * Add Postgresql connection pooling sample. * Lint. * Address review comments. * Address additional review comments. * Rename cloudsql directory to cloud-sql. --- .kokoro/postgres-knex.cfg | 13 + cloud-sql/postgres/knex/README.md | 116 +++++++++ cloud-sql/postgres/knex/app.flexible.yaml | 30 +++ cloud-sql/postgres/knex/app.standard.yaml | 29 +++ cloud-sql/postgres/knex/createTable.js | 52 ++++ cloud-sql/postgres/knex/package.json | 66 +++++ cloud-sql/postgres/knex/server.js | 235 ++++++++++++++++++ .../postgres/knex/test/createTable.test.js | 131 ++++++++++ cloud-sql/postgres/knex/test/server.test.js | 124 +++++++++ cloud-sql/postgres/knex/views/index.pug | 64 +++++ 10 files changed, 860 insertions(+) create mode 100644 .kokoro/postgres-knex.cfg create mode 100644 cloud-sql/postgres/knex/README.md create mode 100644 cloud-sql/postgres/knex/app.flexible.yaml create mode 100644 cloud-sql/postgres/knex/app.standard.yaml create mode 100644 cloud-sql/postgres/knex/createTable.js create mode 100644 cloud-sql/postgres/knex/package.json create mode 100644 cloud-sql/postgres/knex/server.js create mode 100644 cloud-sql/postgres/knex/test/createTable.test.js create mode 100644 cloud-sql/postgres/knex/test/server.test.js create mode 100644 cloud-sql/postgres/knex/views/index.pug diff --git a/.kokoro/postgres-knex.cfg b/.kokoro/postgres-knex.cfg new file mode 100644 index 0000000000..2ddb5e6769 --- /dev/null +++ b/.kokoro/postgres-knex.cfg @@ -0,0 +1,13 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "cloudsql/postgres/knex" +} + +# Tell the trampoline which build file to use. +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-docs-samples/.kokoro/build.sh" +} diff --git a/cloud-sql/postgres/knex/README.md b/cloud-sql/postgres/knex/README.md new file mode 100644 index 0000000000..186ecec4cf --- /dev/null +++ b/cloud-sql/postgres/knex/README.md @@ -0,0 +1,116 @@ +# Connecting to Cloud SQL - PostgreSQL + +## Before you begin + +1. If you haven't already, set up a Node.js Development Environment by following the [Node.js setup guide](https://cloud.google.com/nodejs/docs/setup) and +[create a project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project). + +2. Create a Cloud SQL for PostgreSQL instance by following these +[instructions](https://cloud.google.com/sql/docs/postgres/create-instance). Note the instance name that you create, +and password that you specify for the default 'postgres' user. + + * If you don't want to use the default user to connect, [create a user](https://cloud.google.com/sql/docs/postgres/create-manage-users#creating). + +3. Create a database for your application by following these [instructions](https://cloud.google.com/sql/docs/postgres/create-manage-databases). Note the database name. + +4. Create a service account with the 'Cloud SQL Client' permissions by following these +[instructions](https://cloud.google.com/sql/docs/mysql/connect-external-app#4_if_required_by_your_authentication_method_create_a_service_account). +Download a JSON key to use to authenticate your connection. + + +5. Use the information noted in the previous steps: +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service/account/key.json +export CLOUD_SQL_CONNECTION_NAME='::' +export DB_USER='my-db-user' +export DB_PASS='my-db-pass' +export DB_NAME='my_db' +``` +Note: Saving credentials in environment variables is convenient, but not secure - consider a more +secure solution such as [Cloud KMS](https://cloud.google.com/kms/) to help keep secrets safe. + +## Running locally + +1. To run this application locally, download and install the `cloud_sql_proxy` by +following the instructions [here](https://cloud.google.com/sql/docs/postgres/sql-proxy#install). + + Once the proxy is ready, use the following command to start the proxy in the + background: + ```bash + ./cloud_sql_proxy -dir=/cloudsql -instances=$CLOUD_SQL_CONNECTION_NAME -credential_file=$GOOGLE_APPLICATION_CREDENTIALS + ``` + Note: Make sure to run the command under a user with write access in the + `/cloudsql` directory. This proxy will use this folder to create a unix socket + the application will use to connect to Cloud SQL. + +2. Next, install the Node.js packages necessary to run the app locally by running the following command: + + ``` + npm install + ``` + +3. Run `createTable.js` to create the database table the app requires and to ensure that the database is properly configured. +With the Cloud SQL proxy running, run the following command to create the sample app's table in your Cloud SQL PostgreSQL database: + + ``` + node createTable.js + ``` + +4. Run the sample app locally with the following command: + + ``` + npm start + ``` + +Navigate towards `http://127.0.0.1:8080` to verify your application is running correctly. + +## Deploy to Google App Engine Standard + +1. To allow your app to connect to your Cloud SQL instance when the app is deployed, add the user, password, database, and instance connection name variables from Cloud SQL to the related environment variables in the `app.standard.yaml` file. The deployed application will connect via unix sockets. + + ``` + env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_CONNECTION_NAME: :: + ``` + +2. To deploy to App Engine Standard, run the following command: + + ``` + gcloud app deploy app.standard.yaml + ``` + +3. To launch your browser and view the app at https://[YOUR_PROJECT_ID].appspot.com, run the following command: + + ``` + gcloud app browse + ``` + +## Deploy to Google App Engine Flexible + +1. Add the user, password, database, and instance connection name variables from Cloud SQL to the related environment variables in the `app.flexible.yaml` file. The deployed application will connect via unix sockets. + + ``` + env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_CONNECTION_NAME: :: + ``` + +2. To deploy to App Engine Node.js Flexible Environment, run the following command: + + ``` + gcloud app deploy app.flexible.yaml + ``` + +3. To launch your browser and view the app at https://[YOUR_PROJECT_ID].appspot.com, run the following command: + + ``` + gcloud app browse + ``` + diff --git a/cloud-sql/postgres/knex/app.flexible.yaml b/cloud-sql/postgres/knex/app.flexible.yaml new file mode 100644 index 0000000000..34a67953a1 --- /dev/null +++ b/cloud-sql/postgres/knex/app.flexible.yaml @@ -0,0 +1,30 @@ +# Copyright 2018, Google, Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +runtime: nodejs +env: flex + +# The following env variables may contain sensitive information that grants +# anyone access to your database. Do not add this file to your source control. +env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_INSTANCE_NAME: :: + +beta_settings: + # The connection name of your instance, available by using + # 'gcloud beta sql instances describe [INSTANCE_NAME]' or from + # the Instance details page in the Google Cloud Platform Console. + cloud_sql_instances: :: diff --git a/cloud-sql/postgres/knex/app.standard.yaml b/cloud-sql/postgres/knex/app.standard.yaml new file mode 100644 index 0000000000..b94459f6e3 --- /dev/null +++ b/cloud-sql/postgres/knex/app.standard.yaml @@ -0,0 +1,29 @@ +# Copyright 2018, Google, Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +runtime: nodejs8 + +# The following env variables may contain sensitive information that grants +# anyone access to your database. Do not add this file to your source control. +env_variables: + DB_USER: MY_DB_USER + DB_PASS: MY_DB_PASSWORD + DB_NAME: MY_DATABASE + # e.g. my-awesome-project:us-central1:my-cloud-sql-instance + CLOUD_SQL_INSTANCE_NAME: :: + +beta_settings: + # The connection name of your instance, available by using + # 'gcloud beta sql instances describe [INSTANCE_NAME]' or from + # the Instance details page in the Google Cloud Platform Console. + cloud_sql_instances: :: diff --git a/cloud-sql/postgres/knex/createTable.js b/cloud-sql/postgres/knex/createTable.js new file mode 100644 index 0000000000..cee4be5363 --- /dev/null +++ b/cloud-sql/postgres/knex/createTable.js @@ -0,0 +1,52 @@ +/** + * Copyright 2018 Google LLC. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Knex = require('knex'); +const prompt = require('prompt'); + +const FIELDS = ['user', 'password', 'database']; + +prompt.start(); + +// Prompt the user for connection details +prompt.get(FIELDS, (err, config) => { + if (err) { + console.error(err); + return; + } + + // Connect to the database + const knex = Knex({client: 'pg', connection: config}); + + // Create the "votes" table + knex.schema + .createTable('votes', table => { + table.bigIncrements('vote_id').notNull(); + table.timestamp('time_cast').notNull(); + table.specificType('candidate', 'CHAR(6) NOT NULL'); + }) + .then(() => { + console.log(`Successfully created 'votes' table.`); + return knex.destroy(); + }) + .catch(err => { + console.error(`Failed to create 'votes' table:`, err); + if (knex) { + knex.destroy(); + } + }); +}); diff --git a/cloud-sql/postgres/knex/package.json b/cloud-sql/postgres/knex/package.json new file mode 100644 index 0000000000..777d98e6f7 --- /dev/null +++ b/cloud-sql/postgres/knex/package.json @@ -0,0 +1,66 @@ +{ + "name": "appengine-cloudsql-postgres", + "description": "Node.js PostgreSQL sample for Cloud SQL on App Engine.", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "author": "Google Inc.", + "repository": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git" + }, + "engines": { + "node": ">=8" + }, + "scripts": { + "unit-test": "ava --verbose test/*.test.js", + "start-proxy": "! pgrep cloud_sql_proxy > /dev/null && cloud_sql_proxy -dir=/cloudsql -instances=$CLOUD_SQL_INSTANCE_NAME &", + "system-test": "repo-tools test app -- server.js", + "system-test-proxy": "npm run start-proxy; npm run system-test", + "all-test": "npm run unit-test && npm run system-test", + "test": "repo-tools test run --cmd npm -- run all-test" + }, + "dependencies": { + "async": "2.6.0", + "body-parser": "1.18.2", + "express": "4.16.2", + "knex": "0.14.4", + "pg": "7.4.1", + "prompt": "1.0.0", + "pug": "2.0.0-rc.4", + "@google-cloud/logging-winston": "^0.10.2", + "winston": "^3.1.0" + }, + "devDependencies": { + "@google-cloud/nodejs-repo-tools": "3.0.0", + "ava": "0.25.0", + "proxyquire": "^2.1.0", + "supertest": "^3.3.0", + "sinon": "^7.1.1" + }, + "cloud-repo-tools": { + "requiresKeyFile": true, + "requiresProjectId": true, + "test": { + "app": { + "requiredEnvVars": [ + "DB_USER", + "DB_PASS", + "DB_NAME", + "CLOUD_SQL_INSTANCE_NAME" + ], + "args": [ + "server.js" + ] + }, + "build": { + "requiredEnvVars": [ + "DB_USER", + "DB_PASS", + "DB_NAME", + "CLOUD_SQL_INSTANCE_NAME" + ] + } + } + } +} diff --git a/cloud-sql/postgres/knex/server.js b/cloud-sql/postgres/knex/server.js new file mode 100644 index 0000000000..52ec95fafd --- /dev/null +++ b/cloud-sql/postgres/knex/server.js @@ -0,0 +1,235 @@ +/** + * Copyright 2018 Google LLC. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Require process, so we can mock environment variables. +const process = require('process'); + +const express = require('express'); +const bodyParser = require('body-parser'); +const Knex = require('knex'); + +const app = express(); +app.set('view engine', 'pug'); +app.enable('trust proxy'); + +// Automatically parse request body as form data. +app.use(bodyParser.urlencoded({extended: false})); +app.use(bodyParser.json()); + +// Set Content-Type for all responses for these routes. +app.use((req, res, next) => { + res.set('Content-Type', 'text/html'); + next(); +}); + +// Create a Winston logger that streams to Stackdriver Logging. +const winston = require('winston'); +const {LoggingWinston} = require('@google-cloud/logging-winston'); +const loggingWinston = new LoggingWinston(); +const logger = winston.createLogger({ + level: 'info', + transports: [new winston.transports.Console(), loggingWinston], +}); + +// [START cloud_sql_postgres_connection_pool] +// Initialize Knex, a Node.js SQL query builder library with built-in connection pooling. +const knex = connect(); + +function connect() { + // Configure which instance and what database user to connect with. + // Remember - storing secrets in plaintext is potentially unsafe. Consider using + // something like https://cloud.google.com/kms/ to help keep secrets secret. + const config = { + user: process.env.DB_USER, // e.g. 'my-user' + password: process.env.DB_PASS, // e.g. 'my-user-password' + database: process.env.DB_NAME, // e.g. 'my-database' + }; + + config.host = `/cloudsql/${process.env.CLOUD_SQL_CONNECTION_NAME}`; + + // Establish a connection to the database + const knex = Knex({ + client: 'pg', + connection: config, + }); + + // ... Specify additional properties here. + // [START_EXCLUDE] + + // [START cloud_sql_limit_connections] + // 'max' limits the total number of concurrent connections this pool will keep. Ideal + // values for this setting are highly variable on app design, infrastructure, and database. + knex.client.pool.max = 5; + // 'min' is the minimum number of idle connections Knex maintains in the pool. + // Additional connections will be established to meet this value unless the pool is full. + knex.client.pool.min = 5; + // [END cloud_sql_limit_connections] + // [START cloud_sql_connection_timeout] + // 'acquireTimeoutMillis' is the maximum number of milliseconds to wait for a connection checkout. + // Any attempt to retrieve a connection from this pool that exceeds the set limit will throw an + // SQLException. + knex.client.pool.createTimeoutMillis = 30000; // 30 seconds + // 'idleTimeoutMillis' is the maximum amount of time a connection can sit in the pool. Connections that + // sit idle for this many milliseconds are retried if idleTimeoutMillis is exceeded. + knex.client.pool.idleTimeoutMillis = 600000; // 10 minutes + // [END cloud_sql_connection_timeout] + // [START cloud_sql_connection_backoff] + // 'createRetryIntervalMillis' is how long to idle after failed connection creation before trying again + knex.client.pool.createRetryIntervalMillis = 200; // 0.2 seconds + // [END cloud_sql_connection_backoff] + // [START cloud_sql_connection_lifetime] + // 'acquireTimeoutMillis' is the maximum possible lifetime of a connection in the pool. Connections that + // live longer than this many milliseconds will be closed and reestablished between uses. This + // value should be several minutes shorter than the database's timeout value to avoid unexpected + // terminations. + knex.client.pool.acquireTimeoutMillis = 600000; // 10 minutes + // [START cloud_sql_connection_lifetime] + + // [END_EXCLUDE] + return knex; + // [END cloud_sql_postgres_connection_pool] +} + +// [START cloud_sql_example_statement] +/** + * Insert a vote record into the database. + * + * @param {object} knex The Knex connection object. + * @param {object} vote The vote record to insert. + * @returns {Promise} + */ +async function insertVote(knex, vote) { + try { + return await knex('votes').insert(vote); + } catch (err) { + throw Error(err); + } +} +// [END cloud_sql_example_statement] + +/** + * Retrieve the latest 5 vote records from the database. + * + * @param {object} knex The Knex connection object. + * @returns {Promise} + */ +async function getVotes(knex) { + return await knex + .select('candidate', 'time_cast') + .from('votes') + .orderBy('time_cast', 'desc') + .limit(5); +} + +/** + * Retrieve the total count of records for a given candidate + * from the database. + * + * @param {object} knex The Knex connection object. + * @param {object} candidate The candidate for which to get the total vote count + * @returns {Promise} + */ +async function getVoteCount(knex, candidate) { + return await knex('votes') + .count('vote_id') + .where('candidate', candidate); +} + +app.get('/', (req, res) => { + (async function() { + // Query the total count of "TABS" from the database. + let tabsResult = await getVoteCount(knex, 'TABS'); + let tabsTotalVotes = parseInt(tabsResult[0].count); + // Query the total count of "SPACES" from the database. + let spacesResult = await getVoteCount(knex, 'SPACES'); + let spacesTotalVotes = parseInt(spacesResult[0].count); + // Query the last 5 votes from the database. + let votes = await getVotes(knex); + // Calculate and set leader values. + let leadTeam = ''; + let voteDiff = 0; + let leaderMessage = ''; + if (tabsTotalVotes !== spacesTotalVotes) { + if (tabsTotalVotes > spacesTotalVotes) { + leadTeam = 'TABS'; + voteDiff = tabsTotalVotes - spacesTotalVotes; + } else { + leadTeam = 'SPACES'; + voteDiff = spacesTotalVotes - tabsTotalVotes; + } + leaderMessage = `${leadTeam} are winning by ${voteDiff} vote${ + voteDiff > 1 ? 's' : '' + }.`; + } else { + leaderMessage = 'TABS and SPACES are evenly matched!'; + } + res.render('index.pug', { + votes: votes, + tabsCount: tabsTotalVotes, + spacesCount: spacesTotalVotes, + leadTeam: leadTeam, + voteDiff: voteDiff, + leaderMessage: leaderMessage, + }); + })(); +}); + +app.post('/', (req, res) => { + // [START cloud_sql_example_statement] + // Get the team from the request and record the time of the vote. + const team = req.body.team; + const timestamp = new Date(); + + if (!team || (team !== 'TABS' && team !== 'SPACES')) { + res + .status(400) + .send('Invalid team specified.') + .end(); + } + + // Create a vote record to be stored in the database. + const vote = { + candidate: team, + time_cast: timestamp, + }; + + // Save the data to the database. + insertVote(knex, vote) + // [END cloud_sql_example_statement] + .catch(err => { + logger.error('Error while attempting to submit vote. Error:' + err); + let msg = 'Unable to successfully cast vote!'; + msg += 'Please check the application logs for more details.'; + res + .status(500) + .send(msg) + .end(); + }); + let msg = 'Successfully voted for ' + team + ' at ' + timestamp; + res + .status(200) + .send(msg) + .end(); +}); + +const PORT = process.env.PORT || 8080; +app.listen(PORT, () => { + console.log(`App listening on port ${PORT}`); + console.log('Press Ctrl+C to quit.'); +}); + +module.exports = app; diff --git a/cloud-sql/postgres/knex/test/createTable.test.js b/cloud-sql/postgres/knex/test/createTable.test.js new file mode 100644 index 0000000000..2cfda1654f --- /dev/null +++ b/cloud-sql/postgres/knex/test/createTable.test.js @@ -0,0 +1,131 @@ +/** + * Copyright 2018 Google LLC. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const test = require(`ava`); +const path = require(`path`); +const proxyquire = require(`proxyquire`).noPreserveCache(); +const sinon = require(`sinon`); +const tools = require(`@google-cloud/nodejs-repo-tools`); + +const SAMPLE_PATH = path.join(__dirname, `../createTables.js`); + +const exampleConfig = [`user`, `password`, `database`]; + +function getSample() { + const configMock = exampleConfig; + const promptMock = { + start: sinon.stub(), + get: sinon.stub().yields(null, configMock), + }; + const tableMock = { + increments: sinon.stub(), + timestamp: sinon.stub(), + string: sinon.stub(), + }; + const knexMock = { + schema: { + createTable: sinon.stub(), + }, + destroy: sinon.stub().returns(Promise.resolve()), + }; + + knexMock.schema.createTable + .returns(Promise.resolve(knexMock)) + .yields(tableMock); + const KnexMock = sinon.stub().returns(knexMock); + + return { + mocks: { + Knex: KnexMock, + knex: knexMock, + config: configMock, + prompt: promptMock, + }, + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test.cb.serial(`should create a table`, t => { + const sample = getSample(); + const expectedResult = `Successfully created 'votes' table.`; + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt, + }); + + t.true(sample.mocks.prompt.start.calledOnce); + t.true(sample.mocks.prompt.get.calledOnce); + t.deepEqual(sample.mocks.prompt.get.firstCall.args[0], exampleConfig); + + setTimeout(() => { + t.true(sample.mocks.Knex.calledOnce); + t.deepEqual(sample.mocks.Knex.firstCall.args, [ + { + client: 'pg', + connection: exampleConfig, + }, + ]); + + t.true(sample.mocks.knex.schema.createTable.calledOnce); + t.is(sample.mocks.knex.schema.createTable.firstCall.args[0], 'votes'); + + t.true(console.log.calledWith(expectedResult)); + t.true(sample.mocks.knex.destroy.calledOnce); + t.end(); + }, 10); +}); + +test.cb.serial(`should handle prompt error`, t => { + const error = new Error(`error`); + const sample = getSample(); + sample.mocks.prompt.get = sinon.stub().yields(error); + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt, + }); + + setTimeout(() => { + t.true(console.error.calledOnce); + t.true(console.error.calledWith(error)); + t.true(sample.mocks.Knex.notCalled); + t.end(); + }, 10); +}); + +test.cb.serial(`should handle knex creation error`, t => { + const error = new Error(`error`); + const sample = getSample(); + sample.mocks.knex.schema.createTable = sinon + .stub() + .returns(Promise.reject(error)); + + proxyquire(SAMPLE_PATH, { + knex: sample.mocks.Knex, + prompt: sample.mocks.prompt, + }); + + setTimeout(() => { + t.true(console.error.calledOnce); + t.true(console.error.calledWith(`Failed to create 'votes' table:`, error)); + t.true(sample.mocks.knex.destroy.calledOnce); + t.end(); + }, 10); +}); diff --git a/cloud-sql/postgres/knex/test/server.test.js b/cloud-sql/postgres/knex/test/server.test.js new file mode 100644 index 0000000000..ea2dfa8e4e --- /dev/null +++ b/cloud-sql/postgres/knex/test/server.test.js @@ -0,0 +1,124 @@ +/** + * Copyright 2018 Google LLC. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const express = require(`express`); +const path = require(`path`); +const proxyquire = require(`proxyquire`).noCallThru(); +const request = require(`supertest`); +const sinon = require(`sinon`); +const test = require(`ava`); +const tools = require(`@google-cloud/nodejs-repo-tools`); + +const SAMPLE_PATH = path.join(__dirname, `../server.js`); + +function getSample() { + const testApp = express(); + sinon.stub(testApp, `listen`).yields(); + const expressMock = sinon.stub().returns(testApp); + const timestamp = new Date(); + const resultsMock = [ + { + candidate: 'TABS', + time_cast: timestamp, + }, + ]; + + const knexMock = sinon.stub().returns({ + insert: sinon.stub().returns(Promise.resolve()), + }); + Object.assign(knexMock, { + select: sinon.stub().returnsThis(), + from: sinon.stub().returnsThis(), + orderBy: sinon.stub().returnsThis(), + limit: sinon.stub().returns(Promise.resolve(resultsMock)), + }); + + const KnexMock = sinon.stub().returns(knexMock); + + const processMock = { + env: { + DB_USER: 'user', + DB_PASS: 'password', + DB_NAME: 'database', + }, + }; + + const app = proxyquire(SAMPLE_PATH, { + knex: KnexMock, + express: expressMock, + process: processMock, + }); + + return { + app: app, + mocks: { + express: expressMock, + results: resultsMock, + knex: knexMock, + Knex: KnexMock, + process: processMock, + }, + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test(`should set up sample in Postgres`, t => { + const sample = getSample(); + + t.true(sample.mocks.express.calledOnce); + t.true(sample.mocks.Knex.calledOnce); + t.deepEqual(sample.mocks.Knex.firstCall.args, [ + { + client: 'pg', + connection: { + user: sample.mocks.process.env.DB_USER, + password: sample.mocks.process.env.DB_PASS, + database: sample.mocks.process.env.DB_NAME, + }, + }, + ]); +}); + +test.cb(`should display the default page`, t => { + const sample = getSample(); + const expectedResult = `Tabs VS Spaces`; + + request(sample.app) + .get(`/`) + .expect(200) + .expect(response => { + t.is(response.text, expectedResult); + }) + .end(t.end); +}); + +test.cb(`should handle insert error`, t => { + const sample = getSample(); + const expectedResult = 'Invalid team specified'; + + sample.mocks.knex.limit.returns(Promise.reject()); + + request(sample.app) + .post(`/`) + .expect(400) + .expect(response => { + t.is(response.text.includes(expectedResult), true); + }) + .end(t.end); +}); diff --git a/cloud-sql/postgres/knex/views/index.pug b/cloud-sql/postgres/knex/views/index.pug new file mode 100644 index 0000000000..e20d33ee6b --- /dev/null +++ b/cloud-sql/postgres/knex/views/index.pug @@ -0,0 +1,64 @@ + +doctype html +html(lang="en") + head + title Tabs VS Spaces + + link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css") + link(rel="stylesheet", href="https://fonts.googleapis.com/icon?family=Material+Icons") + script(src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") + body + + nav(class="red lighten-1") + div(class="nav-wrapper") + a(href="#" class="brand-logo center") Tabs VS Spaces + + div(class="section") + + div(class="center") + h4 #{leaderMessage} + + div(class="row center") + div(class="col s6 m5 offset-m1") + div(class=(leadTeam === 'TABS') ? 'card-panel green lighten-3' : 'card-panel') + i(class="material-icons large") keyboard_tab + h3 #{tabsCount} votes + button(id="voteTabs" class="btn green") Vote for TABS + div(class="col s6 m5") + div(class=(leadTeam === 'SPACES') ? 'card-panel green lighten-3' : 'card-panel') + i(class="material-icons large") space_bar + h3 #{spacesCount} votes + button(id="voteSpaces" class="btn blue") Vote for SPACES + + h4(class="header center") Recent Votes + ul(class="container collection center") + each vote in votes + li(class="collection-item avatar") + if vote.candidate.trim() === 'TABS' + i(class="material-icons circle green") keyboard_tab + else + i(class="material-icons circle blue") space_bar + span(class="title") A vote for #{vote.candidate} + p was cast at #{vote.time_cast}. + + script. + function vote(team) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + var msg = ""; + if (this.readyState == 4) { + if (!window.alert(this.responseText)) { + window.location.reload(); + } + } + }; + xhr.open("POST", "/", true); + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); + xhr.send("team=" + team); + } + document.getElementById("voteTabs").addEventListener("click", function () { + vote("TABS"); + }); + document.getElementById("voteSpaces").addEventListener("click", function () { + vote("SPACES"); + }); \ No newline at end of file