diff --git a/.env.example b/.env.example index 51137a962..d6c9b1a5c 100644 --- a/.env.example +++ b/.env.example @@ -9,5 +9,12 @@ ROLLBAR_ACCESS_TOKEN=ROLLBAR_ACCESS_TOKEN ROLLBAR_ENV=development -# TODO: Replace `rails-template` with the name of the app. -DATABASE_URL=postgres://postgres@localhost:5432/rails-template-development +DATABASE_URL=postgres://postgres@localhost:5432/buy-for-your-school-development + +# Contentful +CONTENTFUL_URL=cdn.contentful.com +CONTENTFUL_SPACE= +CONTENTFUL_ENVIRONMENT=master +CONTENTFUL_ACCESS_TOKEN= +CONTENTFUL_PLANNING_START_ENTRY_ID= +CONTENTFUL_PREVIEW_APP=false diff --git a/.env.test b/.env.test index a96b06953..fac84bf76 100644 --- a/.env.test +++ b/.env.test @@ -5,5 +5,12 @@ # # Reference: https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use -# TODO: Replace `rails-template` with the name of the app. -DATABASE_URL=postgres://postgres@localhost:5432/rails-template-test +DATABASE_URL=postgres://postgres@localhost:5432/buy-for-your-school-test + +# Contentful +CONTENTFUL_URL=cdn.contentful.com +CONTENTFUL_SPACE=test +CONTENTFUL_ENVIRONMENT=master +CONTENTFUL_ACCESS_TOKEN=123 +CONTENTFUL_PLANNING_START_ENTRY_ID=1UjQurSOi5MWkcRuGxdXZS +CONTENTFUL_PREVIEW_APP=false diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 99c4a76d5..8b7e41b63 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -1,8 +1,11 @@ -# TODO: Enable GitHub Actions on the repository to test all pull requests -# https://github.com///actions name: CI -on: pull_request +on: + pull_request: + push: + branches: + - main + - develop jobs: test: diff --git a/.gitignore b/.gitignore index 3e1d267c5..bedb8d661 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ db/*.sqlite3 rerun.txt pickle-email-*.html .zeus.sock +/node_modules # If you find yourself ignoring temporary files generated by your text editor # or operating system, you probably want to add a global ignore instead: diff --git a/.ruby-version b/.ruby-version index ec1cf33c3..338a5b5d8 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.3 +2.6.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a0eb392..733795bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,5 +6,22 @@ The format is based on [Keep a Changelog 1.0.0]. ## [Unreleased] -[unreleased]: TODO +## [release-001] - 2020-11-12 + +- address rails-template TODO +- any user can see a start page for the planning journey +- use GOV.UK Frontend framework +- users can see a basic form to start the planning journey sourced by a +Contentful fixture +- planning form is styled as a GOV.UK form +- validate that an answer is provided to a question +- the first planning question comes directly from Contentful +- handle the exceptional case when a Contentful entry is missing +- multiple radio questions can be answered in sequence +- users can be asked to answer a short text question +- Contentful can redirect users to preview endpoints + +[unreleased]: https://github.com/DfE/DFE-Digital/buy-for-your-school/compare/release-001...HEAD +[release-001]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-000...release-001 + [keep a changelog 1.0.0]: https://keepachangelog.com/en/1.0.0/ diff --git a/Dockerfile b/Dockerfile index 3467522af..6841e9059 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.6.3 as release +FROM ruby:2.6.6 as release MAINTAINER dxw RUN apt-get update && apt-get install -qq -y \ build-essential \ @@ -17,6 +17,11 @@ ARG RAILS_ENV ENV RAILS_ENV=${RAILS_ENV:-production} ENV RACK_ENV=${RAILS_ENV:-production} +COPY package.json $INSTALL_PATH/package.json +COPY package-lock.json $INSTALL_PATH/package-lock.json + +RUN npm install + COPY Gemfile $INSTALL_PATH/Gemfile COPY Gemfile.lock $INSTALL_PATH/Gemfile.lock diff --git a/Gemfile b/Gemfile index aa408fbb1..83b8f43c1 100644 --- a/Gemfile +++ b/Gemfile @@ -2,11 +2,13 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby "2.6.3" +ruby "2.6.6" gem "bootsnap", ">= 1.1.0", require: false -gem "bootstrap", ">= 4.3.1" +gem "climate_control" gem "coffee-rails", "~> 5.0" +gem "contentful", "~> 2.15" +gem "govuk_design_system_formbuilder", "~> 2.1" gem "high_voltage" gem "jbuilder", "~> 2.5" gem "jquery-rails" @@ -21,7 +23,7 @@ gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] gem "uglifier", ">= 1.3.0" group :development do - gem "listen", ">= 3.0.5", "< 3.3" + gem "listen", ">= 3.0.5", "< 3.4" gem "spring" gem "spring-watcher-listen", "~> 2.0.0" gem "web-console", ">= 3.3.0" @@ -30,6 +32,7 @@ end group :test do gem "capybara", ">= 2.15" gem "selenium-webdriver" + gem "shoulda-matchers" gem "simplecov" end diff --git a/Gemfile.lock b/Gemfile.lock index 5c4984c75..4ca25a00a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,19 +59,13 @@ GEM addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) ast (2.4.1) - autoprefixer-rails (9.8.6.1) - execjs - better_errors (2.8.3) + better_errors (2.9.1) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) bindex (0.8.1) - bootsnap (1.4.8) + bootsnap (1.5.1) msgpack (~> 1.0) - bootstrap (4.5.2) - autoprefixer-rails (>= 9.1.0) - popper_js (>= 1.14.3, < 2) - sassc-rails (>= 2.0.0) brakeman (4.10.0) builder (3.2.4) bullet (6.1.0) @@ -87,6 +81,7 @@ GEM regexp_parser (~> 1.5) xpath (~> 3.2) childprocess (3.0.0) + climate_control (0.2.0) coderay (1.1.3) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -96,10 +91,15 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.1.7) + contentful (2.15.4) + http (> 0.8, < 5.0) + multi_json (~> 1) crass (1.0.6) database_cleaner (1.8.5) diff-lcs (1.4.4) docile (1.3.2) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) dotenv (2.7.6) dotenv-rails (2.7.6) dotenv (= 2.7.6) @@ -114,9 +114,26 @@ GEM faker (2.14.0) i18n (>= 1.6, < 2) ffi (1.13.1) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake globalid (0.4.2) activesupport (>= 4.2.0) + govuk_design_system_formbuilder (2.1.4) + actionview (>= 5.2) + activemodel (>= 5.2) + activesupport (>= 5.2) high_voltage (3.1.2) + http (4.4.1) + addressable (~> 2.3) + http-cookie (~> 1.0) + http-form_data (~> 2.2) + http-parser (~> 1.2.0) + http-cookie (1.0.3) + domain_name (~> 0.5) + http-form_data (2.3.0) + http-parser (1.2.1) + ffi-compiler (>= 1.0, < 2.0) i18n (1.8.5) concurrent-ruby (~> 1.0) jbuilder (2.10.1) @@ -128,7 +145,7 @@ GEM launchy (2.5.0) addressable (~> 2.7) libv8 (8.4.255.0) - listen (3.2.1) + listen (3.3.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) loofah (2.7.0) @@ -146,19 +163,19 @@ GEM libv8 (~> 8.4.255) minitest (5.14.2) msgpack (1.3.3) + multi_json (1.15.0) nio4r (2.5.4) nokogiri (1.10.10) mini_portile2 (~> 2.4.0) - parallel (1.19.2) + parallel (1.20.0) parser (2.7.2.0) ast (~> 2.4.1) pg (1.2.3) - popper_js (1.16.0) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.6) - puma (5.0.2) + puma (5.0.4) nio4r (~> 2.0) rack (2.2.3) rack-test (1.1.0) @@ -192,12 +209,12 @@ GEM thor (>= 0.20.3, < 2.0) rainbow (3.0.0) rake (13.0.1) - rb-fsevent (0.10.3) + rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) regexp_parser (1.8.2) rexml (3.2.4) - rollbar (3.0.1) + rollbar (3.1.0) rspec-core (3.9.2) rspec-support (~> 3.9.3) rspec-expectations (3.9.2) @@ -215,16 +232,16 @@ GEM rspec-mocks (~> 3.9) rspec-support (~> 3.9) rspec-support (3.9.3) - rubocop (1.0.0) + rubocop (1.2.0) parallel (~> 1.10) parser (>= 2.7.1.5) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8) rexml - rubocop-ast (>= 0.6.0) + rubocop-ast (>= 1.0.1) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (1.0.0) + rubocop-ast (1.1.1) parser (>= 2.7.1.5) rubocop-performance (1.8.1) rubocop (>= 0.87.0) @@ -244,7 +261,9 @@ GEM selenium-webdriver (3.142.7) childprocess (>= 0.5, < 4.0) rubyzip (>= 1.2.2) - simplecov (0.19.0) + shoulda-matchers (4.4.1) + activesupport (>= 4.2.0) + simplecov (0.19.1) docile (~> 1.1) simplecov-html (~> 0.11) simplecov-html (0.12.3) @@ -261,8 +280,8 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - standard (0.8) - rubocop (= 1.0.0) + standard (0.9.0) + rubocop (= 1.2.0) rubocop-performance (= 1.8.1) thor (1.0.1) thread_safe (0.3.6) @@ -270,13 +289,16 @@ GEM turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) - tzinfo (1.2.7) + tzinfo (1.2.8) thread_safe (~> 0.1) uglifier (4.2.0) execjs (>= 0.3.0, < 3) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.7) unicode-display_width (1.7.0) uniform_notifier (1.13.0) - web-console (4.0.4) + web-console (4.1.0) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) @@ -286,7 +308,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.4.0) + zeitwerk (2.4.1) PLATFORMS ruby @@ -294,21 +316,23 @@ PLATFORMS DEPENDENCIES better_errors bootsnap (>= 1.1.0) - bootstrap (>= 4.3.1) brakeman bullet byebug capybara (>= 2.15) + climate_control coffee-rails (~> 5.0) + contentful (~> 2.15) database_cleaner dotenv-rails factory_bot_rails faker + govuk_design_system_formbuilder (~> 2.1) high_voltage jbuilder (~> 2.5) jquery-rails launchy - listen (>= 3.0.5, < 3.3) + listen (>= 3.0.5, < 3.4) mini_racer pg pry @@ -319,6 +343,7 @@ DEPENDENCIES rspec-rails sass-rails (~> 6.0) selenium-webdriver + shoulda-matchers simplecov spring spring-commands-rspec @@ -330,7 +355,7 @@ DEPENDENCIES web-console (>= 3.3.0) RUBY VERSION - ruby 2.6.3p62 + ruby 2.6.6p146 BUNDLED WITH 2.1.4 diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..08545f285 --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: bundle exec puma -C config/puma.rb +release: rake db:migrate diff --git a/README.md b/README.md index d1b569a75..d5852840e 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,31 @@ -# Using this template +# Buy for your school -1. Search for `TODO` across the repository to customise the template to the new - project -1. Be aware of [dxw RFCs](https://github.com/dxw/tech-team-rfcs), especially - those that have not resulted in a default code change in this repository: - - [rfc-013-use-docker-to-deploy-and-run-applications-in-containers](https://github.com/dxw/tech-team-rfcs/blob/master/rfc-013-use-docker-to-deploy-and-run-applications-in-containers.md) - -TODO: Remove this section from the README once complete - ---- - -# Rails Template - -TODO: replace README header with project name - -TODO: Add a summary of who the application is for and what it will do. +A service to help school buying professionals create tender documents that comply with the relevant government policy. These tender documents can then be used to start a procurement process saving schools time and money. ## Getting started +1. `brew install postgres` +1. `brew services start postgres` +1. `createuser postgres --super` 1. copy `/.env.example` into `/.env.development.local`. Our intention is that the example should include enough to get the application started quickly. If this is not the case, please ask another developer for a copy of their `/.env.development.local` file. - -TODO: Add getting started steps +1. `rbenv install 2.6.6 && rbenv local 2.6.6` +1. `gem install bundle` +1. `bundle` +1. `rake db:setup && RAILS_ENV=test rake db:setup` +1. `rails server` +1. Visit http://localhost:3000 ## Running the tests -TODO: Add testing instructions +### The whole test suite + +`bundle exec rake` + +### RSpec only + +`bundle exec rspec` ## Running Brakeman @@ -48,8 +47,7 @@ requests should not be merged before any relevant updates are made. ## Releasing changes -When making a new release, update the [changelog](CHANGELOG.md) in the release -pull request. +When making a new release, follow the [release process](doc/release-process.md). ## Architecture decision records diff --git a/app.json b/app.json new file mode 100644 index 000000000..102ccf075 --- /dev/null +++ b/app.json @@ -0,0 +1,34 @@ +{ + "repository": "https://github.com/DFE-Digital/buy-for-your-school", + "buildpacks": [ + { + "url": "heroku/nodejs" + }, + { + "url": "heroku/ruby" + } + ], + "addons": [ + "heroku-postgresql" + ], + "env": { + "SECRET_KEY_BASE": { + "generator": "secret" + }, + "RAILS_ENV": { + "value": "production" + }, + "WEB_CONCURRENCY": { + "value": 2 + }, + "CONTENTFUL_URL": { + "value": "cdn.contentful.com" + }, + "CONTENTFUL_ENVIRONMENT": { + "value": "master" + }, + "CONTENTFUL_PREVIEW_APP": { + "value": "false" + } + } +} diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 6ff5cd22a..dce990936 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,9 +10,4 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // -//= require jquery3 -//= require popper -//= require rails-ujs -//= require turbolinks -//= require bootstrap-sprockets -//= require_tree . +//= require govuk-frontend/govuk/all.js diff --git a/app/assets/stylesheets/1st_load_framework.css.scss b/app/assets/stylesheets/1st_load_framework.css.scss deleted file mode 100644 index 544d7abfe..000000000 --- a/app/assets/stylesheets/1st_load_framework.css.scss +++ /dev/null @@ -1,45 +0,0 @@ -// import the CSS framework -// Do not use *= require in Sass or your other stylesheets will not be able to access the Bootstrap mixins and variables. -@import "bootstrap"; - -// make all images responsive by default -img { - @extend .img-fluid; - margin: 0 auto; -} -// override for the 'Home' navigation link -.navbar-brand { - font-size: inherit; - } - -// THESE ARE EXAMPLES YOU CAN MODIFY -// create your own classes -// to make views framework-neutral -.column { - @extend .col-md-6; - text-align: center; -} -.form { - @extend .col-md-6; -} -.form-centered { - @extend .col-md-6; - text-align: center; -} -.submit { - @extend .btn; - @extend .btn-primary; - @extend .btn-lg; -} - -// apply styles to HTML elements -// to make views framework-neutral -main { - @extend .container; - margin-top: 30px; // accommodate the navbar -} - -section { - @extend .row; - margin-top: 20px; -} diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index d05ea0f51..89a3ce563 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -13,3 +13,12 @@ *= require_tree . *= require_self */ + +$govuk-font-url-function: "image-url"; +$govuk-image-url-function: "font-url"; + +@import "govuk-frontend/govuk/all"; + +.preview-tag { + background-color: $govuk-error-colour; +} diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb new file mode 100644 index 000000000..ede7e0f00 --- /dev/null +++ b/app/controllers/answers_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AnswersController < ApplicationController + def create + @plan = Plan.find(plan_id) + @question = Question.find(question_id) + + @answer = Answer.new(answer_params) + @answer.response.capitalize! + @answer.question = @question + if @answer.valid? + @answer.save + if @plan.next_entry_id.present? + redirect_to new_plan_question_path(@plan) + else + redirect_to plan_path(@plan) + end + else + render "questions/new.#{@question.contentful_type}" + end + end + + private + + def plan_id + params[:plan_id] + end + + def question_id + params[:question_id] + end + + def answer_params + params.require(:answer).permit(:response) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cb66bbd25..ce505faf3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ApplicationController < ActionController::Base + default_form_builder GOVUKDesignSystemFormBuilder::FormBuilder + def health_check render json: {rails: "OK"}, status: :ok end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb new file mode 100644 index 000000000..4a1e3f7e2 --- /dev/null +++ b/app/controllers/plans_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class PlansController < ApplicationController + def new + plan = Plan.create(category: "catering", next_entry_id: ENV["CONTENTFUL_PLANNING_START_ENTRY_ID"]) + redirect_to new_plan_question_path(plan) + end + + def show + @plan = Plan.includes(questions: [:answer]).find(plan_id) + end + + private + + def plan_id + params[:id] + end +end diff --git a/app/controllers/preview/entries_controller.rb b/app/controllers/preview/entries_controller.rb new file mode 100644 index 000000000..6b91edfa5 --- /dev/null +++ b/app/controllers/preview/entries_controller.rb @@ -0,0 +1,12 @@ +class Preview::EntriesController < ApplicationController + def show + @plan = Plan.create(category: "catering", next_entry_id: entry_id) + redirect_to new_plan_question_path(@plan) + end + + private + + def entry_id + params[:id] + end +end diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb new file mode 100644 index 000000000..a0b090a54 --- /dev/null +++ b/app/controllers/questions_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class QuestionsController < ApplicationController + rescue_from GetContentfulEntry::EntryNotFound do |exception| + render "errors/contentful_entry_not_found", status: 500 + end + + rescue_from CreatePlanningQuestion::UnexpectedContentfulModel do |exception| + render "errors/unexpected_contentful_model", status: 500 + end + + rescue_from CreatePlanningQuestion::UnexpectedContentfulQuestionType do |exception| + render "errors/unexpected_contentful_question_type", status: 500 + end + + def new + @plan = Plan.find(plan_id) + + redirect_to plan_path(@plan) unless @plan.next_entry_id.present? + + contentful_entry = GetContentfulEntry.new(entry_id: @plan.next_entry_id).call + @question, @answer = CreatePlanningQuestion.new( + plan: @plan, contentful_entry: contentful_entry + ).call + + render "new.#{@question.contentful_type}" + end + + private + + def plan_id + params[:plan_id] + end +end diff --git a/app/controllers/visitors_controller.rb b/app/controllers/visitors_controller.rb deleted file mode 100644 index 47970a70a..000000000 --- a/app/controllers/visitors_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class VisitorsController < ApplicationController -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 15b06f0f6..df1e0c629 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,4 +1,17 @@ # frozen_string_literal: true module ApplicationHelper + def custom_banner_tag_class + return "preview-tag" if ENV["CONTENTFUL_PREVIEW_APP"].eql?("true") + end + + def banner_tag + return I18n.t("banner.preview.tag") if ENV["CONTENTFUL_PREVIEW_APP"].eql?("true") + I18n.t("banner.beta.tag") + end + + def banner_message + return I18n.t("banner.preview.message") if ENV["CONTENTFUL_PREVIEW_APP"].eql?("true") + I18n.t("banner.beta.message") + end end diff --git a/app/models/answer.rb b/app/models/answer.rb new file mode 100644 index 000000000..334c51cc6 --- /dev/null +++ b/app/models/answer.rb @@ -0,0 +1,6 @@ +class Answer < ApplicationRecord + self.implicit_order_column = "created_at" + belongs_to :question + + validates :response, presence: true +end diff --git a/app/models/plan.rb b/app/models/plan.rb new file mode 100644 index 000000000..26226cc61 --- /dev/null +++ b/app/models/plan.rb @@ -0,0 +1,4 @@ +class Plan < ApplicationRecord + self.implicit_order_column = "created_at" + has_many :questions +end diff --git a/app/models/question.rb b/app/models/question.rb new file mode 100644 index 000000000..5aef845b2 --- /dev/null +++ b/app/models/question.rb @@ -0,0 +1,9 @@ +class Question < ApplicationRecord + self.implicit_order_column = "created_at" + belongs_to :plan + has_one :answer + + def radio_options + options.map { |option| OpenStruct.new(id: option.downcase, name: option.titleize) } + end +end diff --git a/app/services/create_planning_question.rb b/app/services/create_planning_question.rb new file mode 100644 index 000000000..ab7f8bcec --- /dev/null +++ b/app/services/create_planning_question.rb @@ -0,0 +1,105 @@ +class CreatePlanningQuestion + class UnexpectedContentfulModel < StandardError; end + class UnexpectedContentfulQuestionType < StandardError; end + + ALLOWED_CONTENTFUL_MODELS = %w[question].freeze + ALLOWED_CONTENTFUL_QUESTION_TYPES = ["radios", "short_text"].freeze + + attr_accessor :plan, :contentful_entry + def initialize(plan:, contentful_entry:) + self.plan = plan + self.contentful_entry = contentful_entry + end + + def call + if unexpected_contentful_model? + send_rollbar_warning + raise UnexpectedContentfulModel + end + + if unexpected_contentful_question_type? + send_rollbar_warning + raise UnexpectedContentfulQuestionType + end + + question = Question.create( + title: title, + help_text: help_text, + contentful_type: question_type, + options: options, + raw: raw, + plan: plan + ) + + plan.update(next_entry_id: next_entry_id) + + [question, Answer.new] + end + + private + + def content_entry_id + contentful_entry.id + end + + def content_model + contentful_entry.content_type.id + end + + def expected_contentful_model? + ALLOWED_CONTENTFUL_MODELS.include?(content_model) + end + + def unexpected_contentful_model? + !expected_contentful_model? + end + + def expected_contentful_question_type? + ALLOWED_CONTENTFUL_QUESTION_TYPES.include?(question_type) + end + + def unexpected_contentful_question_type? + !expected_contentful_question_type? + end + + def title + contentful_entry.title + end + + def help_text + return nil unless contentful_entry.respond_to?(:help_text) + contentful_entry.help_text + end + + def options + return nil unless contentful_entry.respond_to?(:options) + contentful_entry.options + end + + def question_type + contentful_entry.type.tr(" ", "_") + end + + def raw + contentful_entry.raw + end + + def next_entry_id + return nil unless contentful_entry.respond_to?(:next) + contentful_entry.next.id + end + + def send_rollbar_warning + Rollbar.warning( + "An unexpected Contentful type was found", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: content_entry_id, + content_model: content_model, + question_type: question_type, + allowed_content_models: ALLOWED_CONTENTFUL_MODELS.join(", "), + allowed_question_types: ALLOWED_CONTENTFUL_QUESTION_TYPES.join(", ") + ) + end +end diff --git a/app/services/get_contentful_entry.rb b/app/services/get_contentful_entry.rb new file mode 100644 index 000000000..c24565eee --- /dev/null +++ b/app/services/get_contentful_entry.rb @@ -0,0 +1,43 @@ +require "contentful" + +class GetContentfulEntry + class EntryNotFound < StandardError; end + + attr_accessor :entry_id + + def initialize(entry_id:) + self.entry_id = entry_id + end + + def call + response = contentful_client.entry(entry_id) + + if response.nil? + send_rollbar_warning + raise EntryNotFound + end + + response + end + + private + + def contentful_client + @contentful_client ||= Contentful::Client.new( + api_url: ENV["CONTENTFUL_URL"], + space: ENV["CONTENTFUL_SPACE"], + environment: ENV["CONTENTFUL_ENVIRONMENT"], + access_token: ENV["CONTENTFUL_ACCESS_TOKEN"] + ) + end + + def send_rollbar_warning + Rollbar.warning( + "The following Contentful entry identifier could not be found.", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: entry_id + ) + end +end diff --git a/app/views/errors/contentful_entry_not_found.html.erb b/app/views/errors/contentful_entry_not_found.html.erb new file mode 100644 index 000000000..182b48168 --- /dev/null +++ b/app/views/errors/contentful_entry_not_found.html.erb @@ -0,0 +1,7 @@ +<%= content_for :title, I18n.t("errors.contentful_entry_not_found.page_title") %> + +

