From db2847a6e73089afc9284a5862ab787bef65c382 Mon Sep 17 00:00:00 2001 From: Michael Herman Date: Wed, 16 Jan 2019 06:57:46 -0700 Subject: [PATCH] part 7 wip --- .travis.yml | 7 +- cypress/integration/exercises.spec.js | 50 +++- docker-compose-dev.yml | 32 +++ docker-compose-prod.yml | 29 +++ docker-compose-stage.yml | 29 +++ docker-push.sh | 10 + init_db.sh | 10 + services/client/Dockerfile-prod | 2 + services/client/Dockerfile-stage | 2 + services/client/package.json | 1 + services/client/src/components/AddUser.jsx | 8 + services/client/src/components/Exercise.jsx | 87 +++++++ services/client/src/components/Exercises.jsx | 199 ++++++++++----- services/client/src/components/Logout.jsx | 5 + services/client/src/components/Message.jsx | 7 + services/client/src/components/NavBar.jsx | 6 + services/client/src/components/UserStatus.jsx | 6 +- services/client/src/components/UsersList.jsx | 7 +- .../src/components/__tests__/AddUser.test.jsx | 21 +- .../components/__tests__/Exercise.test.jsx | 51 ++++ .../components/__tests__/Exercises.test.jsx | 10 +- .../src/components/__tests__/Form.test.jsx | 46 ++-- .../src/components/__tests__/Logout.test.jsx | 13 + .../src/components/__tests__/Message.test.jsx | 25 +- .../src/components/__tests__/NavBar.test.jsx | 23 +- .../components/__tests__/UsersList.test.jsx | 36 ++- .../__snapshots__/Exercise.test.jsx.snap | 36 +++ .../__snapshots__/Exercises.test.jsx.snap | 1 - services/client/src/components/forms/Form.jsx | 12 +- services/exercises/manage.py | 6 +- services/lambda/handler.py | 10 +- services/nginx/dev.conf | 12 + services/nginx/prod.conf | 12 + services/scores/Dockerfile-dev | 24 ++ services/scores/Dockerfile-prod | 20 ++ services/scores/Dockerfile-stage | 20 ++ services/scores/entrypoint-stage.sh | 13 + services/scores/entrypoint.sh | 11 + services/scores/manage.py | 85 +++++++ services/scores/project/__init__.py | 49 ++++ services/scores/project/api/__init__.py | 1 + services/scores/project/api/base.py | 18 ++ services/scores/project/api/models.py | 25 ++ services/scores/project/api/scores.py | 139 ++++++++++ services/scores/project/api/utils.py | 51 ++++ services/scores/project/config.py | 37 +++ services/scores/project/db/Dockerfile | 5 + services/scores/project/db/create.sql | 4 + services/scores/project/tests/__init__.py | 1 + services/scores/project/tests/base.py | 22 ++ services/scores/project/tests/test_base.py | 28 ++ services/scores/project/tests/test_config.py | 42 +++ .../scores/project/tests/test_scores_api.py | 240 ++++++++++++++++++ .../scores/project/tests/test_scores_model.py | 14 + services/scores/project/tests/utils.py | 16 ++ services/scores/requirements.txt | 11 + services/swagger/swagger.json | 2 +- test-ci.sh | 4 + test.sh | 8 + 59 files changed, 1575 insertions(+), 126 deletions(-) create mode 100644 init_db.sh create mode 100644 services/client/src/components/Exercise.jsx create mode 100644 services/client/src/components/__tests__/Exercise.test.jsx create mode 100644 services/client/src/components/__tests__/__snapshots__/Exercise.test.jsx.snap create mode 100755 services/scores/Dockerfile-dev create mode 100755 services/scores/Dockerfile-prod create mode 100755 services/scores/Dockerfile-stage create mode 100755 services/scores/entrypoint-stage.sh create mode 100755 services/scores/entrypoint.sh create mode 100755 services/scores/manage.py create mode 100755 services/scores/project/__init__.py create mode 100755 services/scores/project/api/__init__.py create mode 100755 services/scores/project/api/base.py create mode 100755 services/scores/project/api/models.py create mode 100755 services/scores/project/api/scores.py create mode 100755 services/scores/project/api/utils.py create mode 100755 services/scores/project/config.py create mode 100755 services/scores/project/db/Dockerfile create mode 100755 services/scores/project/db/create.sql create mode 100755 services/scores/project/tests/__init__.py create mode 100755 services/scores/project/tests/base.py create mode 100755 services/scores/project/tests/test_base.py create mode 100755 services/scores/project/tests/test_config.py create mode 100755 services/scores/project/tests/test_scores_api.py create mode 100755 services/scores/project/tests/test_scores_model.py create mode 100755 services/scores/project/tests/utils.py create mode 100755 services/scores/requirements.txt diff --git a/.travis.yml b/.travis.yml index da198c2..711d580 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,10 @@ env: EXERCISES_REPO: ${MAIN_REPO}#${TRAVIS_BRANCH}:services/exercises EXERCISES_DB: test-driven-exercises_db EXERCISES_DB_REPO: ${MAIN_REPO}#${TRAVIS_BRANCH}:services/exercises/project/db + SCORES: test-driven-scores + SCORES_REPO: ${MAIN_REPO}#${TRAVIS_BRANCH}:services/scores + SCORES_DB: test-driven-scores_db + SCORES_DB_REPO: ${MAIN_REPO}#${TRAVIS_BRANCH}:services/scores/project/db SECRET_KEY: my_precious before_install: @@ -30,7 +34,8 @@ before_install: before_script: - export REACT_APP_USERS_SERVICE_URL=http://127.0.0.1 - export REACT_APP_EXERCISES_SERVICE_URL=http://127.0.0.1 - - export REACT_APP_API_GATEWAY_URL=https://a6tlc4juke.execute-api.us-west-1.amazonaws.com/v1/execute + - export REACT_APP_SCORES_SERVICE_URL=http://127.0.0.1 + - export REACT_APP_API_GATEWAY_URL=https://a6tlc4juke.execute-api.us-west-1.amazonaws.com/v2/execute - if [[ "$TRAVIS_BRANCH" == "staging" ]]; then export LOAD_BALANCER_DNS_NAME=http://testdriven-staging-alb-2001734548.us-west-1.elb.amazonaws.com; fi - if [[ "$TRAVIS_BRANCH" == "production" ]]; then export LOAD_BALANCER_DNS_NAME=http://testdriven-production-alb-2116729726.us-west-1.elb.amazonaws.com; fi - npm install diff --git a/cypress/integration/exercises.spec.js b/cypress/integration/exercises.spec.js index 315206e..6abaeae 100644 --- a/cypress/integration/exercises.spec.js +++ b/cypress/integration/exercises.spec.js @@ -11,7 +11,10 @@ describe('Exercises', () => { .visit('/') .get('h1').contains('Exercises') .get('.notification.is-warning').contains('Please log in to submit an exercise.') - .get('button').should('not.be.visible'); + .get('button').contains('Run Code').should('not.be.visible') + .get('.field.is-grouped') + .get('button').contains('Next') + .get('button').contains('Prev').should('not.be.visible'); }); it('should allow a user to submit an exercise if logged in', () => { @@ -33,12 +36,51 @@ describe('Exercises', () => { .get('h1').contains('Exercises') .get('.notification.is-success').contains('Welcome!') .get('.notification.is-danger').should('not.be.visible') - .get('button.button.is-primary').contains('Run Code'); + .get('button.button.is-primary').contains('Run Code') + .get('.field.is-grouped') + .get('button').contains('Next') + .get('button').contains('Prev').should('not.be.visible'); // assert user can submit an exercise + for (let i = 0; i < 23; i++) { + cy.get('textarea').type('{backspace}', { force: true }) + } cy + .get('textarea').type('def sum(x,y):\nreturn x+y', { force: true }) .get('button').contains('Run Code').click() - .wait(800) - .get('h5 > .grade-text').contains('Incorrect!'); + .wait('@gradeExercise') + .get('h5 > .grade-text').contains('Correct!'); + }); + + it('should allow a user to move to different exercises', () => { + cy + .visit('/') + .get('h1').contains('Exercises') + .get('.notification.is-warning').contains('Please log in to submit an exercise.') + .get('button').contains('Run Code').should('not.be.visible') + .get('.field.is-grouped') + .get('button').contains('Next') + .get('button').contains('Prev').should('not.be.visible') + .get('.ace_comment').contains('# Enter your code here.') + // click next + .get('button').contains('Next').click() + .get('button').contains('Next') + .get('button').contains('Prev') + .get('.ace_comment').contains('# Enter your code here.') + // click next + .get('button').contains('Next').click() + .get('button').contains('Next').should('not.be.visible') + .get('button').contains('Prev') + .get('.ace_comment').contains('# Enter your code here.') + for (let i = 0; i < 23; i++) { + cy.get('textarea').type('{backspace}', { force: true }) + } + cy + .get('textarea').type('def sum(x,y):\nreturn x+y', { force: true }) + // click prev + .get('button').contains('Prev').click() + .get('button').contains('Next') + .get('button').contains('Prev') + .get('.ace_comment').contains('# Enter your code here.'); }); }); diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 8a42e9f..5296cb9 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -43,6 +43,7 @@ services: - REACT_APP_USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL} - REACT_APP_API_GATEWAY_URL=${REACT_APP_API_GATEWAY_URL} - REACT_APP_EXERCISES_SERVICE_URL=${REACT_APP_EXERCISES_SERVICE_URL} + - REACT_APP_SCORES_SERVICE_URL=${REACT_APP_SCORES_SERVICE_URL} depends_on: - users @@ -98,3 +99,34 @@ services: depends_on: - users - client + + scores: + build: + context: ./services/scores + dockerfile: Dockerfile-dev + volumes: + - './services/scores:/usr/src/app' + ports: + - 5003:5000 + environment: + - FLASK_ENV=development + - APP_SETTINGS=project.config.DevelopmentConfig + - SECRET_KEY=my_precious + - DATABASE_URL=postgres://postgres:postgres@scores-db:5432/scores_dev + - DATABASE_TEST_URL=postgres://postgres:postgres@scores-db:5432/scores_test + - USERS_SERVICE_URL=http://users:5000 + - EXERCISES_SERVICE_URL=http://exercises:5000 + depends_on: + - users + - scores-db + - exercises + + scores-db: + build: + context: ./services/scores/project/db + dockerfile: Dockerfile + ports: + - 5438:5432 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index 247b1f1..ae8f5d3 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -37,6 +37,7 @@ services: - REACT_APP_USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL} - REACT_APP_API_GATEWAY_URL=${REACT_APP_API_GATEWAY_URL} - REACT_APP_EXERCISES_SERVICE_URL=${REACT_APP_EXERCISES_SERVICE_URL} + - REACT_APP_SCORES_SERVICE_URL=${REACT_APP_SCORES_SERVICE_URL} expose: - 80 depends_on: @@ -88,3 +89,31 @@ services: - URL=swagger.json depends_on: - users + + scores: + build: + context: ./services/scores + dockerfile: Dockerfile-prod + expose: + - 5000 + environment: + - FLASK_ENV=production + - APP_SETTINGS=project.config.StagingConfig + - DATABASE_URL=postgres://postgres:postgres@scores-db:5432/scores_prod + - DATABASE_TEST_URL=postgres://postgres:postgres@scores-db:5432/scores_test + - USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL} + - EXERCISES_SERVICE_URL=${REACT_APP_EXERCISES_SERVICE_URL} + depends_on: + - users + - scores-db + - exercises + + scores-db: + build: + context: ./services/scores/project/db + dockerfile: Dockerfile + expose: + - 5432 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres diff --git a/docker-compose-stage.yml b/docker-compose-stage.yml index d63263c..37a0719 100644 --- a/docker-compose-stage.yml +++ b/docker-compose-stage.yml @@ -37,6 +37,7 @@ services: - REACT_APP_USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL} - REACT_APP_API_GATEWAY_URL=${REACT_APP_API_GATEWAY_URL} - REACT_APP_EXERCISES_SERVICE_URL=${REACT_APP_EXERCISES_SERVICE_URL} + - REACT_APP_SCORES_SERVICE_URL=${REACT_APP_SCORES_SERVICE_URL} expose: - 80 depends_on: @@ -88,3 +89,31 @@ services: depends_on: - users - client + + scores: + build: + context: ./services/scores + dockerfile: Dockerfile-stage + expose: + - 5000 + environment: + - FLASK_ENV=production + - APP_SETTINGS=project.config.StagingConfig + - DATABASE_URL=postgres://postgres:postgres@scores-db:5432/scores_stage + - DATABASE_TEST_URL=postgres://postgres:postgres@scores-db:5432/scores_test + - USERS_SERVICE_URL=${REACT_APP_USERS_SERVICE_URL} + - EXERCISES_SERVICE_URL=${REACT_APP_EXERCISES_SERVICE_URL} + depends_on: + - users + - scores-db + - exercises + + scores-db: + build: + context: ./services/scores/project/db + dockerfile: Dockerfile + expose: + - 5432 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres diff --git a/docker-push.sh b/docker-push.sh index 29eed91..df7a07f 100644 --- a/docker-push.sh +++ b/docker-push.sh @@ -7,10 +7,12 @@ then export DOCKER_ENV=stage export REACT_APP_USERS_SERVICE_URL="http://testdriven-staging-alb-2001734548.us-west-1.elb.amazonaws.com" export REACT_APP_EXERCISES_SERVICE_URL="http://testdriven-staging-alb-2001734548.us-west-1.elb.amazonaws.com" + export REACT_APP_SCORES_SERVICE_URL="http://testdriven-staging-alb-2001734548.us-west-1.elb.amazonaws.com" elif [[ "$TRAVIS_BRANCH" == "production" ]]; then export DOCKER_ENV=prod export REACT_APP_USERS_SERVICE_URL="http://testdriven-production-alb-2116729726.us-west-1.elb.amazonaws.com" export REACT_APP_EXERCISES_SERVICE_URL="http://testdriven-production-alb-2116729726.us-west-1.elb.amazonaws.com" + export REACT_APP_SCORES_SERVICE_URL="http://testdriven-production-alb-2116729726.us-west-1.elb.amazonaws.com" export DATABASE_URL="$AWS_RDS_URI" export SECRET_KEY="$PRODUCTION_SECRET_KEY" fi @@ -55,5 +57,13 @@ then docker build $EXERCISES_DB_REPO -t $EXERCISES_DB:$COMMIT -f Dockerfile docker tag $EXERCISES_DB:$COMMIT $REPO/$EXERCISES_DB:$TAG docker push $REPO/$EXERCISES_DB:$TAG + # scores + docker build $SCORES_REPO -t $SCORES:$COMMIT -f Dockerfile-$DOCKER_ENV + docker tag $SCORES:$COMMIT $REPO/$SCORES:$TAG + docker push $REPO/$SCORES:$TAG + # scores db + docker build $SCORES_DB_REPO -t $SCORES_DB:$COMMIT -f Dockerfile + docker tag $SCORES_DB:$COMMIT $REPO/$SCORES_DB:$TAG + docker push $REPO/$SCORES_DB:$TAG fi fi diff --git a/init_db.sh b/init_db.sh new file mode 100644 index 0000000..d87de94 --- /dev/null +++ b/init_db.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# create +docker-compose -f docker-compose-dev.yml exec exercises python manage.py recreate_db +docker-compose -f docker-compose-dev.yml exec users python manage.py recreate_db +docker-compose -f docker-compose-dev.yml exec scores python manage.py recreate_db +# seed +docker-compose -f docker-compose-dev.yml exec exercises python manage.py seed_db +docker-compose -f docker-compose-dev.yml exec users python manage.py seed_db +docker-compose -f docker-compose-dev.yml exec scores python manage.py seed_db diff --git a/services/client/Dockerfile-prod b/services/client/Dockerfile-prod index d9def45..97f9b5b 100644 --- a/services/client/Dockerfile-prod +++ b/services/client/Dockerfile-prod @@ -23,6 +23,8 @@ ARG REACT_APP_API_GATEWAY_URL ENV REACT_APP_API_GATEWAY_URL $REACT_APP_API_GATEWAY_URL ARG REACT_APP_EXERCISES_SERVICE_URL ENV REACT_APP_EXERCISES_SERVICE_URL $REACT_APP_EXERCISES_SERVICE_URL +ARG REACT_APP_SCORES_SERVICE_URL +ENV REACT_APP_SCORES_SERVICE_URL $REACT_APP_SCORES_SERVICE_URL # create build COPY . /usr/src/app diff --git a/services/client/Dockerfile-stage b/services/client/Dockerfile-stage index bc4d60e..484cd58 100644 --- a/services/client/Dockerfile-stage +++ b/services/client/Dockerfile-stage @@ -23,6 +23,8 @@ ARG REACT_APP_API_GATEWAY_URL ENV REACT_APP_API_GATEWAY_URL $REACT_APP_API_GATEWAY_URL ARG REACT_APP_EXERCISES_SERVICE_URL ENV REACT_APP_EXERCISES_SERVICE_URL $REACT_APP_EXERCISES_SERVICE_URL +ARG REACT_APP_SCORES_SERVICE_URL +ENV REACT_APP_SCORES_SERVICE_URL $REACT_APP_SCORES_SERVICE_URL # create build COPY . /usr/src/app diff --git a/services/client/package.json b/services/client/package.json index 15f3caf..ec4b161 100644 --- a/services/client/package.json +++ b/services/client/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "axios": "^0.18.0", + "prop-types": "^15.6.2", "react": "^16.7.0", "react-ace": "^6.3.2", "react-dom": "^16.7.0", diff --git a/services/client/src/components/AddUser.jsx b/services/client/src/components/AddUser.jsx index ec26298..19ee8a3 100644 --- a/services/client/src/components/AddUser.jsx +++ b/services/client/src/components/AddUser.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; const AddUser = (props) => { return ( @@ -34,4 +35,11 @@ const AddUser = (props) => { ) }; +AddUser.propTypes = { + username: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + handleChange: PropTypes.func.isRequired, + addUser: PropTypes.func.isRequired, +}; + export default AddUser; diff --git a/services/client/src/components/Exercise.jsx b/services/client/src/components/Exercise.jsx new file mode 100644 index 0000000..c0ceb13 --- /dev/null +++ b/services/client/src/components/Exercise.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import AceEditor from 'react-ace'; +import PropTypes from 'prop-types'; +import 'brace/mode/python'; +import 'brace/theme/solarized_dark'; + + +const Exercise = (props) => { + return ( +
+
{props.exercise.body}
+ + {props.isAuthenticated && +
+ + {props.editor.showGrading && +
+ + + + Grading... +
+ } + {props.editor.showCorrect && +
+ + + + Correct! +
+ } + {props.editor.showIncorrect && +
+ + + + Incorrect! +
+ } +
+ } +