<%= I18n.t("errors.contentful_entry_not_found.page_title") %>

+

+ <%= I18n.t("errors.contentful_entry_not_found.page_body") %> +

+ diff --git a/app/views/errors/unexpected_contentful_model.html.erb b/app/views/errors/unexpected_contentful_model.html.erb new file mode 100644 index 000000000..a259b2cfc --- /dev/null +++ b/app/views/errors/unexpected_contentful_model.html.erb @@ -0,0 +1,6 @@ +<%= content_for :title, I18n.t("errors.unexpected_contentful_model.page_title") %> + +

<%= I18n.t("errors.unexpected_contentful_model.page_title") %>

+

+ <%= I18n.t("errors.unexpected_contentful_model.page_body") %> +

diff --git a/app/views/errors/unexpected_contentful_question_type.html.erb b/app/views/errors/unexpected_contentful_question_type.html.erb new file mode 100644 index 000000000..a259b2cfc --- /dev/null +++ b/app/views/errors/unexpected_contentful_question_type.html.erb @@ -0,0 +1,6 @@ +<%= content_for :title, I18n.t("errors.unexpected_contentful_model.page_title") %> + +

<%= I18n.t("errors.unexpected_contentful_model.page_title") %>

+

+ <%= I18n.t("errors.unexpected_contentful_model.page_body") %> +

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 717387601..756796da6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,22 +1,94 @@ - + + - + - <%= content_for?(:title) ? yield(:title) : "Rails Template" %> + <%= content_for?(:title) ? yield(:title) : "Buy for your school" %> - " name="description"/> + + + + + + <%= favicon_link_tag %> + <%= favicon_link_tag 'govuk-mask-icon.svg', rel: 'mask-icon', type: 'image/svg+xml', color: '#0b0c0c' %> + <%= favicon_link_tag 'govuk-apple-touch-icon-180x180.png', rel: 'apple-touch-icon', type: 'image/png', sizes: '180x180' %> + <%= favicon_link_tag 'govuk-apple-touch-icon-167x167.png', rel: 'apple-touch-icon', type: 'image/png', sizes: '167x167' %> + <%= favicon_link_tag 'govuk-apple-touch-icon-152x152.png', rel: 'apple-touch-icon', type: 'image/png', sizes: '152x152' %> + <%= favicon_link_tag 'govuk-apple-touch-icon.png', rel: 'apple-touch-icon', type: 'image/png' %> + <%= stylesheet_link_tag "application", media: 'all', "data-turbolinks-track" => "reload" %> - <%= javascript_include_tag "application", "data-turbolinks-track" => "reload" %> <%= csrf_meta_tags %> + + - -
- <%= render "layouts/navigation" %> + + + + + Skip to main content + + -
- <%= render "layouts/messages" %> - <%= yield %> -
+ +
+
+

+ + + <%= banner_message %> + +

+
+
+ <%= yield %> +
+
+ + + + <%= javascript_include_tag "application", "data-turbolinks-track" => "reload" %> + + diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb deleted file mode 100644 index 996fa0c80..000000000 --- a/app/views/pages/about.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<% content_for :title do %>About<% end %> -

About the Website

-

-This web application was created with -<%= link_to('Rails Composer', 'http://railsapps.github.io/rails-composer/') %> -from the <%= link_to('RailsApps project', 'http://railsapps.github.io/') %>. -

diff --git a/app/views/pages/planning_start_page.html.erb b/app/views/pages/planning_start_page.html.erb new file mode 100644 index 000000000..314a65b6a --- /dev/null +++ b/app/views/pages/planning_start_page.html.erb @@ -0,0 +1,18 @@ +<%= content_for :title, I18n.t("planning.start_page.page_title") %> + +

<%= I18n.t("planning.start_page.page_title") %>

+ +

<%= I18n.t("planning.start_page.overview_title") %>

+<% I18n.t("planning.start_page.overview_body").each do |paragraph| %> +

<%= paragraph %>

+<% end %> + +

<%= I18n.t("planning.start_page.before_you_start_title") %>

+

<%= I18n.t("planning.start_page.before_you_start_list_title") %>