+
+ ) +}; + +Exercise.propTypes = { + exercise: PropTypes.shape({ + body: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, + test_code: PropTypes.string.isRequired, + test_code_solution: PropTypes.string.isRequired, + }).isRequired, + editor: PropTypes.shape({ + button: PropTypes.object.isRequired, + showCorrect: PropTypes.bool.isRequired, + showGrading: PropTypes.bool.isRequired, + showIncorrect: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + isAuthenticated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + submitExercise: PropTypes.func.isRequired, +}; + +export default Exercise; diff --git a/services/client/src/components/Exercises.jsx b/services/client/src/components/Exercises.jsx index 3ed2716..3293937 100644 --- a/services/client/src/components/Exercises.jsx +++ b/services/client/src/components/Exercises.jsx @@ -1,13 +1,14 @@ import React, { Component } from 'react'; -import AceEditor from 'react-ace'; -import 'brace/mode/python'; -import 'brace/theme/solarized_dark'; import axios from 'axios'; +import PropTypes from 'prop-types'; + +import Exercise from './Exercise'; class Exercises extends Component { constructor (props) { super(props); this.state = { + currentExercise: 0, exercises: [], editor: { value: '# Enter your code here.', @@ -16,16 +17,31 @@ class Exercises extends Component { showCorrect: false, showIncorrect: false, }, + showButtons: { + prev: false, + next: false, + }, }; this.onChange = this.onChange.bind(this); this.submitExercise = this.submitExercise.bind(this); + this.updateScore = this.updateScore.bind(this); + this.renderButtons = this.renderButtons.bind(this); + this.nextExercise = this.nextExercise.bind(this); + this.prevExercise = this.prevExercise.bind(this); + this.resetEditor = this.resetEditor.bind(this); }; componentDidMount() { this.getExercises(); }; getExercises() { - axios.get(`${process.env.REACT_APP_EXERCISES_SERVICE_URL}/exercises`) - .then((res) => { this.setState({ exercises: res.data.data.exercises }); }) + return axios.get(`${process.env.REACT_APP_EXERCISES_SERVICE_URL}/exercises`) + .then((res) => { + this.setState({ + exercises: res.data.data.exercises, + currentExercise: 0 + }); + this.renderButtons(); + }) .catch((err) => { console.log(err); }); }; onChange(value) { @@ -33,101 +49,144 @@ class Exercises extends Component { newState.value = value; this.setState(newState); }; - submitExercise(event) { + submitExercise(event, id) { event.preventDefault(); const newState = this.state.editor; + const exercise = this.state.exercises.filter(el => el.id === id)[0] newState.showGrading = true; newState.showCorrect = false; newState.showIncorrect = false; newState.button.isDisabled = true; this.setState(newState); - const data = { answer: this.state.editor.value }; + const data = { + answer: this.state.editor.value, + test: exercise.test_code, + solution: exercise.test_code_solution, + }; const url = process.env.REACT_APP_API_GATEWAY_URL; axios.post(url, data) .then((res) => { newState.showGrading = false newState.button.isDisabled = false - if (res.data) { newState.showCorrect = true }; - if (!res.data) { newState.showIncorrect = true }; + if (res.data && !res.data.errorType) { + newState.showCorrect = true + this.updateScore(exercise.id, true) + }; + if (!res.data || res.data.errorType) { + newState.showIncorrect = true + this.updateScore(exercise.id, false) + }; this.setState(newState); }) .catch((err) => { newState.showGrading = false newState.button.isDisabled = false console.log(err); + this.updateScore(exercise.id, false) }) }; + updateScore(exerciseID, bool) { + const options = { + url: `${process.env.REACT_APP_SCORES_SERVICE_URL}/scores/${exerciseID}`, + method: 'put', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${window.localStorage.authToken}` + }, + data: {correct:bool} + }; + return axios(options) + .then((res) => { console.log(res); }) + .catch((error) => { console.log(error); }); + }; + renderButtons() { + const index = this.state.currentExercise; + let nextButton = false; + let prevButton = false; + if (typeof this.state.exercises[index + 1] !== 'undefined') { + nextButton = true; + } + if (typeof this.state.exercises[index - 1] !== 'undefined') { + prevButton = true; + } + this.setState({ + showButtons: { + next: nextButton, + prev: prevButton + } + }); + }; + nextExercise() { + if (this.state.showButtons.next) { + const currentExercise = this.state.currentExercise; + this.setState({currentExercise: currentExercise + 1}, () => { + this.resetEditor() + this.renderButtons(); + }); + } + }; + prevExercise() { + if (this.state.showButtons.prev) { + const currentExercise = this.state.currentExercise; + this.setState({currentExercise: currentExercise - 1}, () => { + this.resetEditor(); + this.renderButtons(); + }); + } + }; + resetEditor() { + const editor = { + value: '# Enter your code here.', + button: { + isDisabled: false, + }, + showGrading: false, + showCorrect: false, + showIncorrect: false, + } + this.setState({editor: editor}); + }; render() { return (

Exercises



- {!this.props.isAuthenticated && -
- Please log in to submit an exercise. -
+ {!this.props.isAuthenticated && +
+ Please log in to submit an exercise. +
+ } + {this.state.exercises.length > 0 && + + } +
+ { this.state.showButtons.prev && + } - {this.state.exercises.length && -
-
{this.state.exercises[0].body}
- - {this.props.isAuthenticated && -
- - {this.state.editor.showGrading && -
- - - - Grading... -
- } - {this.state.editor.showCorrect && -
- - - - Correct! -
- } - {this.state.editor.showIncorrect && -
- - - - Incorrect! -
- } -
- } -

-
+   + { this.state.showButtons.next && + } +
) }; }; +Exercises.propTypes = { + isAuthenticated: PropTypes.bool.isRequired, +}; + export default Exercises; diff --git a/services/client/src/components/Logout.jsx b/services/client/src/components/Logout.jsx index b93e04a..064a1cb 100644 --- a/services/client/src/components/Logout.jsx +++ b/services/client/src/components/Logout.jsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; class Logout extends Component { componentDidMount() { @@ -14,4 +15,8 @@ class Logout extends Component { }; }; +Logout.propTypes = { + logoutUser: PropTypes.func.isRequired, +}; + export default Logout; diff --git a/services/client/src/components/Message.jsx b/services/client/src/components/Message.jsx index 4f67152..a43028a 100644 --- a/services/client/src/components/Message.jsx +++ b/services/client/src/components/Message.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; const Message = (props) => { return ( @@ -9,4 +10,10 @@ const Message = (props) => { ) }; +Message.propTypes = { + messageName: PropTypes.string, + messageType: PropTypes.string, + removeMessage: PropTypes.func.isRequired, +}; + export default Message; diff --git a/services/client/src/components/NavBar.jsx b/services/client/src/components/NavBar.jsx index 02f91d4..60241ef 100644 --- a/services/client/src/components/NavBar.jsx +++ b/services/client/src/components/NavBar.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import PropTypes from 'prop-types'; const NavBar = (props) => ( // eslint-disable-next-line @@ -46,4 +47,9 @@ const NavBar = (props) => ( ) +NavBar.propTypes = { + title: PropTypes.string.isRequired, + isAuthenticated: PropTypes.bool.isRequired, +}; + export default NavBar; diff --git a/services/client/src/components/UserStatus.jsx b/services/client/src/components/UserStatus.jsx index 74a0278..b6cc5e2 100644 --- a/services/client/src/components/UserStatus.jsx +++ b/services/client/src/components/UserStatus.jsx @@ -1,7 +1,7 @@ import React, { Component } from 'react'; import axios from 'axios'; import { Link } from 'react-router-dom'; - +import PropTypes from 'prop-types'; class UserStatus extends Component { constructor (props) { @@ -58,4 +58,8 @@ class UserStatus extends Component { }; }; +UserStatus.propTypes = { + isAuthenticated: PropTypes.bool.isRequired, +}; + export default UserStatus; diff --git a/services/client/src/components/UsersList.jsx b/services/client/src/components/UsersList.jsx index b7dc040..c07902a 100644 --- a/services/client/src/components/UsersList.jsx +++ b/services/client/src/components/UsersList.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; const UsersList = (props) => { return ( @@ -17,7 +18,7 @@ const UsersList = (props) => { { - props.users.map((user) => { + props.users && props.users.map((user) => { return ( {user.id} @@ -35,4 +36,8 @@ const UsersList = (props) => { ) }; +UsersList.propTypes = { + users: PropTypes.array.isRequired, +}; + export default UsersList; diff --git a/services/client/src/components/__tests__/AddUser.test.jsx b/services/client/src/components/__tests__/AddUser.test.jsx index e1626ba..4c00abd 100644 --- a/services/client/src/components/__tests__/AddUser.test.jsx +++ b/services/client/src/components/__tests__/AddUser.test.jsx @@ -4,13 +4,32 @@ import renderer from 'react-test-renderer'; import AddUser from '../AddUser'; +const testData = { + username: 'michael', + email: 'michael@mherman.org', + handleChange: jest.fn(), + addUser: jest.fn(), +} + +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); + test('AddUser renders properly', () => { - const wrapper = shallow(); + const wrapper = shallow(); const element = wrapper.find('form'); expect(element.find('input').length).toBe(3); expect(element.find('input').get(0).props.name).toBe('username'); expect(element.find('input').get(1).props.name).toBe('email'); expect(element.find('input').get(2).props.type).toBe('submit'); + expect(console.error).toHaveBeenCalledTimes(0); +}); + +test('AddUser does not render properly when not all props are defined', () => { + delete testData.addUser + const wrapper = shallow(); + expect(console.error).toHaveBeenCalledTimes(1); }); test('AddUser renders a snapshot properly', () => { diff --git a/services/client/src/components/__tests__/Exercise.test.jsx b/services/client/src/components/__tests__/Exercise.test.jsx new file mode 100644 index 0000000..d3cd397 --- /dev/null +++ b/services/client/src/components/__tests__/Exercise.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import renderer from 'react-test-renderer'; + +import AceEditor from 'react-ace'; +jest.mock('react-ace'); + +import Exercise from '../Exercise'; + +const testData = { + exercise: { + id: 0, + body: `Define a function called sum that takes two integers + as arguments and returns their sum.` + }, + editor: { + value: '# Enter your code here.', + button: { + isDisabled: false, + }, + showGrading: false, + showCorrect: false, + showIncorrect: false, + }, + isAuthenticated: false, + onChange: jest.fn(), + submitExercise: jest.fn(), +} + +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); + +test('Exercise renders properly', () => { + const wrapper = shallow(); + const heading = wrapper.find('h5'); + expect(heading.length).toBe(1); + expect(heading.text()).toBe(testData.exercise.body) +}); + +test('Exercises renders a snapshot properly when not authenticated', () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); +}); + +test('Exercises renders a snapshot properly when authenticated', () => { + testData.isAuthenticated = true; + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); +}); diff --git a/services/client/src/components/__tests__/Exercises.test.jsx b/services/client/src/components/__tests__/Exercises.test.jsx index d52c4ef..028ba36 100644 --- a/services/client/src/components/__tests__/Exercises.test.jsx +++ b/services/client/src/components/__tests__/Exercises.test.jsx @@ -7,7 +7,6 @@ jest.mock('react-ace'); import Exercises from '../Exercises'; - const exercises = [ { id: 0, @@ -27,13 +26,16 @@ const exercises = [ } ]; +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); + test('Exercises renders properly when not authenticated', () => { const onDidMount = jest.fn(); Exercises.prototype.componentDidMount = onDidMount; const wrapper = shallow(); wrapper.setState({exercises : exercises}); - const heading = wrapper.find('h5'); - expect(heading.length).toBe(1); const alert = wrapper.find('.notification'); expect(alert.length).toBe(1); const alertMessage = wrapper.find('.notification > span'); @@ -46,8 +48,6 @@ test('Exercises renders properly when authenticated', () => { Exercises.prototype.componentDidMount = onDidMount; const wrapper = shallow(); wrapper.setState({exercises : exercises}); - const heading = wrapper.find('h5'); - expect(heading.length).toBe(1); const alert = wrapper.find('.notification'); expect(alert.length).toBe(0); }); diff --git a/services/client/src/components/__tests__/Form.test.jsx b/services/client/src/components/__tests__/Form.test.jsx index 3ddc123..534184c 100644 --- a/services/client/src/components/__tests__/Form.test.jsx +++ b/services/client/src/components/__tests__/Form.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, simulate } from 'enzyme'; import renderer from 'react-test-renderer'; import { MemoryRouter, Switch, Redirect } from 'react-router-dom'; @@ -14,8 +14,10 @@ const testData = [ email: '', password: '' }, - loginUser: jest.fn(), isAuthenticated: false, + loginUser: jest.fn(), + createMessage: jest.fn(), + getUsers: jest.fn(), }, { formType: 'Login', @@ -24,12 +26,18 @@ const testData = [ email: '', password: '' }, - loginUser: jest.fn(), isAuthenticated: false, + loginUser: jest.fn(), + createMessage: jest.fn(), + getUsers: jest.fn(), } -] +]; describe('When not authenticated', () => { + beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); + }); testData.forEach((el) => { const component =
; it(`${el.formType} Form renders properly`, () => { @@ -42,6 +50,12 @@ describe('When not authenticated', () => { expect(formGroup.get(0).props.children.props.name).toBe( Object.keys(el.formData)[0]); expect(formGroup.get(0).props.children.props.value).toBe(''); + expect(console.error).toHaveBeenCalledTimes(0); + }); + it(`${el.formType} Form should be disabled by default`, () => { + const wrapper = shallow(component); + const input = wrapper.find('input[type="submit"]'); + expect(input.get(0).props.disabled).toEqual(true); }); it(`${el.formType} Form submits the form properly`, () => { const wrapper = shallow(component); @@ -61,24 +75,26 @@ describe('When not authenticated', () => { const tree = renderer.create(component).toJSON(); expect(tree).toMatchSnapshot(); }); - it(`${el.formType} Form should be disabled by default`, () => { - const wrapper = shallow(component); - const input = wrapper.find('input[type="submit"]'); - expect(input.get(0).props.disabled).toEqual(true); - }); }) + it(`${testData[0].formType} Form does not render properly when not all props are defined`, () => { + delete testData[0].createMessage + const updatedComponent = ; + shallow(updatedComponent); + expect(console.error).toHaveBeenCalledTimes(1); + }); }); describe('When authenticated', () => { + beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); + }); testData.forEach((el) => { - const component = ; + const component = ; it(`${el.formType} redirects properly`, () => { const wrapper = shallow(component); - expect(wrapper.find('Redirect')).toHaveLength(1); + expect(wrapper.find('Redirect')).toHaveLength(0); + expect(console.error).toHaveBeenCalledTimes(0); }); }) }); diff --git a/services/client/src/components/__tests__/Logout.test.jsx b/services/client/src/components/__tests__/Logout.test.jsx index b348335..3985dc0 100644 --- a/services/client/src/components/__tests__/Logout.test.jsx +++ b/services/client/src/components/__tests__/Logout.test.jsx @@ -7,11 +7,24 @@ import Logout from '../Logout'; const logoutUser = jest.fn(); +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); + test('Logout renders properly', () => { const wrapper = shallow(); const element = wrapper.find('p'); expect(element.length).toBe(1); expect(element.get(0).props.children[0]).toContain('You are now logged out.'); + expect(console.error).toHaveBeenCalledTimes(0); +}); + +test('Logout does not render properly when not all props are defined', () => { + const onDidMount = jest.fn(); + Logout.prototype.componentDidMount = onDidMount; + const wrapper = shallow(); + expect(console.error).toHaveBeenCalledTimes(1); }); test('Logout renders a snapshot properly', () => { diff --git a/services/client/src/components/__tests__/Message.test.jsx b/services/client/src/components/__tests__/Message.test.jsx index 3423557..bb8e095 100644 --- a/services/client/src/components/__tests__/Message.test.jsx +++ b/services/client/src/components/__tests__/Message.test.jsx @@ -1,18 +1,25 @@ import React from 'react'; import { shallow } from 'enzyme'; import renderer from 'react-test-renderer'; +import { MemoryRouter as Router } from 'react-router-dom'; import Message from '../Message'; describe('When given a success message', () => { + const removeMessage = jest.fn(); const messageSuccessProps = { messageName: 'Hello, World!', messageType: 'success', removeMessage: removeMessage, - } + }; + + beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); + }); it(`Message renders properly`, () => { const wrapper = shallow(); @@ -27,6 +34,13 @@ describe('When given a success message', () => { expect(removeMessage).toHaveBeenCalledTimes(0); button.simulate('click'); expect(removeMessage).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledTimes(0); + }); + + it(`Message does not render properly when not all props are defined`, () => { + delete messageSuccessProps.removeMessage + const wrapper = shallow(); + expect(console.error).toHaveBeenCalledTimes(1); }); test('Message renders a snapshot properly', () => { @@ -35,9 +49,11 @@ describe('When given a success message', () => { ).toJSON(); expect(tree).toMatchSnapshot(); }); + }); describe('When given a danger message', () => { + const removeMessage = jest.fn(); const messageDangerProps = { @@ -46,6 +62,11 @@ describe('When given a danger message', () => { removeMessage: removeMessage, } + beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); + }); + it(`Message renders properly`, () => { const wrapper = shallow(); const element = wrapper.find('.notification.is-danger'); @@ -59,6 +80,7 @@ describe('When given a danger message', () => { expect(removeMessage).toHaveBeenCalledTimes(0); button.simulate('click'); expect(removeMessage).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledTimes(0); }); test('Message renders a snapshot properly', () => { @@ -67,4 +89,5 @@ describe('When given a danger message', () => { ).toJSON(); expect(tree).toMatchSnapshot(); }); + }); diff --git a/services/client/src/components/__tests__/NavBar.test.jsx b/services/client/src/components/__tests__/NavBar.test.jsx index 00839ec..362b4a3 100644 --- a/services/client/src/components/__tests__/NavBar.test.jsx +++ b/services/client/src/components/__tests__/NavBar.test.jsx @@ -5,18 +5,33 @@ import { MemoryRouter as Router } from 'react-router-dom'; import NavBar from '../NavBar'; -const title = 'Hello, World!'; +const testData = { + title: 'Hello, World!', + isAuthenticated: false, +} + +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); test('NavBar renders properly', () => { - const wrapper = shallow(); + const wrapper = shallow(); const element = wrapper.find('strong'); expect(element.length).toBe(1); - expect(element.get(0).props.children).toBe(title); + expect(element.get(0).props.children).toBe(testData.title); + expect(console.error).toHaveBeenCalledTimes(0); +}); + +test('NavBar does not render properly when not all props are defined', () => { + delete testData.isAuthenticated + const wrapper = shallow(); + expect(console.error).toHaveBeenCalledTimes(1); }); test('NavBar renders a snapshot properly', () => { const tree = renderer.create( - + ).toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/services/client/src/components/__tests__/UsersList.test.jsx b/services/client/src/components/__tests__/UsersList.test.jsx index a943d50..be24301 100644 --- a/services/client/src/components/__tests__/UsersList.test.jsx +++ b/services/client/src/components/__tests__/UsersList.test.jsx @@ -6,20 +6,25 @@ import UsersList from '../UsersList'; const users = [ { - 'active': true, - 'admin': false, - 'email': 'hermanmu@gmail.com', - 'id': 1, - 'username': 'michael' + active: true, + admin: false, + email: 'hermanmu@gmail.com', + id: 1, + username: 'michael' }, { - 'active': true, - 'admin': false, - 'email': 'michael@mherman.org', - 'id': 2, - 'username': 'michaelherman' + active: true, + admin: false, + email: 'michael@mherman.org', + id: 2, + username: 'michaelherman' } -] +]; + +beforeEach(() => { + console.error = jest.fn(); + console.error.mockClear(); +}); test('UsersList renders properly', () => { const wrapper = shallow(); @@ -48,6 +53,15 @@ test('UsersList renders properly', () => { expect(td.get(4).props.children).toBe('false'); }); +test('UsersList does not render properly when not all props are defined', () => { + const wrapper = shallow(); + expect(wrapper.find('h1').get(0).props.children).toBe('All Users'); + // table + const table = wrapper.find('table'); + expect(table.length).toBe(1); + expect(console.error).toHaveBeenCalledTimes(1); +}); + test('UsersList renders a snapshot properly', () => { const tree = renderer.create().toJSON(); expect(tree).toMatchSnapshot(); diff --git a/services/client/src/components/__tests__/__snapshots__/Exercise.test.jsx.snap b/services/client/src/components/__tests__/__snapshots__/Exercise.test.jsx.snap new file mode 100644 index 0000000..f5f55b1 --- /dev/null +++ b/services/client/src/components/__tests__/__snapshots__/Exercise.test.jsx.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Exercises renders a snapshot properly when authenticated 1`] = ` +
+
+ Define a function called sum that takes two integers + as arguments and returns their sum. +
+
+ +
+
+
+
+`; + +exports[`Exercises renders a snapshot properly when not authenticated 1`] = ` +
+
+ Define a function called sum that takes two integers + as arguments and returns their sum. +
+
+
+
+`; diff --git a/services/client/src/components/__tests__/__snapshots__/Exercises.test.jsx.snap b/services/client/src/components/__tests__/__snapshots__/Exercises.test.jsx.snap index 95f416e..354a0d6 100644 --- a/services/client/src/components/__tests__/__snapshots__/Exercises.test.jsx.snap +++ b/services/client/src/components/__tests__/__snapshots__/Exercises.test.jsx.snap @@ -16,6 +16,5 @@ exports[`Exercises renders a snapshot properly 1`] = ` Please log in to submit an exercise. - 0 `; diff --git a/services/client/src/components/forms/Form.jsx b/services/client/src/components/forms/Form.jsx index 5bd0465..c6fe03b 100644 --- a/services/client/src/components/forms/Form.jsx +++ b/services/client/src/components/forms/Form.jsx @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import axios from 'axios'; import { Redirect } from 'react-router-dom'; +import PropTypes from 'prop-types'; import { registerFormRules, loginFormRules } from './form-rules.js'; import FormErrors from './FormErrors.jsx'; @@ -126,8 +127,7 @@ class Form extends Component { if (this.props.isAuthenticated) { return ; }; - let formRules = this.state.loginFormRules; // new - // new + let formRules = this.state.loginFormRules; if (this.props.formType === 'Register') { formRules = this.state.registerFormRules; } @@ -140,7 +140,6 @@ class Form extends Component {

Register

}

- {/* new */} ', methods=['GET']) +@authenticate +def get_single_score_by_user_id(resp, score_id): + """Get single score by user id""" + response_object = { + 'status': 'fail', + 'message': 'Score does not exist' + } + try: + score = Score.query.filter_by( + id=int(score_id), + user_id=int(resp['data']['id']) + ).first() + if not score: + return jsonify(response_object), 404 + else: + response_object = { + 'status': 'success', + 'data': score.to_json() + } + return jsonify(response_object), 200 + except ValueError: + return jsonify(response_object), 404 + + +@scores_blueprint.route('/scores', methods=['POST']) +@authenticate +def add_scores(resp): + post_data = request.get_json() + response_object = { + 'status': 'fail', + 'message': 'Invalid payload.' + } + if not post_data: + return jsonify(response_object), 400 + exercise_id = post_data.get('exercise_id') + correct = False + if post_data.get('correct'): + correct = post_data.get('correct') + try: + db.session.add(Score( + user_id=resp['data']['id'], + exercise_id=exercise_id, + correct=correct)) + db.session.commit() + response_object['status'] = 'success' + response_object['message'] = 'New score was added!' + return jsonify(response_object), 201 + except exc.IntegrityError: + db.session.rollback() + return jsonify(response_object), 400 + except (exc.IntegrityError, ValueError): + db.session.rollback() + return jsonify(response_object), 400 + + +@scores_blueprint.route('/scores/', methods=['PUT']) +@authenticate +def update_score(resp, exercise_id): + """Update score""" + post_data = request.get_json() + response_object = { + 'status': 'fail', + 'message': 'Invalid payload.' + } + if not post_data: + return jsonify(response_object), 400 + correct = post_data.get('correct') + try: + score = Score.query.filter_by( + exercise_id=int(exercise_id), + user_id=int(resp['data']['id']) + ).first() + if score: + score.correct = correct + db.session.commit() + response_object['status'] = 'success' + response_object['message'] = 'Score was updated!' + return jsonify(response_object), 200 + else: + db.session.add(Score( + user_id=resp['data']['id'], + exercise_id=int(exercise_id), + correct=correct)) + db.session.commit() + response_object['status'] = 'success' + response_object['message'] = 'New score was added!' + return jsonify(response_object), 201 + except (exc.IntegrityError, ValueError, TypeError): + db.session().rollback() + return jsonify(response_object), 400 diff --git a/services/scores/project/api/utils.py b/services/scores/project/api/utils.py new file mode 100755 index 0000000..beb029e --- /dev/null +++ b/services/scores/project/api/utils.py @@ -0,0 +1,51 @@ +# project/api/utils.py + + +import json +from functools import wraps + +import requests +from flask import request, jsonify, current_app + + +def authenticate(f): + @wraps(f) + def decorated_function(*args, **kwargs): + response_object = { + 'status': 'error', + 'message': 'Something went wrong. Please contact us.' + } + code = 401 + auth_header = request.headers.get('Authorization') + if not auth_header: + response_object['message'] = 'Provide a valid auth token.' + code = 403 + return jsonify(response_object), code + auth_token = auth_header.split(" ")[1] + response = ensure_authenticated(auth_token) + if not response: + response_object['message'] = 'Invalid token.' + return jsonify(response_object), code + return f(response, *args, **kwargs) + return decorated_function + + +def ensure_authenticated(token): + if current_app.config['TESTING']: + test_response = { + 'data': {'id': 998877}, + 'status': 'success', + 'admin': True + } + return test_response + url = '{0}/auth/status'.format(current_app.config['USERS_SERVICE_URL']) + bearer = 'Bearer {0}'.format(token) + headers = {'Authorization': bearer} + response = requests.get(url, headers=headers) + data = json.loads(response.text) + if response.status_code == 200 and \ + data['status'] == 'success' and \ + data['data']['active']: + return data + else: + return False diff --git a/services/scores/project/config.py b/services/scores/project/config.py new file mode 100755 index 0000000..5e51143 --- /dev/null +++ b/services/scores/project/config.py @@ -0,0 +1,37 @@ +# project/config.py + + +import os + + +class BaseConfig: + """Base configuration""" + DEBUG = False + TESTING = False + DEBUG_TB_ENABLED = False + DEBUG_TB_INTERCEPT_REDIRECTS = False + SECRET_KEY = os.environ.get('SECRET_KEY') + SQLALCHEMY_TRACK_MODIFICATIONS = False + USERS_SERVICE_URL = os.environ.get('USERS_SERVICE_URL') + + +class DevelopmentConfig(BaseConfig): + """Development configuration""" + DEBUG_TB_ENABLED = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') + + +class TestingConfig(BaseConfig): + """Testing configuration""" + TESTING = True + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_TEST_URL') + + +class StagingConfig(BaseConfig): + """Staging configuration""" + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') + + +class ProductionConfig(BaseConfig): + """Production configuration""" + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') diff --git a/services/scores/project/db/Dockerfile b/services/scores/project/db/Dockerfile new file mode 100755 index 0000000..cff0280 --- /dev/null +++ b/services/scores/project/db/Dockerfile @@ -0,0 +1,5 @@ +# base image +FROM postgres:11.1-alpine + +# run create.sql on init +ADD create.sql /docker-entrypoint-initdb.d diff --git a/services/scores/project/db/create.sql b/services/scores/project/db/create.sql new file mode 100755 index 0000000..c7e8fcd --- /dev/null +++ b/services/scores/project/db/create.sql @@ -0,0 +1,4 @@ +CREATE DATABASE scores_prod; +CREATE DATABASE scores_stage; +CREATE DATABASE scores_dev; +CREATE DATABASE scores_test; diff --git a/services/scores/project/tests/__init__.py b/services/scores/project/tests/__init__.py new file mode 100755 index 0000000..efc6a40 --- /dev/null +++ b/services/scores/project/tests/__init__.py @@ -0,0 +1 @@ +# project/tests/__init__.py diff --git a/services/scores/project/tests/base.py b/services/scores/project/tests/base.py new file mode 100755 index 0000000..62388fa --- /dev/null +++ b/services/scores/project/tests/base.py @@ -0,0 +1,22 @@ +# services/scores/project/tests/base.py + + +from flask_testing import TestCase + +from project import create_app, db + +app = create_app() + + +class BaseTestCase(TestCase): + def create_app(self): + app.config.from_object('project.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + db.session.commit() + + def tearDown(self): + db.session.remove() + db.drop_all() diff --git a/services/scores/project/tests/test_base.py b/services/scores/project/tests/test_base.py new file mode 100755 index 0000000..2e73070 --- /dev/null +++ b/services/scores/project/tests/test_base.py @@ -0,0 +1,28 @@ +# project/tests/test_eval.py + + +import json + +from project.tests.base import BaseTestCase + + +class TestBaseBlueprint(BaseTestCase): + + def test_ping(self): + """Ensure the /ping route behaves correctly.""" + response = self.client.get( + '/base/ping', + headers=dict(Authorization='Bearer test') + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertIn('pong!', data['message']) + self.assertIn('success', data['status']) + + def test_ping_no_header(self): + """Ensure error is thrown if 'Authorization' header is empty.""" + response = self.client.get('/base/ping') + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 403) + self.assertIn('Provide a valid auth token.', data['message']) + self.assertIn('error', data['status']) diff --git a/services/scores/project/tests/test_config.py b/services/scores/project/tests/test_config.py new file mode 100755 index 0000000..3e4f0e4 --- /dev/null +++ b/services/scores/project/tests/test_config.py @@ -0,0 +1,42 @@ +# services/scores/project/tests/test_config.py + + +import unittest + +from flask import current_app +from flask_testing import TestCase + +from project import create_app + +app = create_app() + + +class TestDevelopmentConfig(TestCase): + def create_app(self): + app.config.from_object('project.config.DevelopmentConfig') + return app + + def test_app_is_development(self): + self.assertFalse(current_app is None) + + +class TestTestingConfig(TestCase): + def create_app(self): + app.config.from_object('project.config.TestingConfig') + return app + + def test_app_is_testing(self): + self.assertTrue(app.config['TESTING']) + + +class TestProductionConfig(TestCase): + def create_app(self): + app.config.from_object('project.config.ProductionConfig') + return app + + def test_app_is_production(self): + self.assertFalse(app.config['TESTING']) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/scores/project/tests/test_scores_api.py b/services/scores/project/tests/test_scores_api.py new file mode 100755 index 0000000..ee133c4 --- /dev/null +++ b/services/scores/project/tests/test_scores_api.py @@ -0,0 +1,240 @@ +# services/scores/project/tests/test_scores_api.py + + +import json +import unittest + +from project.tests.base import BaseTestCase +from project.tests.utils import add_score + + +class TestScoresService(BaseTestCase): + """Tests for the Scores Service.""" + + def test_scores_ping(self): + """Ensure the /ping route behaves correctly.""" + response = self.client.get('/scores/ping') + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertIn('pong!', data['message']) + self.assertIn('success', data['status']) + + def test_all_scores(self): + """Ensure get all scores behaves correctly.""" + add_score(1, 2, True) + add_score(998877, 878778, False) + with self.client: + response = self.client.get('/scores') + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['data']['scores']), 2) + self.assertEqual(1, data['data']['scores'][0]['user_id']) + self.assertEqual(2, data['data']['scores'][0]['exercise_id']) + self.assertTrue(data['data']['scores'][0]['correct']) + self.assertEqual(998877, data['data']['scores'][1]['user_id']) + self.assertEqual(878778, data['data']['scores'][1]['exercise_id']) + self.assertFalse(data['data']['scores'][1]['correct']) + self.assertIn('success', data['status']) + + def test_all_scores_by_user_id(self): + """Ensure get all scores by user id behaves correctly.""" + add_score(998877, 878778, True) + with self.client: + response = self.client.get( + f'/scores/user', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['data']['scores']), 1) + self.assertEqual(998877, data['data']['scores'][0]['user_id']) + self.assertEqual(878778, data['data']['scores'][0]['exercise_id']) + self.assertTrue(data['data']['scores'][0]['correct']) + self.assertIn('success', data['status']) + + def test_all_scores_by_user_id_no_scores(self): + """Ensure get all scores by user id behaves correctly with 0 scores.""" + with self.client: + response = self.client.get( + f'/scores/user', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['data']['scores']), 0) + self.assertIn('success', data['status']) + + def test_all_scores_by_user_id_no_header(self): + """Ensure error is thrown if 'Authorization' header is empty.""" + response = self.client.get(f'/scores/user') + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 403) + self.assertIn('Provide a valid auth token.', data['message']) + self.assertIn('error', data['status']) + + def test_single_score_by_user_id(self): + """Ensure get all scores by user id behaves correctly.""" + score = add_score(998877, 65479, True) + with self.client: + response = self.client.get( + f'/scores/user/{score.id}', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(998877, data['data']['user_id']) + self.assertEqual(65479, data['data']['exercise_id']) + self.assertTrue(data['data']['correct']) + self.assertIn('success', data['status']) + + def test_single_score_by_user_id_no_id(self): + """Ensure error is thrown if an id is not provided.""" + with self.client: + response = self.client.get( + '/scores/user/blah', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertIn('Score does not exist', data['message']) + self.assertIn('fail', data['status']) + + def test_single_score_incorrect_id(self): + """Ensure error is thrown if the id does not exist.""" + with self.client: + response = self.client.get( + '/scores/user/999', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertIn('Score does not exist', data['message']) + self.assertIn('fail', data['status']) + + def test_single_score_by_user_id_no_header(self): + """Ensure error is thrown if 'Authorization' header is empty.""" + response = self.client.get(f'/scores/user/999') + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 403) + self.assertIn('Provide a valid auth token.', data['message']) + self.assertIn('error', data['status']) + + def test_add_score(self): + """Ensure a new score can be added to the database.""" + with self.client: + response = self.client.post( + '/scores', + data=json.dumps({ + 'exercise_id': 1, + 'correct': False, + }), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 201) + self.assertIn('New score was added!', data['message']) + self.assertIn('success', data['status']) + + def test_add_score_invalid_json(self): + """Ensure error is thrown if the JSON object is empty.""" + with self.client: + response = self.client.post( + '/scores', + data=json.dumps({}), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertIn('Invalid payload.', data['message']) + self.assertIn('fail', data['status']) + + def test_add_score_invalid_json_keys(self): + """Ensure error is thrown if the JSON object is invalid.""" + with self.client: + response = self.client.post( + '/scores', + data=json.dumps({'correct': True}), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertIn('Invalid payload.', data['message']) + self.assertIn('fail', data['status']) + + def test_add_score_no_header(self): + """Ensure error is thrown if 'Authorization' header is empty.""" + response = self.client.post( + '/scores', + data=json.dumps({ + 'exercise_id': 1, + 'correct': False, + }), + content_type='application/json' + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 403) + self.assertIn('Provide a valid auth token.', data['message']) + self.assertIn('error', data['status']) + + def test_update_score(self): + """Ensure an existing score can be updated in the database.""" + add_score(998877, 65479, True) + with self.client: + response = self.client.put( + f'/scores/65479', + data=json.dumps({'correct': False}), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertIn('Score was updated!', data['message']) + self.assertIn('success', data['status']) + + def test_update_score_invalid_json(self): + """Ensure error is thrown if the JSON object is empty.""" + with self.client: + response = self.client.put( + '/scores/7', + data=json.dumps({}), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertIn('Invalid payload.', data['message']) + self.assertIn('fail', data['status']) + + def test_update_score_invalid_exercise_id(self): + """Should create the score if it doesn't exist.""" + add_score(998877, 65479, True) + with self.client: + response = self.client.put( + '/scores/9', + data=json.dumps({'correct': False}), + content_type='application/json', + headers=({'Authorization': 'Bearer test'}) + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 201) + self.assertIn('New score was added!', data['message']) + self.assertIn('success', data['status']) + + def test_update_score_no_header(self): + """Ensure error is thrown if 'Authorization' header is empty.""" + response = self.client.put( + '/scores/9', + data=json.dumps({'correct': False}), + content_type='application/json' + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 403) + self.assertIn('Provide a valid auth token.', data['message']) + self.assertIn('error', data['status']) + + +if __name__ == '__main__': + unittest.main() diff --git a/services/scores/project/tests/test_scores_model.py b/services/scores/project/tests/test_scores_model.py new file mode 100755 index 0000000..a95e9ee --- /dev/null +++ b/services/scores/project/tests/test_scores_model.py @@ -0,0 +1,14 @@ +# services/scores/project/tests/test_scores_model.py + + +from project.tests.base import BaseTestCase +from project.tests.utils import add_score + + +class TestScoreModel(BaseTestCase): + + def test_add_score(self): + score = add_score(1, 1, True) + self.assertTrue(score.id) + self.assertEqual(score.user_id, 1) + self.assertEqual(score.exercise_id, 1) diff --git a/services/scores/project/tests/utils.py b/services/scores/project/tests/utils.py new file mode 100755 index 0000000..95b4565 --- /dev/null +++ b/services/scores/project/tests/utils.py @@ -0,0 +1,16 @@ +# services/scores/project/tests/utils.py + + +from project import db +from project.api.models import Score + + +def add_score(user_id, exercise_id, correct): + score = Score( + user_id=user_id, + exercise_id=exercise_id, + correct=correct + ) + db.session.add(score) + db.session.commit() + return score diff --git a/services/scores/requirements.txt b/services/scores/requirements.txt new file mode 100755 index 0000000..8e0c0a4 --- /dev/null +++ b/services/scores/requirements.txt @@ -0,0 +1,11 @@ +coverage==4.5.2 +flake8===3.6.0 +Flask==1.0.2 +flask-cors==3.0.7 +flask-debugtoolbar==0.10.1 +flask-migrate==2.3.1 +Flask-SQLAlchemy==2.3.2 +Flask-Testing==0.7.1 +gunicorn==19.9.0 +psycopg2-binary==2.7.6.1 +requests==2.21.0 diff --git a/services/swagger/swagger.json b/services/swagger/swagger.json index c80cd05..0bf5dd1 100644 --- a/services/swagger/swagger.json +++ b/services/swagger/swagger.json @@ -1 +1 @@ -{"info": {"version": "0.0.1", "description": "Swagger spec for documenting the users service", "title": "Users Service"}, "paths": {"/auth/logout": {"get": {"security": [{"bearerAuth": []}], "responses": {"200": {"description": "Successfully logged out"}}, "summary": "Logs a user out"}}, "/auth/register": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user"}}}, "required": true, "description": "User to add"}, "responses": {"200": {"description": "user object"}}, "summary": "Creates a new user"}}, "/users/ping": {"get": {"responses": {"200": {"description": "Will return 'pong!'"}}, "summary": "Just a sanity check"}}, "/auth/login": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user"}}}, "required": true, "description": "User to log in"}, "responses": {"200": {"description": "Successfully logged in"}}, "summary": "Logs a user in"}}, "/auth/status": {"get": {"security": [{"bearerAuth": []}], "responses": {"200": {"description": "user object"}}, "summary": "Returns the logged in user's status"}}, "/users/{id}": {"get": {"responses": {"200": {"description": "user object"}}, "parameters": [{"schema": {"type": "integer", "format": "int64"}, "required": true, "description": "ID of user to fetch", "name": "id", "in": "path"}], "summary": "Returns a user based on a single user ID"}}, "/users": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user-full"}}}, "required": true, "description": "User to add"}, "security": [{"bearerAuth": []}], "responses": {"200": {"description": "User added"}}, "summary": "Adds a new user"}, "get": {"responses": {"200": {"description": "user object"}}, "summary": "Returns all users"}}}, "servers": [{"url": "http://testdriven-production-alb-2116729726.us-west-1.elb.amazonaws.com"}], "components": {"securitySchemes": {"bearerAuth": {"scheme": "bearer", "type": "http"}}, "schemas": {"user-full": {"properties": {"username": {"type": "string"}, "password": {"type": "string"}, "email": {"type": "string"}}}, "user": {"properties": {"password": {"type": "string"}, "email": {"type": "string"}}}}}, "openapi": "3.0.0"} \ No newline at end of file +{"info": {"version": "0.0.1", "description": "Swagger spec for documenting the users service", "title": "Users Service"}, "paths": {"/auth/logout": {"get": {"security": [{"bearerAuth": []}], "responses": {"200": {"description": "Successfully logged out"}}, "summary": "Logs a user out"}}, "/auth/register": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user"}}}, "required": true, "description": "User to add"}, "responses": {"200": {"description": "user object"}}, "summary": "Creates a new user"}}, "/users/ping": {"get": {"responses": {"200": {"description": "Will return 'pong!'"}}, "summary": "Just a sanity check"}}, "/auth/login": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user"}}}, "required": true, "description": "User to log in"}, "responses": {"200": {"description": "Successfully logged in"}}, "summary": "Logs a user in"}}, "/auth/status": {"get": {"security": [{"bearerAuth": []}], "responses": {"200": {"description": "user object"}}, "summary": "Returns the logged in user's status"}}, "/users/{id}": {"get": {"responses": {"200": {"description": "user object"}}, "parameters": [{"required": true, "in": "path", "description": "ID of user to fetch", "name": "id", "schema": {"type": "integer", "format": "int64"}}], "summary": "Returns a user based on a single user ID"}}, "/users": {"post": {"requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/user-full"}}}, "required": true, "description": "User to add"}, "security": [{"bearerAuth": []}], "responses": {"200": {"description": "User added"}}, "summary": "Adds a new user"}, "get": {"responses": {"200": {"description": "user object"}}, "summary": "Returns all users"}}}, "openapi": "3.0.0", "components": {"securitySchemes": {"bearerAuth": {"scheme": "bearer", "type": "http"}}, "schemas": {"user-full": {"properties": {"username": {"type": "string"}, "password": {"type": "string"}, "email": {"type": "string"}}}, "user": {"properties": {"password": {"type": "string"}, "email": {"type": "string"}}}}}, "servers": [{"url": "http://testdriven-staging-alb-2001734548.us-west-1.elb.amazonaws.com"}]} \ No newline at end of file diff --git a/test-ci.sh b/test-ci.sh index 8cb4281..ccd8e68 100644 --- a/test-ci.sh +++ b/test-ci.sh @@ -21,6 +21,10 @@ dev() { inspect $? exercises docker-compose -f docker-compose-dev.yml run exercises flake8 project inspect $? exercises-lint + docker-compose -f docker-compose-dev.yml run scores python manage.py test + inspect $? scores + docker-compose -f docker-compose-dev.yml run scores flake8 project + inspect $? scores-lint docker-compose -f docker-compose-dev.yml exec client npm test -- --coverage inspect $? client docker-compose -f docker-compose-dev.yml down diff --git a/test.sh b/test.sh index 640db7e..7c774e8 100644 --- a/test.sh +++ b/test.sh @@ -21,6 +21,10 @@ server() { inspect $? exercises docker-compose -f docker-compose-dev.yml run exercises flake8 project inspect $? exercises-lint + docker-compose -f docker-compose-dev.yml run scores python manage.py test + inspect $? scores + docker-compose -f docker-compose-dev.yml run scores flake8 project + inspect $? scores-lint docker-compose -f docker-compose-dev.yml down } @@ -52,6 +56,10 @@ all() { inspect $? exercises docker-compose -f docker-compose-dev.yml run exercises flake8 project inspect $? exercises-lint + docker-compose -f docker-compose-dev.yml run scores python manage.py test + inspect $? scores + docker-compose -f docker-compose-dev.yml run scores flake8 project + inspect $? scores-lint docker-compose -f docker-compose-dev.yml exec client npm test -- --coverage inspect $? client docker-compose -f docker-compose-dev.yml down