+
    + <% I18n.t("planning.start_page.before_you_start_list_items").each do |list_item| %> +
  • <%= list_item %>
  • + <% end %> +
+

<%= I18n.t("planning.start_page.before_you_start_body") %>

+<%= link_to I18n.t("generic.button.start"), new_plan_path, class: "govuk-button" %> diff --git a/app/views/plans/show.html.erb b/app/views/plans/show.html.erb new file mode 100644 index 000000000..699c0bb7c --- /dev/null +++ b/app/views/plans/show.html.erb @@ -0,0 +1,11 @@ +<%= content_for :title, @plan.category.capitalize %> + +

<%= @plan.category.capitalize %>

+
+ <% @plan.questions.each do |question| %> +
+
<%= question.title %>
+
<%= question.answer.response %>
+
+ <% end %> +
diff --git a/app/views/questions/new.radios.html.erb b/app/views/questions/new.radios.html.erb new file mode 100644 index 000000000..668e6d5e3 --- /dev/null +++ b/app/views/questions/new.radios.html.erb @@ -0,0 +1,15 @@ +<%= content_for :title, @question.title %> + +<%= form_for @answer, url: plan_question_answers_path(@plan, @question), method: "post" do |f| %> + <%= f.hidden_field :response, value: nil %> + <%= f.govuk_collection_radio_buttons :response, + @question.radio_options, + :id, + ->(option) { option.name }, + :description, + legend: { text: @question.title, size: 'xl' }, + hint: { text: @question.help_text }, + inline: false + %> + <%= f.submit I18n.t("generic.button.soft_finish"), class: "govuk-button" %> +<% end %> diff --git a/app/views/questions/new.short_text.html.erb b/app/views/questions/new.short_text.html.erb new file mode 100644 index 000000000..93ef2726e --- /dev/null +++ b/app/views/questions/new.short_text.html.erb @@ -0,0 +1,9 @@ +<%= content_for :title, @question.title %> + +<%= form_for @answer, url: plan_question_answers_path(@plan, @question), method: "post" do |f| %> + <%= f.govuk_text_field :response, + label: { text: @question.title, size: 'xl' }, + width: "one-third" + %> + <%= f.submit I18n.t("generic.button.soft_finish"), class: "govuk-button" %> +<% end %> diff --git a/app/views/visitors/index.html.erb b/app/views/visitors/index.html.erb deleted file mode 100644 index 7a28e8706..000000000 --- a/app/views/visitors/index.html.erb +++ /dev/null @@ -1 +0,0 @@ -

Welcome

diff --git a/config/application.rb b/config/application.rb index d73f2c3ba..4ba252194 100644 --- a/config/application.rb +++ b/config/application.rb @@ -8,8 +8,7 @@ # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) -# TODO: Name the application -module RailsTemplate +module BuyForYourSchool class Application < Rails::Application config.generators do |g| g.test_framework :rspec, diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index ba194685a..80ed0077c 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -7,6 +7,13 @@ # Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path + +# Add the GOVUK Frontend images path +Rails.application.config.assets.paths << Rails.root.join("node_modules", "govuk-frontend", "govuk", "assets", "images") + +# Add the GOVUK Frontend fonts path +Rails.application.config.assets.paths << Rails.root.join("node_modules", "govuk-frontend", "govuk", "assets", "fonts") + # Add Yarn node_modules folder to the asset load path. Rails.application.config.assets.paths << Rails.root.join("node_modules") @@ -14,3 +21,17 @@ # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. # Rails.application.config.assets.precompile += %w( admin.js admin.css ) + +# Add GOVUK assets by name, these are assets not loaded via sass +Rails.application.config.assets.precompile += [ + "favicon.ico", + "govuk-apple-touch-icon-152x152.png", + "govuk-apple-touch-icon-167x167.png", + "govuk-apple-touch-icon-180x180.png", + "govuk-apple-touch-icon.png", + "govuk-crest-2x.png", + "govuk-crest.png", + "govuk-logotype-crown.png", + "govuk-mask-icon.svg", + "govuk-opengraph-image.png" +] diff --git a/config/initializers/dotenv.rb b/config/initializers/dotenv.rb index a5670514d..a51a11bad 100644 --- a/config/initializers/dotenv.rb +++ b/config/initializers/dotenv.rb @@ -1,3 +1,12 @@ # Require environment variables on initialisation # https://github.com/bkeepers/dotenv#required-keys -Dotenv.require_keys if defined?(Dotenv) +if defined?(Dotenv) + Dotenv.require_keys( + "CONTENTFUL_URL", + "CONTENTFUL_SPACE", + "CONTENTFUL_ENVIRONMENT", + "CONTENTFUL_ACCESS_TOKEN", + "CONTENTFUL_PLANNING_START_ENTRY_ID", + "CONTENTFUL_PREVIEW_APP" + ) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index cf9b342d0..edc4cc40b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,33 +1,41 @@ -# Files in the config/locales directory are used for internationalization -# and are automatically loaded by Rails. If you want to use locales other -# than English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t 'hello' -# -# In views, this is aliased to just `t`: -# -# <%= t('hello') %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# The following keys must be escaped otherwise they will not be retrieved by -# the default I18n backend: -# -# true, false, on, off, yes, no -# -# Instead, surround them with single quotes. -# -# en: -# 'true': 'foo' -# -# To learn more, please read the Rails Internationalization guide -# available at https://guides.rubyonrails.org/i18n.html. - en: - hello: "Hello world" + generic: + button: + start: "Continue" + soft_finish: "Save and continue later" + banner: + beta: + tag: "beta" + message: "This is a new service - your feedback will help us to improve it." + preview: + tag: "preview" + message: "This environment is only for previewing Contentful changes before publishing." + planning: + start_page: + page_title: "Identify the level of support you need for what you're buying" + overview_title: "Overview" + overview_body: + - "Get the right level of support you need to buy goods and services for your school." + - "By telling us about your experience and confidence levels in buying the goods or service, we will tailor the guidance that will be shown to you on screen." + - "More guidance will be shown if you need more support. For experienced buyers, the guidance will be hidden, but will be accessible to you within each page if you need it." + before_you_start_title: "Before you start" + before_you_start_list_title: "Consider:" + before_you_start_list_items: + - any previous experience in buying the goods or services you need + - your level of understanding of the procurement process + - your school's procurement policy + - support you have access to in your school + - information on the current contract + before_you_start_body: "It will take around 3 minutes to complete the process" + errors: + contentful_entry_not_found: "An unexpected error occurred. The starting question has been revoked by the content team." + errors: + contentful_entry_not_found: + page_title: "An unexpected error occurred" + page_body: "The service has had a problem trying to retrieve the required question. The team have been notified of this problem and you should be able to retry shortly." + unexpected_contentful_model: + page_title: "An unexpected error occurred" + page_body: "The service has had a problem trying to retrieve the required question. The team have been notified of this problem and you should be able to retry shortly." + unexpected_contentful_question_type: + page_title: "An unexpected error occurred" + page_body: "The service has had a problem trying to retrieve the required question. The team have been notified of this problem and you should be able to retry shortly." diff --git a/config/puma.rb b/config/puma.rb index a8adec5d1..c7c69681a 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -22,15 +22,15 @@ # the concurrency of the application would be max `threads` * `workers`. # Workers do not work on JRuby or Windows (both of which do not support # processes). -# -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } + +workers ENV.fetch("WEB_CONCURRENCY", 2) # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write # process behavior so workers use less memory. -# -# preload_app! + +preload_app! # Allow puma to be restarted by `rails restart` command. plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index 38780c79f..df4fc10b0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,5 +2,15 @@ Rails.application.routes.draw do get "health_check" => "application#health_check" - root to: "visitors#index" + root to: "high_voltage/pages#show", id: "planning_start_page" + + resources :plans, only: [:new, :show] do + resources :questions, only: [:new] do + resources :answers, only: [:create] + end + end + + namespace :preview do + resources :entries, only: [:show] + end end diff --git a/db/migrate/20201026154740_enable_uuid.rb b/db/migrate/20201026154740_enable_uuid.rb new file mode 100644 index 000000000..8c10331e3 --- /dev/null +++ b/db/migrate/20201026154740_enable_uuid.rb @@ -0,0 +1,5 @@ +class EnableUuid < ActiveRecord::Migration[6.0] + def change + enable_extension "pgcrypto" unless extension_enabled?("pgcrypto") + end +end diff --git a/db/migrate/20201027160912_create_plan_table.rb b/db/migrate/20201027160912_create_plan_table.rb new file mode 100644 index 000000000..eb50fe589 --- /dev/null +++ b/db/migrate/20201027160912_create_plan_table.rb @@ -0,0 +1,8 @@ +class CreatePlanTable < ActiveRecord::Migration[6.0] + def change + create_table :plans, id: :uuid do |t| + t.string :category, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20201027163306_create_question_table.rb b/db/migrate/20201027163306_create_question_table.rb new file mode 100644 index 000000000..585e3e02c --- /dev/null +++ b/db/migrate/20201027163306_create_question_table.rb @@ -0,0 +1,13 @@ +class CreateQuestionTable < ActiveRecord::Migration[6.0] + def change + create_table :questions, id: :uuid do |t| + t.references :plan, type: :uuid + t.string :title, null: false + t.string :help_text + t.string :contentful_type, null: false + t.string :options, array: true + t.binary :raw, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20201028103219_create_answer_table.rb b/db/migrate/20201028103219_create_answer_table.rb new file mode 100644 index 000000000..6c21fe950 --- /dev/null +++ b/db/migrate/20201028103219_create_answer_table.rb @@ -0,0 +1,9 @@ +class CreateAnswerTable < ActiveRecord::Migration[6.0] + def change + create_table :answers, id: :uuid do |t| + t.references :question, type: :uuid + t.string :response, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20201102161431_add_next_entry_id_to_plan.rb b/db/migrate/20201102161431_add_next_entry_id_to_plan.rb new file mode 100644 index 000000000..eeaa015cb --- /dev/null +++ b/db/migrate/20201102161431_add_next_entry_id_to_plan.rb @@ -0,0 +1,5 @@ +class AddNextEntryIdToPlan < ActiveRecord::Migration[6.0] + def change + add_column :plans, :next_entry_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2611543b3..bb81f990e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,17 +2,45 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `rails +# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 0) do +ActiveRecord::Schema.define(version: 2020_11_02_161431) do # These are extensions that must be enabled in order to support this database + enable_extension "pgcrypto" enable_extension "plpgsql" + create_table "answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "question_id" + t.string "response", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["question_id"], name: "index_answers_on_question_id" + end + + create_table "plans", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "category", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "next_entry_id" + end + + create_table "questions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "plan_id" + t.string "title", null: false + t.string "help_text" + t.string "contentful_type", null: false + t.string "options", array: true + t.binary "raw", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["plan_id"], name: "index_questions_on_plan_id" + end + end diff --git a/doc/release-process.md b/doc/release-process.md new file mode 100644 index 000000000..6ade9bdff --- /dev/null +++ b/doc/release-process.md @@ -0,0 +1,55 @@ +# Release process + +To create a new release and deploy it to production, follow this process: + +## 1. Create the release pull request + +1. Make a branch from `develop` called `release-xxx` (where `xxx` is the sequential release number) +1. Move all features from the `Unreleased` section of [`CHANGELOG.md`](../CHANGELOG.md) to a new heading with the release number linked to a diff of the two latest versions, together with the date in the following format: + + ```markdown + ## [Release XXX] - 2020-10-27 + + ... + + [release xxx]: + https://github.com/DFE-Digital/buy-for-your-school/compare/previous-release...release-xxx + ``` + +1. Create a commit for the release, including the changes for the release in the commit message +1. Push the branch +1. Open a pull request to merge the new branch to `develop` and get it reviewed + +## 2. Confirm the release and review the pull request + +The pull request should be reviewed to confirm that the changes currently are safe to ship and that [`CHANGELOG.md`](../CHANGELOG.md) accurately reflects the changes included in the release: + +1. Confirm the release with any relevant people (for example the product owner) +1. Think about any dependencies that also need considering: dependent parts of the service that also need updating; environment variables that need changing/adding; third-party services that need to be set up/updated +1. Merge the pull request with `develop` +1. Merge the resultant merge commit into `main` + +## 3. Push the tag + +Once the pull request has been merged, create a tag against the `main` branch commit in the format `release-xxx` (zero-padded again) and push it to GitHub: + +```sh +git tag release-xxx merge-commit-for-release +git push origin refs/tags/release-xxx +``` + +## 4. Validate the deployment + +Check in the production environment to make sure the deployment has gone as expected, and that no problems have arisen during build or release stages. + +## 5. Update Trello + +Features in the "Approved" column should be moved to "Done". + +## 6. Announce the release in Slack + +Let everybody know that a new release has been shipped. + +The usual place to do this is #sct-buy-for-your-school, with a message like: + +> 🚢 Release # for Buy for Your School is now live. Changes in this release: [link to changelog] 🚀 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..c926eaa2e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "rails-template", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "govuk-frontend": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-3.9.1.tgz", + "integrity": "sha512-ouOoDUj0QwDA4uCHIBkGCFMpORuTRcSuDscOrz7V1PBcOecntLglxJAZAuNm+j2sPo7anoScHU0ZSeE2QIoeAg==" + } + } +} diff --git a/package.json b/package.json index 3fba2722b..a5ea2767c 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,7 @@ { "name": "rails-template", "private": true, - "dependencies": {} + "dependencies": { + "govuk-frontend": "^3.9.1" + } } diff --git a/spec/factories/answer.rb b/spec/factories/answer.rb new file mode 100644 index 000000000..0b6d0fded --- /dev/null +++ b/spec/factories/answer.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :answer do + association :question, factory: :question + + response { "Green" } + end +end diff --git a/spec/factories/plan.rb b/spec/factories/plan.rb new file mode 100644 index 000000000..81dd8ef0a --- /dev/null +++ b/spec/factories/plan.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :plan do + category { "catering" } + next_entry_id { "47EI2X2T5EDTpJX9WjRR9p" } + + trait :catering do + category { "catering" } + end + end +end diff --git a/spec/factories/question.rb b/spec/factories/question.rb new file mode 100644 index 000000000..0610c305d --- /dev/null +++ b/spec/factories/question.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :question do + title { "What is your favourite colour?" } + help_text { "Choose the primary colour closest to your choice" } + raw { "{\"sys\":{}}" } + + association :plan, factory: :plan + + trait :radio do + options { ["Red", "Green", "Blue"] } + contentful_type { "radios" } + end + end +end diff --git a/spec/features/visitors/about_page_spec.rb b/spec/features/visitors/about_page_spec.rb deleted file mode 100644 index c70415dd1..000000000 --- a/spec/features/visitors/about_page_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# Feature: 'About' page -# As a visitor -# I want to visit an 'about' page -# So I can learn more about the website -feature "About page" do - # Scenario: Visit the 'about' page - # Given I am a visitor - # When I visit the 'about' page - # Then I see "About the Website" - scenario "Visit the about page" do - visit "pages/about" - expect(page).to have_content "About the Website" - end -end diff --git a/spec/features/visitors/anyone_can_complete_a_planning_journey_spec.rb b/spec/features/visitors/anyone_can_complete_a_planning_journey_spec.rb new file mode 100644 index 000000000..4988ef256 --- /dev/null +++ b/spec/features/visitors/anyone_can_complete_a_planning_journey_spec.rb @@ -0,0 +1,156 @@ +require "rails_helper" + +feature "Anyone can start the planning journey" do + scenario "Start page includs a call to action" do + stub_get_contentful_entry + + visit root_path + + click_on(I18n.t("generic.button.start")) + + expect(page).to have_content("Which service do you need?") + expect(page).to have_content("Tell us which service you need.") + expect(page).to have_content("Catering") + expect(page).to have_content("Cleaning") + + choose("Catering") + + click_on(I18n.t("generic.button.soft_finish")) + end + + scenario "an answer must be provided" do + stub_get_contentful_entry + + visit root_path + + click_on(I18n.t("generic.button.start")) + + # Omit a choice + + click_on(I18n.t("generic.button.soft_finish")) + + expect(page).to have_content("can't be blank") + end + + context "when the starter question has a next question" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "47EI2X2T5EDTpJX9WjRR9p" + ) do + example.run + end + end + + scenario "there are 2 questions to answer" do + visit root_path + + stub_get_contentful_entry( + entry_id: "47EI2X2T5EDTpJX9WjRR9p", + fixture_filename: "has-next-question-example.json" + ) + click_on(I18n.t("generic.button.start")) + + choose("Catering") + + stub_get_contentful_entry( + entry_id: "5lYcZs1ootDrOnk09LDLZg", + fixture_filename: "no-next-question-example.json" + ) + click_on(I18n.t("generic.button.soft_finish")) + + choose("Stationary") + click_on(I18n.t("generic.button.soft_finish")) + + expect(page).to have_content("Catering") + expect(page).to have_content("Stationary") + end + end + + scenario "a Contentful entry_id does not exist" do + contentful_client = stub_contentful_client + + allow(contentful_client).to receive(:entry) + .with(anything) + .and_raise(GetContentfulEntry::EntryNotFound.new("The following Contentful error could not be found: sss ")) + + visit root_path + + click_on(I18n.t("generic.button.start")) + + expect(page).to have_content(I18n.t("errors.contentful_entry_not_found.page_title")) + expect(page).to have_content(I18n.t("errors.contentful_entry_not_found.page_body")) + end + + context "when Contentful entry is of type short_text" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "hfjJgWRg4xiiiImwVRDtZ" + ) do + example.run + end + end + + scenario "user can answer using free text" do + stub_get_contentful_entry( + entry_id: "hfjJgWRg4xiiiImwVRDtZ", + fixture_filename: "short-text-question-example.json" + ) + + visit root_path + click_on(I18n.t("generic.button.start")) + + fill_in "answer[response]", with: "email@example.com" + click_on(I18n.t("generic.button.soft_finish")) + + expect(page).to have_content("Email@example") + end + end + + context "when Contentful entry model wasn't a type of question" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "6EKsv389ETYcQql3htK3Z2" + ) do + example.run + end + end + + scenario "returns an error message" do + stub_get_contentful_entry( + entry_id: "6EKsv389ETYcQql3htK3Z2", + fixture_filename: "an-unexpected-model-example.json" + ) + + visit root_path + + click_on(I18n.t("generic.button.start")) + + expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_title")) + expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_body")) + end + end + + context "when Contentful question entry wasn't an expected type" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "8as7df68uhasdnuasdf" + ) do + example.run + end + end + + scenario "returns an error message" do + stub_get_contentful_entry( + entry_id: "8as7df68uhasdnuasdf", + fixture_filename: "an-unexpected-question-type-example.json" + ) + + visit root_path + + click_on(I18n.t("generic.button.start")) + + expect(page).to have_content(I18n.t("errors.unexpected_contentful_question_type.page_title")) + expect(page).to have_content(I18n.t("errors.unexpected_contentful_question_type.page_body")) + end + end +end diff --git a/spec/features/visitors/anyone_can_see_a_planning_start_page_spec.rb b/spec/features/visitors/anyone_can_see_a_planning_start_page_spec.rb new file mode 100644 index 000000000..963546264 --- /dev/null +++ b/spec/features/visitors/anyone_can_see_a_planning_start_page_spec.rb @@ -0,0 +1,18 @@ +feature "Users can see a start page for planning their purchase" do + scenario "Start page content is shown on the root path" do + visit root_path + + expect(page).to have_content(I18n.t("planning.start_page.page_title")) + + expect(page).to have_content(I18n.t("planning.start_page.overview_title")) + I18n.t("planning.start_page.overview_body").each do |paragraph| + expect(page).to have_content(paragraph) + end + + expect(page).to have_content(I18n.t("planning.start_page.before_you_start_list_title")) + I18n.t("planning.start_page.before_you_start_list_items").each do |list_item| + expect(page).to have_content(list_item) + end + expect(page).to have_content(I18n.t("planning.start_page.before_you_start_body")) + end +end diff --git a/spec/features/visitors/anyone_can_see_the_service_banners_spec.rb b/spec/features/visitors/anyone_can_see_the_service_banners_spec.rb new file mode 100644 index 000000000..ccd85d3c3 --- /dev/null +++ b/spec/features/visitors/anyone_can_see_the_service_banners_spec.rb @@ -0,0 +1,25 @@ +feature "Users can see the service banners" do + scenario "A beta phase banner helps set expectations of the service" do + visit root_path + + expect(page).to have_content(I18n.t("banner.beta.tag")) + expect(page).to have_content(I18n.t("banner.beta.message")) + end + + context "when the app is configured as a Contenetful preview app" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PREVIEW_APP: "true" + ) do + example.run + end + end + + it "renders a preview banner" do + visit root_path + + expect(page).to have_content(I18n.t("banner.preview.tag")) + expect(page).to have_content(I18n.t("banner.preview.message")) + end + end +end diff --git a/spec/features/visitors/home_page_spec.rb b/spec/features/visitors/home_page_spec.rb deleted file mode 100644 index 0fa7c853e..000000000 --- a/spec/features/visitors/home_page_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# Feature: Home page -# As a visitor -# I want to visit a home page -# So I can learn more about the website -feature "Home page" do - # Scenario: Visit the home page - # Given I am a visitor - # When I visit the home page - # Then I see "Welcome" - scenario "visit the home page" do - visit root_path - expect(page).to have_content "Welcome" - end -end diff --git a/spec/fixtures/contentful/an-unexpected-model-example.json b/spec/fixtures/contentful/an-unexpected-model-example.json new file mode 100644 index 000000000..f213d6483 --- /dev/null +++ b/spec/fixtures/contentful/an-unexpected-model-example.json @@ -0,0 +1,35 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "jspwts36h1os" + } + }, + "id": "6EKsv389ETYcQql3htK3Z2", + "type": "Entry", + "createdAt": "2020-09-02T15:56:05.472Z", + "updatedAt": "2020-09-02T16:19:00.652Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 2, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "unmanagedPage" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/check-your-answers", + "type": "radios" + } +} diff --git a/spec/fixtures/contentful/an-unexpected-question-type-example.json b/spec/fixtures/contentful/an-unexpected-question-type-example.json new file mode 100644 index 000000000..1f02406eb --- /dev/null +++ b/spec/fixtures/contentful/an-unexpected-question-type-example.json @@ -0,0 +1,35 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "jspwts36h1os" + } + }, + "id": "8as7df68uhasdnuasdf", + "type": "Entry", + "createdAt": "2020-09-02T15:56:05.472Z", + "updatedAt": "2020-09-02T16:19:00.652Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 2, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/check-your-answers", + "type": "telepathy" + } +} diff --git a/spec/fixtures/contentful/has-next-question-example.json b/spec/fixtures/contentful/has-next-question-example.json new file mode 100644 index 000000000..f5993f55a --- /dev/null +++ b/spec/fixtures/contentful/has-next-question-example.json @@ -0,0 +1,48 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "jspwts36h1os" + } + }, + "id": "47EI2X2T5EDTpJX9WjRR9p", + "type": "Entry", + "createdAt": "2020-09-14T19:23:05.211Z", + "updatedAt": "2020-09-14T19:23:05.211Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/which-service", + "title": "Which service do you need?", + "helpText": "Tell us which service you need.", + "type": "radios", + "options": [ + "Catering", + "Cleaning" + ], + "next": { + "sys": { + "type": "Link", + "linkType": "Entry", + "id": "5lYcZs1ootDrOnk09LDLZg" + } + } + } +} diff --git a/spec/fixtures/contentful/no-next-question-example.json b/spec/fixtures/contentful/no-next-question-example.json new file mode 100644 index 000000000..c90cba585 --- /dev/null +++ b/spec/fixtures/contentful/no-next-question-example.json @@ -0,0 +1,41 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "jspwts36h1os" + } + }, + "id": "5lYcZs1ootDrOnk09LDLZg", + "type": "Entry", + "createdAt": "2020-09-14T19:23:05.211Z", + "updatedAt": "2020-09-14T19:23:05.211Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/which-goods", + "title": "Which goods do you need?", + "helpText": "Tell us which goods you need.", + "type": "radios", + "options": [ + "Stationary", + "IT supplies" + ] + } +} diff --git a/spec/fixtures/contentful/radio-question-example.json b/spec/fixtures/contentful/radio-question-example.json new file mode 100644 index 000000000..75f56ca5e --- /dev/null +++ b/spec/fixtures/contentful/radio-question-example.json @@ -0,0 +1,41 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "jspwts36h1os" + } + }, + "id": "1UjQurSOi5MWkcRuGxdXZS", + "type": "Entry", + "createdAt": "2020-09-07T10:56:40.585Z", + "updatedAt": "2020-09-14T22:16:54.633Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 7, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/which-service", + "title": "Which service do you need?", + "helpText": "Tell us which service you need.", + "type": "radios", + "options": [ + "Catering", + "Cleaning" + ] + } +} diff --git a/spec/fixtures/contentful/short-text-question-example.json b/spec/fixtures/contentful/short-text-question-example.json new file mode 100644 index 000000000..8ec9aa354 --- /dev/null +++ b/spec/fixtures/contentful/short-text-question-example.json @@ -0,0 +1,36 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "hfjJgWRg4xiiiImwVRDtZ", + "type": "Entry", + "createdAt": "2020-11-05T16:42:58.340Z", + "updatedAt": "2020-11-05T16:42:58.340Z", + "environment": { + "sys": { + "id": "master", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/what-email-address-did-you-use", + "title": "What email address did you use?", + "type": "short text" + } +} diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb new file mode 100644 index 000000000..f920635db --- /dev/null +++ b/spec/helpers/application_helper_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe ApplicationHelper, type: :helper do + describe "#custom_banner_tag_class" do + it "returns nil" do + expect(helper.custom_banner_tag_class).to eq(nil) + end + + context "when PREVIEW_APP is configured to true" do + around do |example| + ClimateControl.modify(CONTENTFUL_PREVIEW_APP: "true") do + example.run + end + end + + it "returns a css class for preview styling" do + expect(helper.custom_banner_tag_class).to eq("preview-tag") + end + end + end + + describe "#banner_tag" do + it "returns the beta tag by default" do + expect(helper.banner_tag).to eq(I18n.t("banner.beta.tag")) + end + + context "when PREVIEW_APP is configured to true" do + around do |example| + ClimateControl.modify(CONTENTFUL_PREVIEW_APP: "true") do + example.run + end + end + + it "returns the preview tag copy" do + expect(helper.banner_tag).to eq(I18n.t("banner.preview.tag")) + end + end + end + + describe "#banner_message" do + it "returns the beta message by default" do + expect(helper.banner_message).to eq(I18n.t("banner.beta.message")) + end + + context "when PREVIEW_APP is configured to true" do + around do |example| + ClimateControl.modify(CONTENTFUL_PREVIEW_APP: "true") do + example.run + end + end + + it "returns the preview message copy" do + expect(helper.banner_message).to eq(I18n.t("banner.preview.message")) + end + end + end +end diff --git a/spec/models/answer_spec.rb b/spec/models/answer_spec.rb new file mode 100644 index 000000000..663f40943 --- /dev/null +++ b/spec/models/answer_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +RSpec.describe Answer, type: :model do + it { should belong_to(:question) } + + it "captures the users response as a string" do + answer = build(:answer, response: "Yellow") + expect(answer.response).to eql("Yellow") + end + + describe "validations" do + it { is_expected.to validate_presence_of(:response) } + end +end diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb new file mode 100644 index 000000000..5777a67b8 --- /dev/null +++ b/spec/models/plan_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe Plan, type: :model do + it { should have_many(:questions) } + + it "captures the category" do + plan = build(:plan, :catering) + expect(plan.category).to eql("catering") + end + + it "stores an identifier for the next Contentful Entry" do + plan = build(:plan, :catering, next_entry_id: "47EI2X2T5EDTpJX9WjRR9p") + expect(plan.next_entry_id).to eql("47EI2X2T5EDTpJX9WjRR9p") + end +end diff --git a/spec/models/question_spec.rb b/spec/models/question_spec.rb new file mode 100644 index 000000000..27c8f0a3f --- /dev/null +++ b/spec/models/question_spec.rb @@ -0,0 +1,24 @@ +require "rails_helper" + +RSpec.describe Question, type: :model do + it { should belong_to(:plan) } + it "store the basic fields of a contentful response" do + question = build(:question, + :radio, + title: "foo", + help_text: "bar", + options: ["baz", "boo"]) + + expect(question.title).to eql("foo") + expect(question.help_text).to eql("bar") + expect(question.options).to eql(["baz", "boo"]) + end + + it "captures the raw contentful response" do + contentful_json_response = JSON("foo": {}) + question = build(:question, + raw: contentful_json_response) + + expect(question.raw).to eql("{\"foo\":{}}") + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 90ad2e452..9516a10c5 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -33,6 +33,8 @@ exit 1 end RSpec.configure do |config| + config.include ContentfulHelpers + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures config.fixture_path = "#{::Rails.root}/spec/fixtures" @@ -60,4 +62,11 @@ config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") + + Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end + end end diff --git a/spec/requests/entry_preview_spec.rb b/spec/requests/entry_preview_spec.rb new file mode 100644 index 000000000..101990656 --- /dev/null +++ b/spec/requests/entry_preview_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +RSpec.describe "Entry previews", type: :request do + it "creates a dummy plan and redirects to the question creation flow" do + entry_id = "123" + fake_plan = create(:plan) + expect(Plan).to receive(:create) + .with(category: anything, next_entry_id: entry_id) + .and_return(fake_plan) + + get "/preview/entries/#{entry_id}" + + expect(response).to have_http_status(:found) + expect(response).to redirect_to("/plans/#{fake_plan.id}/questions/new") + end +end diff --git a/spec/services/create_planning_question_spec.rb b/spec/services/create_planning_question_spec.rb new file mode 100644 index 000000000..0657ddccf --- /dev/null +++ b/spec/services/create_planning_question_spec.rb @@ -0,0 +1,151 @@ +require "rails_helper" + +RSpec.describe CreatePlanningQuestion do + describe "#call" do + context "when the new question is of type question" do + it "creates a local copy of the new question" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "radio-question-example.json" + ) + + question, _answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(question.title).to eq("Which service do you need?") + expect(question.help_text).to eq("Tell us which service you need.") + expect(question.contentful_type).to eq("radios") + expect(question.options).to eq(["Catering", "Cleaning"]) + expect(question.raw).to eq(fake_entry.raw) + end + + it "updates the plan with a new next_entry_id" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "has-next-question-example.json" + ) + + _question, _answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(plan.next_entry_id).to eql("5lYcZs1ootDrOnk09LDLZg") + end + + it "returns a fresh answer object" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "radio-question-example.json" + ) + + _question, answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(answer).to be_kind_of(Answer) + expect(answer.response).to eql(nil) + end + end + + context "when the question is of type short_text" do + it "sets help_text and options to nil" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "short-text-question-example.json" + ) + + question, _answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(question.help_text).to eq(nil) + expect(question.options).to eq(nil) + end + + it "replaces spaces with underscores" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "short-text-question-example.json" + ) + + question, _answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(question.contentful_type).to eq("short_text") + end + end + + context "when the new question does not have a following question" do + it "updates the plan by setting the next_entry_id to nil" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "radio-question-example.json" + ) + + _question, _answer = described_class.new(plan: plan, contentful_entry: fake_entry).call + + expect(plan.next_entry_id).to eql(nil) + end + end + + context "when the new entry has an unexpected content model" do + it "raises an error" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "an-unexpected-model-example.json" + ) + + expect { described_class.new(plan: plan, contentful_entry: fake_entry).call } + .to raise_error(CreatePlanningQuestion::UnexpectedContentfulModel) + end + + it "raises a rollbar event" do + plan = create(:plan, :catering) + + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "an-unexpected-model-example.json" + ) + + expect(Rollbar).to receive(:warning) + .with("An unexpected Contentful type was found", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: "6EKsv389ETYcQql3htK3Z2", + content_model: "unmanagedPage", + question_type: "radios", + allowed_content_models: CreatePlanningQuestion::ALLOWED_CONTENTFUL_MODELS.join(", "), + allowed_question_types: CreatePlanningQuestion::ALLOWED_CONTENTFUL_QUESTION_TYPES.join(", ")) + .and_call_original + expect { described_class.new(plan: plan, contentful_entry: fake_entry).call } + .to raise_error(CreatePlanningQuestion::UnexpectedContentfulModel) + end + end + + context "when the new question has an unexpected type" do + it "raises an error" do + plan = create(:plan, :catering) + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "an-unexpected-question-type-example.json" + ) + + expect { described_class.new(plan: plan, contentful_entry: fake_entry).call } + .to raise_error(CreatePlanningQuestion::UnexpectedContentfulQuestionType) + end + + it "raises a rollbar event" do + plan = create(:plan, :catering) + + fake_entry = fake_contentful_radio_question_entry( + contentful_fixture_filename: "an-unexpected-question-type-example.json" + ) + + expect(Rollbar).to receive(:warning) + .with("An unexpected Contentful type was found", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: "8as7df68uhasdnuasdf", + content_model: "question", + question_type: "telepathy", + allowed_content_models: CreatePlanningQuestion::ALLOWED_CONTENTFUL_MODELS.join(", "), + allowed_question_types: CreatePlanningQuestion::ALLOWED_CONTENTFUL_QUESTION_TYPES.join(", ")) + .and_call_original + expect { described_class.new(plan: plan, contentful_entry: fake_entry).call } + .to raise_error(CreatePlanningQuestion::UnexpectedContentfulQuestionType) + end + end + end +end diff --git a/spec/services/get_contentful_entry_spec.rb b/spec/services/get_contentful_entry_spec.rb new file mode 100644 index 000000000..8d61e7bc1 --- /dev/null +++ b/spec/services/get_contentful_entry_spec.rb @@ -0,0 +1,74 @@ +require "rails_helper" + +RSpec.describe GetContentfulEntry do + let(:contentful_url) { "preview.contentful" } + let(:contentful_space) { "abc" } + let(:contentful_environment) { "test" } + let(:contentful_access_token) { "123" } + let(:contentful_planning_start_entry_id) { "1a2b3c4d5" } + + around do |example| + ClimateControl.modify( + CONTENTFUL_URL: contentful_url, + CONTENTFUL_SPACE: contentful_space, + CONTENTFUL_ENVIRONMENT: contentful_environment, + CONTENTFUL_ACCESS_TOKEN: contentful_access_token, + CONTENTFUL_PLANNING_START_ENTRY_ID: contentful_planning_start_entry_id + ) do + example.run + end + end + + describe "#call" do + it "returns the contents of Contentful fixture (for now)" do + contentful_client = instance_double(Contentful::Client) + expect(Contentful::Client).to receive(:new) + .with(api_url: contentful_url, + space: contentful_space, + environment: contentful_environment, + access_token: contentful_access_token) + .and_return(contentful_client) + + contentful_response = double(Contentful::Entry, id: contentful_planning_start_entry_id) + expect(contentful_client).to receive(:entry) + .with(contentful_planning_start_entry_id) + .and_return(contentful_response) + + result = described_class.new(entry_id: contentful_planning_start_entry_id).call + + expect(result).to eq(contentful_response) + end + + context "when the Contentful entry cannot be found" do + it "returns an error message" do + missing_entry_id = "345vsdf7" + contentful_client = stub_contentful_client + + allow(contentful_client).to receive(:entry) + .with(missing_entry_id) + .and_return(nil) + + expect { described_class.new(entry_id: missing_entry_id).call } + .to raise_error(GetContentfulEntry::EntryNotFound) + end + + it "raises a rollbar event" do + contentful_client = stub_contentful_client + + allow(contentful_client).to receive(:entry) + .with(anything) + .and_return(nil) + + expect(Rollbar).to receive(:warning) + .with("The following Contentful entry identifier could not be found.", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: "123") + .and_call_original + expect { described_class.new(entry_id: "123").call } + .to raise_error(GetContentfulEntry::EntryNotFound) + end + end + end +end diff --git a/spec/support/contentful_helpers.rb b/spec/support/contentful_helpers.rb new file mode 100644 index 000000000..6673d9cb4 --- /dev/null +++ b/spec/support/contentful_helpers.rb @@ -0,0 +1,48 @@ +module ContentfulHelpers + def stub_get_contentful_entry( + entry_id: "1UjQurSOi5MWkcRuGxdXZS", + fixture_filename: "radio-question-example.json" + ) + raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{fixture_filename}") + + contentful_client = stub_contentful_client + contentful_response = fake_contentful_radio_question_entry(contentful_fixture_filename: fixture_filename) + allow(contentful_client).to receive(:entry) + .with(entry_id) + .and_return(contentful_response) + + allow(contentful_response).to receive(:raw) + .and_return(raw_response) + end + + def stub_contentful_client + contentful_client = instance_double(Contentful::Client) + expect(Contentful::Client).to receive(:new) + .with(api_url: anything, space: anything, environment: anything, access_token: anything) + .and_return(contentful_client) + contentful_client + end + + def stub_contentful_question(fake_entry: fake_contentful_radio_question_entry) + get_contentful_question_double = instance_double(GetContentfulEntry) + allow(GetContentfulEntry).to receive(:new).and_return(get_contentful_question_double) + allow(get_contentful_question_double).to receive(:call).and_return(fake_entry) + end + + def fake_contentful_radio_question_entry(contentful_fixture_filename:) + raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{contentful_fixture_filename}") + hash_response = JSON.parse(raw_response) + + double( + Contentful::Entry, + id: hash_response.dig("sys", "id"), + title: hash_response.dig("fields", "title"), + help_text: hash_response.dig("fields", "helpText"), + options: hash_response.dig("fields", "options"), + type: hash_response.dig("fields", "type"), + next: double(id: hash_response.dig("fields", "next", "sys", "id")), + raw: raw_response, + content_type: double(id: hash_response.dig("sys", "contentType", "sys", "id")) + ) + end +end