diff --git a/.env.example b/.env.example index df498325f..178000046 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ ROLLBAR_ENV=development DATABASE_URL=postgres://postgres@localhost:5432/buy-for-your-school-development REDIS_CACHE_URL=redis://localhost:6379/1 +REDIS_SIDEKIQ_URL=redis://localhost:6379/2 # Contentful CONTENTFUL_URL=cdn.contentful.com diff --git a/.env.test b/.env.test index 36b12b2ff..1ed0b00d1 100644 --- a/.env.test +++ b/.env.test @@ -6,7 +6,8 @@ # Reference: https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use DATABASE_URL=postgres://postgres@localhost:5432/buy-for-your-school-test -REDIS_CACHE_URL=redis://localhost:6379/2 +REDIS_CACHE_URL=redis://localhost:6379/11 +REDIS_SIDEKIQ_URL=redis://localhost:6379/12 # Contentful CONTENTFUL_URL=cdn.contentful.com diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4aae4a9..260c36446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog 1.0.0]. ## [Unreleased] +## [release-004] - 2020-12-17 + +- fix primary key type on long_text_answers table to UUID +- nightly task to warm the Contentful cache for all entries +- form button content is configurable through Contentful +- users can be asked to provide a single date answer +- add Webmock to prevent real http requests in the test suite +- content users can see a journey map of all the Contentful steps +- users can be asked to provide multiple answers via a checkbox question +- journey map shows an error to the content team if a duplicate entry is detected +- journey map shows an error to the content team if the journey doesn't end within 50 steps +- refactor how we store and access a Step's associated Contentful Entry ID +- users can edit their answers +- only add Contentful entries that form part of a valid journey to the cache + ## [release-003] - 2020-12-07 - add database foreign key constraints for better data integrity @@ -36,7 +51,8 @@ Contentful fixture - Contentful can redirect users to preview endpoints - users can be asked to answer a long text question -[unreleased]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-003...HEAD +[unreleased]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-004...HEAD +[release-004]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-003...release-004 [release-003]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-002...release-003 [release-002]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-001...release-002 [release-001]: https://github.com/DFE-Digital/buy-for-your-school/compare/release-000...release-001 diff --git a/Gemfile b/Gemfile index 40627cfff..f8ffc488b 100644 --- a/Gemfile +++ b/Gemfile @@ -18,8 +18,10 @@ gem "puma", "~> 5.1" gem "redis", "~> 4.2" gem "redis-namespace" gem "rollbar" -gem "rails", "~> 6.0.0" +gem "rails", "~> 6.1.0" gem "sass-rails", "~> 6.0" +gem "sidekiq", "~> 6.1" +gem "sidekiq-cron", "~> 1.2" gem "turbolinks", "~> 5" gem "tzinfo-data", platforms: %i[mingw mswin x64_mingw jruby] gem "uglifier", ">= 1.3.0" @@ -37,6 +39,7 @@ group :test do gem "selenium-webdriver" gem "shoulda-matchers" gem "simplecov" + gem "webmock" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 41d862126..3811a422a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,61 +1,65 @@ GEM remote: https://rubygems.org/ specs: - actioncable (6.0.3.4) - actionpack (= 6.0.3.4) + actioncable (6.1.0) + actionpack (= 6.1.0) + activesupport (= 6.1.0) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.0.3.4) - actionpack (= 6.0.3.4) - activejob (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) + actionmailbox (6.1.0) + actionpack (= 6.1.0) + activejob (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) mail (>= 2.7.1) - actionmailer (6.0.3.4) - actionpack (= 6.0.3.4) - actionview (= 6.0.3.4) - activejob (= 6.0.3.4) + actionmailer (6.1.0) + actionpack (= 6.1.0) + actionview (= 6.1.0) + activejob (= 6.1.0) + activesupport (= 6.1.0) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.3.4) - actionview (= 6.0.3.4) - activesupport (= 6.0.3.4) - rack (~> 2.0, >= 2.0.8) + actionpack (6.1.0) + actionview (= 6.1.0) + activesupport (= 6.1.0) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.0.3.4) - actionpack (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) + actiontext (6.1.0) + actionpack (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) nokogiri (>= 1.8.5) - actionview (6.0.3.4) - activesupport (= 6.0.3.4) + actionview (6.1.0) + activesupport (= 6.1.0) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.0.3.4) - activesupport (= 6.0.3.4) + activejob (6.1.0) + activesupport (= 6.1.0) globalid (>= 0.3.6) - activemodel (6.0.3.4) - activesupport (= 6.0.3.4) - activerecord (6.0.3.4) - activemodel (= 6.0.3.4) - activesupport (= 6.0.3.4) - activestorage (6.0.3.4) - actionpack (= 6.0.3.4) - activejob (= 6.0.3.4) - activerecord (= 6.0.3.4) + activemodel (6.1.0) + activesupport (= 6.1.0) + activerecord (6.1.0) + activemodel (= 6.1.0) + activesupport (= 6.1.0) + activestorage (6.1.0) + actionpack (= 6.1.0) + activejob (= 6.1.0) + activerecord (= 6.1.0) + activesupport (= 6.1.0) marcel (~> 0.3.1) - activesupport (6.0.3.4) + mimemagic (~> 0.3.2) + activesupport (6.1.0) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - zeitwerk (~> 2.2, >= 2.2.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) ast (2.4.1) @@ -68,7 +72,7 @@ GEM msgpack (~> 1.0) brakeman (4.10.0) builder (3.2.4) - bullet (6.1.0) + bullet (6.1.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) @@ -91,9 +95,11 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.1.7) + connection_pool (2.2.3) contentful (2.15.4) http (> 0.8, < 5.0) multi_json (~> 1) + crack (0.4.4) crass (1.0.6) database_cleaner (1.8.5) diff-lcs (1.4.4) @@ -105,6 +111,8 @@ GEM dotenv (= 2.7.6) railties (>= 3.2) erubi (1.10.0) + et-orbi (1.2.4) + tzinfo execjs (2.7.0) factory_bot (6.1.0) activesupport (>= 5.0.0) @@ -117,12 +125,16 @@ GEM ffi-compiler (1.0.1) ffi (>= 1.0.0) rake + fugit (1.4.1) + et-orbi (~> 1.1, >= 1.1.8) + raabro (~> 1.4) globalid (0.4.2) activesupport (>= 4.2.0) govuk_design_system_formbuilder (2.1.5) actionview (>= 5.2) activemodel (>= 5.2) activesupport (>= 5.2) + hashdiff (1.0.1) high_voltage (3.1.2) http (4.4.1) addressable (~> 2.3) @@ -176,25 +188,26 @@ GEM coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.6) - puma (5.1.0) + puma (5.1.1) nio4r (~> 2.0) + raabro (1.4.0) rack (2.2.3) rack-test (1.1.0) rack (>= 1.0, < 3) - rails (6.0.3.4) - actioncable (= 6.0.3.4) - actionmailbox (= 6.0.3.4) - actionmailer (= 6.0.3.4) - actionpack (= 6.0.3.4) - actiontext (= 6.0.3.4) - actionview (= 6.0.3.4) - activejob (= 6.0.3.4) - activemodel (= 6.0.3.4) - activerecord (= 6.0.3.4) - activestorage (= 6.0.3.4) - activesupport (= 6.0.3.4) - bundler (>= 1.3.0) - railties (= 6.0.3.4) + rails (6.1.0) + actioncable (= 6.1.0) + actionmailbox (= 6.1.0) + actionmailer (= 6.1.0) + actionpack (= 6.1.0) + actiontext (= 6.1.0) + actionview (= 6.1.0) + activejob (= 6.1.0) + activemodel (= 6.1.0) + activerecord (= 6.1.0) + activestorage (= 6.1.0) + activesupport (= 6.1.0) + bundler (>= 1.15.0) + railties (= 6.1.0) sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) @@ -202,12 +215,12 @@ GEM rails-html-sanitizer (1.3.0) loofah (~> 2.3) rails_layout (1.0.42) - railties (6.0.3.4) - actionpack (= 6.0.3.4) - activesupport (= 6.0.3.4) + railties (6.1.0) + actionpack (= 6.1.0) + activesupport (= 6.1.0) method_source rake (>= 0.8.7) - thor (>= 0.20.3, < 2.0) + thor (~> 1.0) rainbow (3.0.0) rake (13.0.1) rb-fsevent (0.10.4) @@ -267,6 +280,13 @@ GEM rubyzip (>= 1.2.2) shoulda-matchers (4.4.1) activesupport (>= 4.2.0) + sidekiq (6.1.2) + connection_pool (>= 2.2.2) + rack (~> 2.0) + redis (>= 4.2.0) + sidekiq-cron (1.2.0) + fugit (~> 1.1) + sidekiq (>= 4.2.1) simplecov (0.20.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -286,17 +306,16 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - standard (0.10.1) + standard (0.10.2) rubocop (= 1.4.2) rubocop-performance (= 1.9.1) thor (1.0.1) - thread_safe (0.3.6) tilt (2.0.10) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) - tzinfo (1.2.8) - thread_safe (~> 0.1) + tzinfo (2.0.3) + concurrent-ruby (~> 1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) @@ -309,6 +328,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.10.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.3) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -344,7 +367,7 @@ DEPENDENCIES pg pry puma (~> 5.1) - rails (~> 6.0.0) + rails (~> 6.1.0) rails_layout redis (~> 4.2) redis-namespace @@ -353,6 +376,8 @@ DEPENDENCIES sass-rails (~> 6.0) selenium-webdriver shoulda-matchers + sidekiq (~> 6.1) + sidekiq-cron (~> 1.2) simplecov spring spring-commands-rspec @@ -362,6 +387,7 @@ DEPENDENCIES tzinfo-data uglifier (>= 1.3.0) web-console (>= 3.3.0) + webmock RUBY VERSION ruby 2.6.6p146 diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index c4019c885..1d1bd3c52 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -6,9 +6,15 @@ def create @step = Step.find(step_id) @answer = AnswerFactory.new(step: @step).call - @answer.assign_attributes(answer_params) @answer.step = @step + case @step.contentful_type + when "checkboxes" + @answer.assign_attributes(checkbox_params) + else + @answer.assign_attributes(answer_params) + end + if @answer.valid? @answer.save if @journey.next_entry_id.present? @@ -17,7 +23,22 @@ def create redirect_to journey_path(@journey) end else - render "steps/new.#{@step.contentful_type}" + render "steps/#{@step.contentful_type}", locals: {layout: "steps/new_form_wrapper"} + end + end + + def update + @journey = Journey.find(journey_id) + @step = Step.find(step_id) + @answer = @step.answer + + @answer.assign_attributes(answer_params) + + if @answer.valid? + @answer.save + redirect_to journey_path(@journey) + else + render "steps/#{@step.contentful_type}", locals: {layout: "steps/edit_form_wrapper"} end end @@ -34,4 +55,8 @@ def step_id def answer_params params.require(:answer).permit(:response) end + + def checkbox_params + params.require(:answer).permit(response: []) + end end diff --git a/app/controllers/journey_maps_controller.rb b/app/controllers/journey_maps_controller.rb new file mode 100644 index 000000000..eab7d3d64 --- /dev/null +++ b/app/controllers/journey_maps_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class JourneyMapsController < ApplicationController + rescue_from BuildJourneyOrder::RepeatEntryDetected do |exception| + render "errors/repeat_step_in_the_contentful_journey", status: 500, locals: {error: exception} + end + + rescue_from BuildJourneyOrder::TooManyChainedEntriesDetected do |exception| + render "errors/too_many_steps_in_the_contentful_journey", status: 500, locals: {error: exception} + end + + def new + entries = GetAllContentfulEntries.new.call + @journey_map = BuildJourneyOrder.new( + entries: entries.to_a, + starting_entry_id: ENV["CONTENTFUL_PLANNING_START_ENTRY_ID"] + ).call + end +end diff --git a/app/controllers/journeys_controller.rb b/app/controllers/journeys_controller.rb index 39774f62d..3b8815076 100644 --- a/app/controllers/journeys_controller.rb +++ b/app/controllers/journeys_controller.rb @@ -8,8 +8,9 @@ def new def show @journey = Journey.includes( - steps: [:radio_answer, :short_text_answer] + steps: [:radio_answer, :short_text_answer, :long_text_answer] ).find(journey_id) + @steps = @journey.steps.map { |step| StepPresenter.new(step) } end private diff --git a/app/controllers/steps_controller.rb b/app/controllers/steps_controller.rb index 20cee9e3e..f25f4da6b 100644 --- a/app/controllers/steps_controller.rb +++ b/app/controllers/steps_controller.rb @@ -31,7 +31,15 @@ def show @step = Step.find(params[:id]) @answer = AnswerFactory.new(step: @step).call - render "new.#{@step.contentful_type}" + render @step.contentful_type, locals: {layout: "steps/new_form_wrapper"} + end + + def edit + @journey = Journey.find(journey_id) + @step = Step.find(params[:id]) + @answer = @step.answer + + render "steps/#{@step.contentful_type}", locals: {layout: "steps/edit_form_wrapper"} end private diff --git a/app/helpers/step_helper.rb b/app/helpers/step_helper.rb new file mode 100644 index 000000000..5b7ced134 --- /dev/null +++ b/app/helpers/step_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module StepHelper + def radio_options(array_of_options:) + array_of_options.map { |option| + OpenStruct.new(id: option.downcase, name: option.titleize) + } + end + + def checkbox_options(array_of_options:) + array_of_options.map { |option| + OpenStruct.new(id: option.downcase, name: option.titleize) + } + end +end diff --git a/app/jobs/warm_entry_cache_job.rb b/app/jobs/warm_entry_cache_job.rb new file mode 100644 index 000000000..3b04dfcbe --- /dev/null +++ b/app/jobs/warm_entry_cache_job.rb @@ -0,0 +1,29 @@ +class WarmEntryCacheJob < ApplicationJob + include CacheableEntry + + queue_as :caching + sidekiq_options retry: 5 + + def perform + entries = BuildJourneyOrder.new( + entries: GetAllContentfulEntries.new.call, + starting_entry_id: ENV["CONTENTFUL_PLANNING_START_ENTRY_ID"] + ).call + + entries.each do |entry| + store_in_cache(cache: cache, key: "contentful:entry:#{entry.id}", entry: entry) + end + rescue BuildJourneyOrder::RepeatEntryDetected, + BuildJourneyOrder::TooManyChainedEntriesDetected + cache.extend_ttl_on_all_entries + end + + private + + def cache + @cache ||= Cache.new( + enabled: ENV.fetch("CONTENTFUL_ENTRY_CACHING"), + ttl: ENV.fetch("CONTENTFUL_ENTRY_CACHING_TTL", 60 * 60 * 72) + ) + end +end diff --git a/app/models/checkbox_answers.rb b/app/models/checkbox_answers.rb new file mode 100644 index 000000000..c93e5b78d --- /dev/null +++ b/app/models/checkbox_answers.rb @@ -0,0 +1,13 @@ +class CheckboxAnswers < ActiveRecord::Base + self.implicit_order_column = "created_at" + belongs_to :step + + validates :response, presence: true + + def response=(args) + return if args.blank? + args.reject!(&:blank?) if args.is_a?(Array) + + super(args) + end +end diff --git a/app/models/single_date_answer.rb b/app/models/single_date_answer.rb new file mode 100644 index 000000000..b85a192c3 --- /dev/null +++ b/app/models/single_date_answer.rb @@ -0,0 +1,6 @@ +class SingleDateAnswer < ActiveRecord::Base + self.implicit_order_column = "created_at" + belongs_to :step + + validates :response, presence: true +end diff --git a/app/models/step.rb b/app/models/step.rb index 6ce516683..dc464aeb3 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -5,16 +5,20 @@ class Step < ApplicationRecord has_one :radio_answer has_one :short_text_answer has_one :long_text_answer - - def radio_options - options.map { |option| OpenStruct.new(id: option.downcase, name: option.titleize) } - end + has_one :single_date_answer + has_one :checkbox_answers def answer - @answer ||= radio_answer || short_text_answer || long_text_answer + @answer ||= + radio_answer || + short_text_answer || + long_text_answer || + single_date_answer || + checkbox_answers end - def question? - contentful_model == "question" + def primary_call_to_action_text + return I18n.t("generic.button.next") unless super.present? + super end end diff --git a/app/presenters/step_presenter.rb b/app/presenters/step_presenter.rb new file mode 100644 index 000000000..e018c14d8 --- /dev/null +++ b/app/presenters/step_presenter.rb @@ -0,0 +1,5 @@ +class StepPresenter < SimpleDelegator + def question? + contentful_model == "question" + end +end diff --git a/app/services/answer_factory.rb b/app/services/answer_factory.rb index eb3cfc5d6..b42b9392e 100644 --- a/app/services/answer_factory.rb +++ b/app/services/answer_factory.rb @@ -10,6 +10,8 @@ def call when "radios" then RadioAnswer.new when "short_text" then ShortTextAnswer.new when "long_text" then LongTextAnswer.new + when "single_date" then SingleDateAnswer.new + when "checkboxes" then CheckboxAnswers.new end end end diff --git a/app/services/build_journey_order.rb b/app/services/build_journey_order.rb new file mode 100644 index 000000000..b2de6141d --- /dev/null +++ b/app/services/build_journey_order.rb @@ -0,0 +1,64 @@ +class BuildJourneyOrder + class RepeatEntryDetected < StandardError; end + + class TooManyChainedEntriesDetected < StandardError; end + + ENTRY_JOURNEY_MAX_LENGTH = 50 + + attr_accessor :entries, :starting_entry_id + + def initialize(entries:, starting_entry_id:) + self.entries = entries + self.starting_entry_id = starting_entry_id + end + + def call + recursive_path( + entry_lookup: entry_lookup_hash, + next_entry_id: starting_entry_id, + entries: [] + ) + end + + private + + def entry_lookup_hash + @entry_lookup_hash ||= entries.to_h { |entry| [entry.id, entry] } + end + + def recursive_path(entry_lookup:, next_entry_id:, entries:) + entry = entry_lookup.fetch(next_entry_id, nil) + + if entries.include?(entry) + send_rollbar_error(message: "A repeated Contentful entry was found in the same journey", entry_id: entry.id) + raise RepeatEntryDetected.new(entry.id) + end + + if entries.count >= ENTRY_JOURNEY_MAX_LENGTH + send_rollbar_error(message: "More than #{ENTRY_JOURNEY_MAX_LENGTH} steps were found in a journey map", entry_id: entry.id) + raise TooManyChainedEntriesDetected.new(entry.id) + end + + entries << entry if entry.present? + + if entry.respond_to?(:next) + recursive_path( + entry_lookup: entry_lookup, + next_entry_id: entry.next.id, + entries: entries + ) + else + entries + end + end + + def send_rollbar_error(message:, entry_id:) + Rollbar.error( + message, + 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/services/cache.rb b/app/services/cache.rb index d6b0d1d0a..685a35757 100644 --- a/app/services/cache.rb +++ b/app/services/cache.rb @@ -1,8 +1,7 @@ class Cache attr_accessor :enabled, :key, :ttl - def initialize(enabled:, key:, ttl:) + def initialize(enabled:, ttl:) self.enabled = enabled - self.key = key self.ttl = ttl end @@ -10,7 +9,7 @@ def redis_cache @redis_cache ||= RedisCache.redis end - def hit? + def hit?(key:) if enabled == "true" redis_cache.exists?(key) else @@ -18,13 +17,22 @@ def hit? end end - def get + def get(key:) redis_cache.get(key) end - def set(value:) + def set(key:, value:) return unless enabled == "true" redis_cache.set(key, value) redis_cache.expire(key, ttl) end + + def extend_ttl_on_all_entries(extension: (60 * 60 * 24)) + redis_cache.keys("contentful:entry:*").map do |key| + previous_ttl = redis_cache.ttl(key) + extended_ttl = extension + previous_ttl + + redis_cache.expire(key, extended_ttl) + end + end end diff --git a/app/services/concerns/cacheable_entry.rb b/app/services/concerns/cacheable_entry.rb index f265f6225..3375184f4 100644 --- a/app/services/concerns/cacheable_entry.rb +++ b/app/services/concerns/cacheable_entry.rb @@ -1,23 +1,22 @@ module CacheableEntry extend ActiveSupport::Concern - def find_and_build_entry_from_cache(cache:) + def find_and_build_entry_from_cache(cache:, key:) Contentful::ResourceBuilder.new( - load_from_cache(cache: cache) + load_from_cache(cache: cache, key: key) ).run end - def store_in_cache(cache:, entry:) + def store_in_cache(cache:, key:, entry:) return unless entry.present? && entry.respond_to?(:raw) - - cache.set(value: JSON.dump(entry.raw.to_json)) + cache.set(key: key, value: JSON.dump(entry.raw.to_json)) end private - def load_from_cache(cache:) + def load_from_cache(cache:, key:) # rubocop:disable Security/JSONLoad - serialised_json_string = cache.get + serialised_json_string = cache.get(key: key) unserialised_json_string = JSON.restore(serialised_json_string) JSON.parse(unserialised_json_string) # rubocop:enable Security/JSONLoad diff --git a/app/services/create_journey_step.rb b/app/services/create_journey_step.rb index d8af03a5b..e4da8b37a 100644 --- a/app/services/create_journey_step.rb +++ b/app/services/create_journey_step.rb @@ -4,7 +4,14 @@ class UnexpectedContentfulModel < StandardError; end class UnexpectedContentfulStepType < StandardError; end ALLOWED_CONTENTFUL_MODELS = %w[question staticContent].freeze - ALLOWED_CONTENTFUL_ENTRY_TYPES = %w[radios short_text long_text paragraphs].freeze + ALLOWED_CONTENTFUL_ENTRY_TYPES = %w[ + radios + short_text + long_text + paragraphs + single_date + checkboxes + ].freeze attr_accessor :journey, :contentful_entry def initialize(journey:, contentful_entry:) @@ -27,9 +34,11 @@ def call title: title, help_text: help_text, body: body, + contentful_id: content_entry_id, contentful_model: content_model, contentful_type: step_type, options: options, + primary_call_to_action_text: primary_call_to_action_text, raw: raw, journey: journey ) @@ -88,6 +97,11 @@ def step_type contentful_entry.type.tr(" ", "_") end + def primary_call_to_action_text + return nil unless contentful_entry.respond_to?(:primary_call_to_action) + contentful_entry.primary_call_to_action + end + def raw contentful_entry.raw end diff --git a/app/services/get_contentful_entry.rb b/app/services/get_contentful_entry.rb index 665e81a12..304d3d975 100644 --- a/app/services/get_contentful_entry.rb +++ b/app/services/get_contentful_entry.rb @@ -9,17 +9,16 @@ def initialize(entry_id:, contentful_connector: ContentfulConnector.new) @contentful_connector = contentful_connector self.cache = Cache.new( enabled: ENV.fetch("CONTENTFUL_ENTRY_CACHING"), - key: "contentful:entry:#{entry_id}", - ttl: ENV.fetch("CONTENTFUL_ENTRY_CACHING_TTL", 172_800) # 48 hours + ttl: ENV.fetch("CONTENTFUL_ENTRY_CACHING_TTL", 60 * 60 * 72) ) end def call - if cache.hit? - entry = find_and_build_entry_from_cache(cache: cache) + if cache.hit?(key: cache_key) + entry = find_and_build_entry_from_cache(cache: cache, key: cache_key) else entry = @contentful_connector.get_entry_by_id(entry_id) - store_in_cache(cache: cache, entry: entry) + store_in_cache(cache: cache, key: cache_key, entry: entry) end if entry.nil? @@ -32,6 +31,10 @@ def call private + def cache_key + "contentful:entry:#{entry_id}" + end + def send_rollbar_warning Rollbar.warning( "The following Contentful entry identifier could not be found.", diff --git a/app/views/errors/repeat_step_in_the_contentful_journey.html.erb b/app/views/errors/repeat_step_in_the_contentful_journey.html.erb new file mode 100644 index 000000000..aba030975 --- /dev/null +++ b/app/views/errors/repeat_step_in_the_contentful_journey.html.erb @@ -0,0 +1,6 @@ +<%= content_for :title, I18n.t("errors.repeat_step_in_the_contentful_journey.page_title") %> + +

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

+

+ <%= I18n.t("errors.repeat_step_in_the_contentful_journey.page_body", entry_id: error.message) %> +

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

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

+

+ <%= I18n.t("errors.too_many_steps_in_the_contentful_journey.page_body", entry_id: error.message, step_count: BuildJourneyOrder::ENTRY_JOURNEY_MAX_LENGTH) %> +

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

<%= I18n.t("journey_map.page_title") %>

+ +
    + <% @journey_map.each do |entry| %> +
  1. + <%= link_to entry.title, "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/#{entry.id}", target: :blank, class: "govuk-link" %> +
  2. + <% end %> +
diff --git a/app/views/journeys/_checkboxes_answer.html.erb b/app/views/journeys/_checkboxes_answer.html.erb new file mode 100644 index 000000000..5bb8d711b --- /dev/null +++ b/app/views/journeys/_checkboxes_answer.html.erb @@ -0,0 +1,5 @@ +
+
<%= step.title %>
+
<%= answer.response.reject(&:blank?).map(&:capitalize).join(", ") %>
+
<%= link_to I18n.t("generic.button.change_answer"), edit_journey_step_path(@journey, step), class: "govuk-link" %>
+
diff --git a/app/views/journeys/_long_text_answer.html.erb b/app/views/journeys/_long_text_answer.html.erb index e3fdd9bd6..6f13b8a51 100644 --- a/app/views/journeys/_long_text_answer.html.erb +++ b/app/views/journeys/_long_text_answer.html.erb @@ -1,4 +1,5 @@
<%= step.title %>
<%= simple_format(step.answer.response) %>
+
<%= link_to I18n.t("generic.button.change_answer"), edit_journey_step_path(@journey, step), class: "govuk-link" %>
diff --git a/app/views/journeys/_radios_answer.html.erb b/app/views/journeys/_radios_answer.html.erb index 7774e2258..feb47caec 100644 --- a/app/views/journeys/_radios_answer.html.erb +++ b/app/views/journeys/_radios_answer.html.erb @@ -1,4 +1,5 @@
<%= step.title %>
<%= answer.response.capitalize %>
+
<%= link_to I18n.t("generic.button.change_answer"), edit_journey_step_path(@journey, step), class: "govuk-link" %>
diff --git a/app/views/journeys/_short_text_answer.html.erb b/app/views/journeys/_short_text_answer.html.erb index 35e6b130e..2f009b423 100644 --- a/app/views/journeys/_short_text_answer.html.erb +++ b/app/views/journeys/_short_text_answer.html.erb @@ -1,4 +1,5 @@
<%= step.title %>
<%= answer.response %>
+
<%= link_to I18n.t("generic.button.change_answer"), edit_journey_step_path(@journey, step), class: "govuk-link" %>
diff --git a/app/views/journeys/_single_date_answer.html.erb b/app/views/journeys/_single_date_answer.html.erb new file mode 100644 index 000000000..2e008f4cc --- /dev/null +++ b/app/views/journeys/_single_date_answer.html.erb @@ -0,0 +1,5 @@ +
+
<%= step.title %>
+
<%= I18n.l(answer.response) %>
+
<%= link_to I18n.t("generic.button.change_answer"), edit_journey_step_path(@journey, step), class: "govuk-link" %>
+
diff --git a/app/views/journeys/show.html.erb b/app/views/journeys/show.html.erb index d98c848ca..ceb4f5d5f 100644 --- a/app/views/journeys/show.html.erb +++ b/app/views/journeys/show.html.erb @@ -2,7 +2,7 @@

<%= @journey.category.capitalize %>

- <% @journey.steps.each do |step| %> + <% @steps.each do |step| %> <% if step.question? %> <%= render "#{step.contentful_type}_answer", step: step, answer: step.answer %> <% end %> diff --git a/app/views/steps/_edit_form_wrapper.html.erb b/app/views/steps/_edit_form_wrapper.html.erb new file mode 100644 index 000000000..72300574c --- /dev/null +++ b/app/views/steps/_edit_form_wrapper.html.erb @@ -0,0 +1,5 @@ +<%= content_for :title, @step.title %> +<%= form_for @answer, as: :answer, method: "put", url: journey_step_answer_path(@journey, @step, @answer) do |f| %> + <%= yield f %> + <%= f.submit I18n.t("generic.button.update"), class: "govuk-button" %> +<% end %> diff --git a/app/views/steps/_new_form_wrapper.html.erb b/app/views/steps/_new_form_wrapper.html.erb new file mode 100644 index 000000000..f72359486 --- /dev/null +++ b/app/views/steps/_new_form_wrapper.html.erb @@ -0,0 +1,5 @@ +<%= content_for :title, @step.title %> +<%= form_for @answer, as: :answer, url: journey_step_answers_path(@journey, @step), method: "post" do |f| %> + <%= yield f %> + <%= f.submit @step.primary_call_to_action_text, class: "govuk-button" %> +<% end %> diff --git a/app/views/steps/checkboxes.html.erb b/app/views/steps/checkboxes.html.erb new file mode 100644 index 000000000..63d43274f --- /dev/null +++ b/app/views/steps/checkboxes.html.erb @@ -0,0 +1,8 @@ +<%= render layout: layout do |f| %> + <%= f.govuk_collection_check_boxes :response, + checkbox_options(array_of_options: @step.options), + :id, + :name, + legend: { text: @step.title, size: "xl" } + %> +<% end %> diff --git a/app/views/steps/long_text.html.erb b/app/views/steps/long_text.html.erb new file mode 100644 index 000000000..5933f8d45 --- /dev/null +++ b/app/views/steps/long_text.html.erb @@ -0,0 +1,8 @@ +<%= render layout: layout do |f| %> + <%= f.govuk_text_area :response, + label: { text: @step.title, size: 'xl' }, + hint: { text: @step.help_text }, + width: "one-third", + rows: 9 + %> +<% end %> diff --git a/app/views/steps/new.long_text.html.erb b/app/views/steps/new.long_text.html.erb deleted file mode 100644 index dfa991e31..000000000 --- a/app/views/steps/new.long_text.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<%= content_for :title, @step.title %> - -<%= form_for @answer, as: :answer, url: journey_step_answers_path(@journey, @step), method: "post" do |f| %> - <%= f.govuk_text_area :response, - label: { text: @step.title, size: 'xl' }, - hint: { text: @step.help_text }, - width: "one-third", - rows: 9 - %> - <%= f.submit I18n.t("generic.button.soft_finish"), class: "govuk-button" %> -<% end %> diff --git a/app/views/steps/new.short_text.html.erb b/app/views/steps/new.short_text.html.erb deleted file mode 100644 index 4ee893f35..000000000 --- a/app/views/steps/new.short_text.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%= content_for :title, @step.title %> - -<%= form_for @answer, as: :answer, url: journey_step_answers_path(@journey, @step), method: "post" do |f| %> - <%= f.govuk_text_field :response, - label: { text: @step.title, size: 'xl' }, - hint: { text: @step.help_text }, - width: "one-third" - %> - <%= f.submit I18n.t("generic.button.soft_finish"), class: "govuk-button" %> -<% end %> diff --git a/app/views/steps/new.paragraphs.html.erb b/app/views/steps/paragraphs.html.erb similarity index 68% rename from app/views/steps/new.paragraphs.html.erb rename to app/views/steps/paragraphs.html.erb index 7306d4b5e..67a00db56 100644 --- a/app/views/steps/new.paragraphs.html.erb +++ b/app/views/steps/paragraphs.html.erb @@ -4,4 +4,4 @@
<%= simple_format(@step.body, wrapper_tag: "p", class: "govuk-body") %>
-<%= link_to I18n.t("generic.button.next"), new_journey_step_path, class: "govuk-button" %> +<%= link_to @step.primary_call_to_action_text, new_journey_step_path, class: "govuk-button" %> diff --git a/app/views/steps/new.radios.html.erb b/app/views/steps/radios.html.erb similarity index 51% rename from app/views/steps/new.radios.html.erb rename to app/views/steps/radios.html.erb index cb5558178..d0dcfb0ce 100644 --- a/app/views/steps/new.radios.html.erb +++ b/app/views/steps/radios.html.erb @@ -1,9 +1,7 @@ -<%= content_for :title, @step.title %> - -<%= form_for @answer, as: :answer, url: journey_step_answers_path(@journey, @step), method: "post" do |f| %> +<%= render layout: layout do |f| %> <%= f.hidden_field :response, value: nil %> <%= f.govuk_collection_radio_buttons :response, - @step.radio_options, + radio_options(array_of_options: @step.options), :id, ->(option) { option.name }, :description, @@ -11,5 +9,4 @@ hint: { text: @step.help_text }, inline: false %> - <%= f.submit I18n.t("generic.button.soft_finish"), class: "govuk-button" %> <% end %> diff --git a/app/views/steps/short_text.html.erb b/app/views/steps/short_text.html.erb new file mode 100644 index 000000000..3d414cc28 --- /dev/null +++ b/app/views/steps/short_text.html.erb @@ -0,0 +1,7 @@ +<%= render layout: layout do |f| %> + <%= f.govuk_text_field :response, + label: { text: @step.title, size: 'xl' }, + hint: { text: @step.help_text }, + width: "one-third" + %> +<% end %> diff --git a/app/views/steps/single_date.html.erb b/app/views/steps/single_date.html.erb new file mode 100644 index 000000000..0cc149ca1 --- /dev/null +++ b/app/views/steps/single_date.html.erb @@ -0,0 +1,6 @@ +<%= render layout: layout do |f| %> + <%= f.govuk_date_field :response, + legend: { text: @step.title, size: "xl" }, + hint: { text: @step.help_text } + %> +<% end %> diff --git a/config/application.rb b/config/application.rb index 4ba252194..0b9010946 100644 --- a/config/application.rb +++ b/config/application.rb @@ -32,5 +32,13 @@ class Application < Rails::Application # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading # the framework and any gems in your application. + + # Automatically add UUID as the type of primary key on new tables, if you + # use the Rails migration generator with 'create' + config.generators do |g| + g.orm :active_record, primary_key_type: :uuid + end + + config.active_job.queue_adapter = :sidekiq end end diff --git a/config/environments/test.rb b/config/environments/test.rb index cc7c118b5..e5e40667f 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,5 +61,7 @@ Bullet.raise = true # raise an error if n+1 query occurs Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :radio_answer Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :short_text_answer + Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :long_text_answer + Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :single_date_answer end end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 000000000..e80787b84 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,13 @@ +Sidekiq.configure_server do |config| + config.redis = {url: ENV["REDIS_SIDEKIQ_URL"]} + + # Sidekiq Cron + schedule_file = "config/schedule.yml" + if File.exist?(schedule_file) + Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) + end +end + +Sidekiq.configure_client do |config| + config.redis = {url: ENV["REDIS_SIDEKIQ_URL"]} +end diff --git a/config/locales/en.yml b/config/locales/en.yml index f607c4ca4..64910dae2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,11 +1,15 @@ en: app: name: Buy for your school + date: + formats: + default: "%-d %b %Y" generic: button: start: "Continue" next: "Continue" - soft_finish: "Save and continue later" + change_answer: "Change" + update: "Update" banner: beta: tag: "beta" @@ -32,6 +36,8 @@ en: 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 step has been revoked by the content team." + journey_map: + page_title: "Contentful entry map" errors: contentful_entry_not_found: page_title: "An unexpected error occurred" @@ -42,3 +48,9 @@ en: unexpected_contentful_step_type: page_title: "An unexpected error occurred" page_body: "The service has had a problem trying to retrieve the required step. The team have been notified of this problem and you should be able to retry shortly." + repeat_step_in_the_contentful_journey: + page_title: "An unexpected error occurred" + page_body: "One or more steps in the Contentful journey would leave the user in an infinite loop. This entry ID was presented more than once to the user: %{entry_id}" + too_many_steps_in_the_contentful_journey: + page_title: "An unexpected error occurred" + page_body: "More than %{step_count} steps were found in the Contentful journey. Is the journey missing an end? The last Entry ID was: %{entry_id}" diff --git a/config/routes.rb b/config/routes.rb index 6e3bf2aa6..af256d000 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,9 +4,10 @@ get "health_check" => "application#health_check" root to: "high_voltage/pages#show", id: "planning_start_page" + resource :journey_map, only: [:new] resources :journeys, only: [:new, :show] do - resources :steps, only: [:new, :show] do - resources :answers, only: [:create] + resources :steps, only: [:new, :show, :edit] do + resources :answers, only: [:create, :update] end end diff --git a/config/schedule.yml b/config/schedule.yml new file mode 100644 index 000000000..544798e82 --- /dev/null +++ b/config/schedule.yml @@ -0,0 +1,8 @@ +warm_contentful_entry_cache: + cron: "0 3 * * *" + class: "WarmEntryCacheJob" + description: This is a content driven service and the content is sourced by + the Contentful API. As a single point of failure we have a Redis cache to + provide some resilience to downtime or minor connection issues. As this + service is used infrequently by real users, we automatically update the + cache. diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 000000000..e7bf86115 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,6 @@ +--- +:concurrency: 5 +:queues: + - default + - [high_priority, 2] + - [caching, 1] diff --git a/db/migrate/20201203103421_change_long_text_answer_to_uuid.rb b/db/migrate/20201203103421_change_long_text_answer_to_uuid.rb new file mode 100644 index 000000000..b4b14f905 --- /dev/null +++ b/db/migrate/20201203103421_change_long_text_answer_to_uuid.rb @@ -0,0 +1,17 @@ +class ChangeLongTextAnswerToUuid < ActiveRecord::Migration[6.0] + def up + enable_extension "uuid-ossp" unless extension_enabled?("uuid-ossp") + + add_column :long_text_answers, :uuid, :uuid, default: "gen_random_uuid()", null: false + + change_table :long_text_answers do |t| + t.remove :id + t.rename :uuid, :id + end + execute "ALTER TABLE long_text_answers ADD PRIMARY KEY (id);" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20201207171647_add_primary_call_to_action_text_to_step.rb b/db/migrate/20201207171647_add_primary_call_to_action_text_to_step.rb new file mode 100644 index 000000000..19a710411 --- /dev/null +++ b/db/migrate/20201207171647_add_primary_call_to_action_text_to_step.rb @@ -0,0 +1,5 @@ +class AddPrimaryCallToActionTextToStep < ActiveRecord::Migration[6.0] + def change + add_column :steps, :primary_call_to_action_text, :string + end +end diff --git a/db/migrate/20201208110342_create_single_date_answer.rb b/db/migrate/20201208110342_create_single_date_answer.rb new file mode 100644 index 000000000..c84bbebd0 --- /dev/null +++ b/db/migrate/20201208110342_create_single_date_answer.rb @@ -0,0 +1,9 @@ +class CreateSingleDateAnswer < ActiveRecord::Migration[6.0] + def change + create_table :single_date_answers, id: :uuid do |t| + t.references :step, type: :uuid + t.date :response, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20201209122555_create_checkbox_answers.rb b/db/migrate/20201209122555_create_checkbox_answers.rb new file mode 100644 index 000000000..5424846e6 --- /dev/null +++ b/db/migrate/20201209122555_create_checkbox_answers.rb @@ -0,0 +1,9 @@ +class CreateCheckboxAnswers < ActiveRecord::Migration[6.0] + def change + create_table :checkbox_answers, id: :uuid do |t| + t.references :step, type: :uuid + t.string :response, array: true, default: [] + t.timestamps + end + end +end diff --git a/db/migrate/20201216150806_add_contentful_id_to_step.rb b/db/migrate/20201216150806_add_contentful_id_to_step.rb new file mode 100644 index 000000000..0f3e8eba0 --- /dev/null +++ b/db/migrate/20201216150806_add_contentful_id_to_step.rb @@ -0,0 +1,20 @@ +class AddContentfulIdToStep < ActiveRecord::Migration[6.1] + def up + add_column :steps, :contentful_id, :string + + ActiveRecord::Base.transaction do + Step.all.map do |step| + contentful_id = JSON.parse( + step.raw.gsub("=>", ":") + ).dig("sys", "id") + step.update(contentful_id: contentful_id) + end + end + + change_column :steps, :contentful_id, :string, null: false + end + + def down + remove_column :steps, :contentful_id, :string + end +end diff --git a/db/migrate/20201216160028_change_step_raw_field_to_json_b.rb b/db/migrate/20201216160028_change_step_raw_field_to_json_b.rb new file mode 100644 index 000000000..3b0fb4708 --- /dev/null +++ b/db/migrate/20201216160028_change_step_raw_field_to_json_b.rb @@ -0,0 +1,27 @@ +class ChangeStepRawFieldToJsonB < ActiveRecord::Migration[6.1] + def up + add_column :steps, :raw_jsonb, :jsonb + ActiveRecord::Base.transaction do + Step.all.map do |step| + hash = JSON.parse(step.raw.gsub("=>", ":")) + step.update(raw_jsonb: hash) + end + end + remove_column :steps, :raw + rename_column :steps, :raw_jsonb, :raw + change_column :steps, :raw, :jsonb, null: false + end + + def down + add_column :steps, :raw_binary, :binary + ActiveRecord::Base.transaction do + Step.all.map do |step| + string = step.raw.to_s + step.update(raw_binary: string) + end + end + remove_column :steps, :raw + rename_column :steps, :raw_binary, :raw + change_column :steps, :raw, :binary, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index f43e73088..e24feb3ef 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,19 +2,28 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# 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 +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/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: 2020_12_02_150359) do +ActiveRecord::Schema.define(version: 2020_12_16_160028) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" + enable_extension "uuid-ossp" + + create_table "checkbox_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "step_id" + t.string "response", default: [], array: true + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["step_id"], name: "index_checkbox_answers_on_step_id" + end create_table "journeys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "category", null: false @@ -23,7 +32,7 @@ t.string "next_entry_id" end - create_table "long_text_answers", force: :cascade do |t| + create_table "long_text_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "step_id" t.text "response", null: false t.datetime "created_at", precision: 6, null: false @@ -47,17 +56,27 @@ t.index ["step_id"], name: "index_short_text_answers_on_step_id" end + create_table "single_date_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "step_id" + t.date "response", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["step_id"], name: "index_single_date_answers_on_step_id" + end + create_table "steps", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "journey_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.text "body" t.string "contentful_model" + t.string "primary_call_to_action_text" + t.string "contentful_id", null: false + t.jsonb "raw", null: false t.index ["journey_id"], name: "index_steps_on_journey_id" end diff --git a/doc/architecture/decisions/0008-use-sidekiq-for-asynchronous-tasks.md b/doc/architecture/decisions/0008-use-sidekiq-for-asynchronous-tasks.md new file mode 100644 index 000000000..26d412b5b --- /dev/null +++ b/doc/architecture/decisions/0008-use-sidekiq-for-asynchronous-tasks.md @@ -0,0 +1,28 @@ +# 8. use sidekiq for asynchronous tasks + +Date: 2020-11-30 + +## Status + +Accepted + +## Context + +We need a way for the service to automatically and regularly run a task. + +We already have Redis available as our caching layer and Sidekiq works great with it. + +An alternative could be to use Cron for scheduled tasks and a postgres backed asynchronous jobs, perhaps even run inline. We know how to get Sidekiq running with Docker and reusing Redis (rather than Postgres) for job data that is ephemeral feels a better fit given we already have Redis. + +## Decision + +Use Sidekiq for processing asynchronous tasks. + +## Consequences + +- We add another process/box of complexity to the system architecture though we would need to do this with Cron too +- It should take less time to add another asynchronous task in future +- It should take less time to add another scheduled task +- A clear separation of concerns between persistent data being in Postgres and ephemeral data in Redis +- Sidekiq can manage the scheduled task queue on initialiser which automatically takes care of provisioning jobs on all environments +- Adding Sidekiq takes more time now and will require another widget to Terraform when moving to GPaaS. We have done this before. diff --git a/doc/release-process.md b/doc/release-process.md index 6ade9bdff..adae30ae1 100644 --- a/doc/release-process.md +++ b/doc/release-process.md @@ -52,4 +52,4 @@ 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] 🚀 +> 🚢 Release ### for Buy for your school is now live. (Changes in this release)[https://github.com/DFE-Digital/buy-for-your-school/blob/develop/CHANGELOG.md#release-###---YYYY-MM-DD] - (Production environment)[https://dfe-buy-for-your-school-beta.herokuapp.com/] 🚀 diff --git a/docker-compose.yml b/docker-compose.yml index da51c0054..a68d0031a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: command: bundle exec rails s -p 3000 -b '0.0.0.0' ports: - "3000:3000" + image: "buy_for_your_school:dev" depends_on: - db - redis @@ -17,6 +18,7 @@ services: environment: DATABASE_URL: postgres://postgres:password@db:5432/buy-for-your-school-development REDIS_CACHE_URL: redis://redis:6379/1 + REDIS_SIDEKIQ_URL: redis://redis:6379/2 volumes: - .:/srv/app tty: true @@ -40,6 +42,17 @@ services: networks: - dev + sidekiq: + image: buy_for_your_school:dev + command: bundle exec sidekiq -C config/sidekiq.yml + depends_on: + - db + - redis + volumes: + - .:/srv/app + networks: + - dev + networks: dev: volumes: diff --git a/heroku.yml b/heroku.yml index a9aa82ec7..17b96f089 100644 --- a/heroku.yml +++ b/heroku.yml @@ -7,3 +7,7 @@ release: - rake db:migrate run: web: bundle exec puma -C config/puma.rb + worker: + command: + - bundle exec sidekiq -C config/sidekiq.yml + image: web diff --git a/spec/factories/answer.rb b/spec/factories/answer.rb index fd1a42adf..6b89a1498 100644 --- a/spec/factories/answer.rb +++ b/spec/factories/answer.rb @@ -1,19 +1,31 @@ FactoryBot.define do factory :radio_answer do - association :step, factory: :step, contentful_type: "radios" + association :step, factory: :step, contentful_type: "radios", contentful_model: "question" response { "Green" } end factory :short_text_answer do - association :step, factory: :step, contentful_type: "short_text" + association :step, factory: :step, contentful_type: "short_text", contentful_model: "question" response { "Green" } end factory :long_text_answer do - association :step, factory: :step, contentful_type: "long_text" + association :step, factory: :step, contentful_type: "long_text", contentful_model: "question" response { "Lots of green" } end + + factory :single_date_answer do + association :step, factory: :step, contentful_type: "single_date" + + response { 1.year.from_now } + end + + factory :checkbox_answers do + association :step, factory: :step, contentful_type: "checkboxes" + + response { ["breakfast", "lunch", ""] } + end end diff --git a/spec/factories/step.rb b/spec/factories/step.rb index 40b9cfd3c..e1ed7a3b0 100644 --- a/spec/factories/step.rb +++ b/spec/factories/step.rb @@ -2,7 +2,8 @@ factory :step do title { "What is your favourite colour?" } help_text { "Choose the primary colour closest to your choice" } - raw { "{\"sys\":{}}" } + raw { "{\"sys\":{\"id\"=>\"123\"}}" } + contentful_id { "123" } association :journey, factory: :journey @@ -27,6 +28,20 @@ association :long_text_answer end + trait :single_date do + options { nil } + contentful_model { "question" } + contentful_type { "single_date" } + association :single_date_answer + end + + trait :checkbox_answers do + options { ["Brown", "Gold"] } + contentful_model { "question" } + contentful_type { "checkboxes" } + association :checkbox_answers + end + trait :static_content do options { nil } contentful_model { "staticContent" } diff --git a/spec/features/visitors/anyone_can_complete_a_journey_spec.rb b/spec/features/visitors/anyone_can_complete_a_journey_spec.rb index 34b905b95..c27a39bd1 100644 --- a/spec/features/visitors/anyone_can_complete_a_journey_spec.rb +++ b/spec/features/visitors/anyone_can_complete_a_journey_spec.rb @@ -1,6 +1,14 @@ require "rails_helper" feature "Anyone can start a journey" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "1UjQurSOi5MWkcRuGxdXZS" + ) do + example.run + end + end + scenario "Start page includes a call to action" do stub_get_contentful_entry @@ -15,7 +23,7 @@ choose("Catering") - click_on(I18n.t("generic.button.soft_finish")) + click_on(I18n.t("generic.button.next")) end scenario "an answer must be provided" do @@ -27,7 +35,7 @@ # Omit a choice - click_on(I18n.t("generic.button.soft_finish")) + click_on(I18n.t("generic.button.next")) expect(page).to have_content("can't be blank") end @@ -56,118 +64,163 @@ entry_id: "5lYcZs1ootDrOnk09LDLZg", fixture_filename: "no-next-question-example.json" ) - click_on(I18n.t("generic.button.soft_finish")) + + click_on(I18n.t("generic.button.next")) choose("Stationary") - click_on(I18n.t("generic.button.soft_finish")) + click_on(I18n.t("generic.button.next")) 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 ")) + context "when the Contentful model is of type question" do + 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 - visit root_path + scenario "user can answer using free text" do + stub_get_contentful_entry( + entry_id: "hfjJgWRg4xiiiImwVRDtZ", + fixture_filename: "short-text-question-example.json" + ) - click_on(I18n.t("generic.button.start")) + 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 + fill_in "answer[response]", with: "email@example.com" + click_on(I18n.t("generic.button.next")) - context "when Contentful entry is of type short_text" do - around do |example| - ClimateControl.modify( - CONTENTFUL_PLANNING_START_ENTRY_ID: "hfjJgWRg4xiiiImwVRDtZ" - ) do - example.run + expect(page).to have_content("email@example") end end - scenario "user can answer using free text" do - stub_get_contentful_entry( - entry_id: "hfjJgWRg4xiiiImwVRDtZ", - fixture_filename: "short-text-question-example.json" - ) + context "when Contentful entry is of type long_text" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "2jWIO1MrVIya9NZrFWT4e" + ) do + example.run + end + end - visit root_path - click_on(I18n.t("generic.button.start")) + scenario "user can answer using free text with multiple lines" do + stub_get_contentful_entry( + entry_id: "2jWIO1MrVIya9NZrFWT4e", + fixture_filename: "long-text-question-example.json" + ) - fill_in "answer[response]", with: "email@example.com" - click_on(I18n.t("generic.button.soft_finish")) + visit root_path + click_on(I18n.t("generic.button.start")) - expect(page).to have_content("email@example") + fill_in "answer[response]", with: "We would like a supplier to provide catering from September 2020.\r\nThey must be able to supply us for 3 years minumum." + click_on(I18n.t("generic.button.next")) + + within(".govuk-summary-list") do + paragraphs_elements = find_all("p") + expect(paragraphs_elements.first.text).to have_content("We would like a supplier to provide catering from September 2020.") + expect(paragraphs_elements.last.text).to have_content("They must be able to supply us for 3 years minumum.") + end + end end - end - context "when Contentful entry is of type long_text" do - around do |example| - ClimateControl.modify( - CONTENTFUL_PLANNING_START_ENTRY_ID: "2jWIO1MrVIya9NZrFWT4e" - ) do - example.run + context "when Contentful entry is of type single_date" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "55G5kpCLLL3h5yBQLiVlYy" + ) do + example.run + end + end + + scenario "user can answer using a date input" do + stub_get_contentful_entry( + entry_id: "55G5kpCLLL3h5yBQLiVlYy", + fixture_filename: "single-date-example.json" + ) + + visit root_path + click_on(I18n.t("generic.button.start")) + + fill_in "answer[response(3i)]", with: "12" + fill_in "answer[response(2i)]", with: "8" + fill_in "answer[response(1i)]", with: "2020" + + click_on(I18n.t("generic.button.next")) + + expect(page).to have_content("12 Aug 2020") end end - scenario "user can answer using free text with multiple lines" do - stub_get_contentful_entry( - entry_id: "2jWIO1MrVIya9NZrFWT4e", - fixture_filename: "long-text-question-example.json" - ) + context "when Contentful entry is of type checkboxes" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "1DqhwF2XkJJ0Um6NSweWlZ" + ) do + example.run + end + end - visit root_path - click_on(I18n.t("generic.button.start")) + scenario "user can select multiple answers" do + stub_get_contentful_entry( + entry_id: "1DqhwF2XkJJ0Um6NSweWlZ", + fixture_filename: "checkbox-example.json" + ) + + visit root_path + click_on(I18n.t("generic.button.start")) - fill_in "answer[response]", with: "We would like a supplier to provide catering from September 2020.\r\nThey must be able to supply us for 3 years minumum." - click_on(I18n.t("generic.button.soft_finish")) + check "Breakfast" + check "Lunch" - within(".govuk-summary-list") do - paragraphs_elements = find_all("p") - expect(paragraphs_elements.first.text).to have_content("We would like a supplier to provide catering from September 2020.") - expect(paragraphs_elements.last.text).to have_content("They must be able to supply us for 3 years minumum.") + click_on(I18n.t("generic.button.next")) + + expect(page).to have_content("Breakfast, Lunch") end end end - context "when Contentful entry is of type static_content" do - around do |example| - ClimateControl.modify( - CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" - ) do - example.run + context "when the Contentful model is of type staticContent" do + context "when Contentful entry is of type paragraphs" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end end - end - scenario "user can read static content and proceed without answering" do - stub_get_contentful_entry( - entry_id: "5kZ9hIFDvNCEhjWs72SFwj", - fixture_filename: "static-content-example.json" - ) + scenario "user can read static content and proceed without answering" do + stub_get_contentful_entry( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "static-content-example.json" + ) - visit root_path - click_on(I18n.t("generic.button.start")) + visit root_path + click_on(I18n.t("generic.button.start")) - expect(page).to have_content("When you should start") + expect(page).to have_content("When you should start") - within(".static-content") do - paragraphs_elements = find_all("p") - expect(paragraphs_elements.first.text).to have_content("Procuring a new catering contract can take up to 6 months to consult, create, review and award.") - expect(paragraphs_elements.last.text).to have_content("Usually existing contracts start and end in the month of September. We recommend starting this process around March.") - end + within(".static-content") do + paragraphs_elements = find_all("p") + expect(paragraphs_elements.first.text).to have_content("Procuring a new catering contract can take up to 6 months to consult, create, review and award.") + expect(paragraphs_elements.last.text).to have_content("Usually existing contracts start and end in the month of September. We recommend starting this process around March.") + end - click_on(I18n.t("generic.button.next")) + click_on(I18n.t("generic.button.next")) - expect(page).to have_content("Catering") + expect(page).to have_content("Catering") + end end end - context "when Contentful entry model wasn't a type of question" do + context "when Contentful entry model wasn't an expected type" do around do |example| ClimateControl.modify( CONTENTFUL_PLANNING_START_ENTRY_ID: "6EKsv389ETYcQql3htK3Z2" @@ -191,7 +244,7 @@ end end - context "when Contentful step entry wasn't an expected type" do + context "when the Contentful Entry wasn't an expected step type" do around do |example| ClimateControl.modify( CONTENTFUL_PLANNING_START_ENTRY_ID: "8as7df68uhasdnuasdf" @@ -214,4 +267,19 @@ expect(page).to have_content(I18n.t("errors.unexpected_contentful_step_type.page_body")) 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 end diff --git a/spec/features/visitors/anyone_can_edit_their_answers_spec.rb b/spec/features/visitors/anyone_can_edit_their_answers_spec.rb new file mode 100644 index 000000000..ee64ff27d --- /dev/null +++ b/spec/features/visitors/anyone_can_edit_their_answers_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +feature "Users can edit their answers" do + let(:answer) { create(:short_text_answer, response: "answer") } + scenario "The journey summary page displays a change link" do + visit journey_path(answer.step.journey) + + expect(page).to have_content("answer") + expect(page).to have_content("Change") + end + + scenario "The edited answer is saved" do + visit journey_path(answer.step.journey) + + click_on(I18n.t("generic.button.change_answer")) + + fill_in "answer[response]", with: "email@example.com" + + click_on(I18n.t("generic.button.update")) + + expect(page).to have_content("email@example.com") + end + + context "An error is thrown" do + scenario "When an answer is invalid" do + visit journey_path(answer.step.journey) + + click_on(I18n.t("generic.button.change_answer")) + + fill_in "answer[response]", with: "" + + click_on(I18n.t("generic.button.update")) + + expect(page).to have_content("can't be blank") + end + end +end diff --git a/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb b/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb new file mode 100644 index 000000000..44ea047b6 --- /dev/null +++ b/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb @@ -0,0 +1,81 @@ +feature "Users can see all the steps of a journey" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + scenario "Multiple journey steps" do + stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "closed-path-with-multiple-example.json" + ) + + visit new_journey_map_path + + expect(page).to have_content(I18n.t("journey_map.page_title")) + + within(".govuk-list") do + list_items = find_all("li") + within(list_items.first) do + expect(page).to have_link("When you should start", href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/5kZ9hIFDvNCEhjWs72SFwj") + end + within(list_items.last) do + expect(page).to have_link("Which service do you need?", href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/hfjJgWRg4xiiiImwVRDtZ") + end + end + end + + context "when the map isn't valid" do + context "when the same entry is found twice" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + it "returns an error message" do + stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "repeat-entry-example.json" + ) + + visit new_journey_map_path + + expect(page).to have_content(I18n.t("errors.repeat_step_in_the_contentful_journey.page_title")) + expect(page).to have_content( + I18n.t("errors.repeat_step_in_the_contentful_journey.page_body", entry_id: "5kZ9hIFDvNCEhjWs72SFwj") + ) + end + end + + context "when the chain becomes obviously too long" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + it "returns an error message" do + stub_const("BuildJourneyOrder::ENTRY_JOURNEY_MAX_LENGTH", 1) + stub_get_contentful_entries( + entry_id: "hfjJgWRg4xiiiImwVRDtZ", + fixture_filename: "closed-path-with-multiple-example.json" + ) + + visit new_journey_map_path + + expect(page).to have_content(I18n.t("errors.too_many_steps_in_the_contentful_journey.page_title")) + expect(page).to have_content( + I18n.t("errors.too_many_steps_in_the_contentful_journey.page_body", entry_id: "hfjJgWRg4xiiiImwVRDtZ", step_count: 1) + ) + end + end + end +end diff --git a/spec/fixtures/contentful/checkbox-example.json b/spec/fixtures/contentful/checkbox-example.json new file mode 100644 index 000000000..d606d24f7 --- /dev/null +++ b/spec/fixtures/contentful/checkbox-example.json @@ -0,0 +1,41 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "1DqhwF2XkJJ0Um6NSweWlZ", + "type": "Entry", + "createdAt": "2020-12-09T11:53:52.744Z", + "updatedAt": "2020-12-09T11:53:52.744Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/everyday-services", + "type": "checkboxes", + "title": "Everyday services that are required and need to be considered", + "options": [ + "breakfast", + "lunch", + "dinner" + ] + } +} diff --git a/spec/fixtures/contentful/closed-path-with-multiple-example.json b/spec/fixtures/contentful/closed-path-with-multiple-example.json new file mode 100644 index 000000000..23b0b4e99 --- /dev/null +++ b/spec/fixtures/contentful/closed-path-with-multiple-example.json @@ -0,0 +1,95 @@ +{ + "sys": { + "type": "Array" + }, + "total": 2, + "skip": 0, + "limit": 100, + "items": [ + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-02T10:48:35.748Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs", + "next": { + "sys": { + "type": "Link", + "linkType": "Entry", + "id": "hfjJgWRg4xiiiImwVRDtZ" + } + } + } + }, + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "hfjJgWRg4xiiiImwVRDtZ", + "type": "Entry", + "createdAt": "2020-11-04T12:28:30.442Z", + "updatedAt": "2020-11-26T16:39:54.188Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 6, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/dev-start-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/multiple-entries-example.json b/spec/fixtures/contentful/multiple-entries-example.json new file mode 100644 index 000000000..ec1d49501 --- /dev/null +++ b/spec/fixtures/contentful/multiple-entries-example.json @@ -0,0 +1,102 @@ +{ + "sys": { + "type": "Array" + }, + "total": 2, + "skip": 0, + "limit": 100, + "items": [ + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-02T10:48:35.748Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs", + "next": { + "sys": { + "type": "Link", + "linkType": "Entry", + "id": "hfjJgWRg4xiiiImwVRDtZ" + } + } + } + }, + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "hfjJgWRg4xiiiImwVRDtZ", + "type": "Entry", + "createdAt": "2020-11-04T12:28:30.442Z", + "updatedAt": "2020-11-26T16:39:54.188Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 6, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/dev-start-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": "52Ni9UFvZj8BYXSbhs373C" + } + } + } + } + ] +} diff --git a/spec/fixtures/contentful/no-primary-button-example.json b/spec/fixtures/contentful/no-primary-button-example.json new file mode 100644 index 000000000..07f995490 --- /dev/null +++ b/spec/fixtures/contentful/no-primary-button-example.json @@ -0,0 +1,37 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-07T17:04:21.854Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 2, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs" + } +} diff --git a/spec/fixtures/contentful/primary-button-example.json b/spec/fixtures/contentful/primary-button-example.json new file mode 100644 index 000000000..5e80952ab --- /dev/null +++ b/spec/fixtures/contentful/primary-button-example.json @@ -0,0 +1,38 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-07T17:04:21.854Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 2, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs", + "primaryCallToAction": "Go onwards!" + } +} diff --git a/spec/fixtures/contentful/repeat-entry-example.json b/spec/fixtures/contentful/repeat-entry-example.json new file mode 100644 index 000000000..72abdf553 --- /dev/null +++ b/spec/fixtures/contentful/repeat-entry-example.json @@ -0,0 +1,146 @@ +{ + "sys": { + "type": "Array" + }, + "total": 4, + "skip": 0, + "limit": 100, + "items": [ + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-02T10:48:35.748Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs", + "next": { + "sys": { + "type": "Link", + "linkType": "Entry", + "id": "hfjJgWRg4xiiiImwVRDtZ" + } + } + } + }, + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "hfjJgWRg4xiiiImwVRDtZ", + "type": "Entry", + "createdAt": "2020-11-04T12:28:30.442Z", + "updatedAt": "2020-11-26T16:39:54.188Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 6, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/dev-start-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": "5kZ9hIFDvNCEhjWs72SFwj" + } + } + } + }, + { + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "5kZ9hIFDvNCEhjWs72SFwj", + "type": "Entry", + "createdAt": "2020-12-02T10:48:35.748Z", + "updatedAt": "2020-12-02T10:48:35.748Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "staticContent" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/timelines", + "title": "When you should start", + "body": "Procuring a new catering contract can take up to 6 months to consult, create, review and award. \n\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.", + "type": "paragraphs", + "next": { + "sys": { + "type": "Link", + "linkType": "Entry", + "id": "hfjJgWRg4xiiiImwVRDtZ" + } + } + } + } + ] +} diff --git a/spec/fixtures/contentful/single-date-example.json b/spec/fixtures/contentful/single-date-example.json new file mode 100644 index 000000000..752133c8f --- /dev/null +++ b/spec/fixtures/contentful/single-date-example.json @@ -0,0 +1,36 @@ +{ + "sys": { + "space": { + "sys": { + "type": "Link", + "linkType": "Space", + "id": "rwl7tyzv9sys" + } + }, + "id": "55G5kpCLLL3h5yBQLiVlYy", + "type": "Entry", + "createdAt": "2020-12-08T10:43:19.102Z", + "updatedAt": "2020-12-08T10:43:19.102Z", + "environment": { + "sys": { + "id": "develop", + "type": "Link", + "linkType": "Environment" + } + }, + "revision": 1, + "contentType": { + "sys": { + "type": "Link", + "linkType": "ContentType", + "id": "question" + } + }, + "locale": "en-US" + }, + "fields": { + "slug": "/starting-date", + "type": "single date", + "title": "When will this start?" + } +} diff --git a/spec/helpers/step_helper_spec.rb b/spec/helpers/step_helper_spec.rb new file mode 100644 index 000000000..aa5172f53 --- /dev/null +++ b/spec/helpers/step_helper_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe StepHelper, type: :helper do + describe "#radio_options" do + it "returns an array of OpenStruct objects" do + options = ["green", "yellow"] + result = helper.radio_options(array_of_options: options) + expect(result).to match([ + OpenStruct.new(id: "green", name: "Green"), + OpenStruct.new(id: "yellow", name: "Yellow") + ]) + end + end + + describe "#checkbox_options" do + it "returns an array of OpenStruct objects" do + options = ["red", "blue"] + result = helper.checkbox_options(array_of_options: options) + expect(result).to match([ + OpenStruct.new(id: "red", name: "Red"), + OpenStruct.new(id: "blue", name: "Blue") + ]) + end + end +end diff --git a/spec/jobs/warm_entry_cache_job_spec.rb b/spec/jobs/warm_entry_cache_job_spec.rb new file mode 100644 index 000000000..7de1f74b3 --- /dev/null +++ b/spec/jobs/warm_entry_cache_job_spec.rb @@ -0,0 +1,87 @@ +require "rails_helper" + +RSpec.describe WarmEntryCacheJob, type: :job do + include ActiveJob::TestHelper + + before(:each) { ActiveJob::Base.queue_adapter = :test } + after(:each) { RedisCache.redis.flushdb } + + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj", + CONTENTFUL_ENTRY_CACHING: "true" + ) do + example.run + end + end + + describe ".perform_later" do + it "enqueues a job asynchronously on the caching queue" do + expect { + described_class.perform_later + }.to have_enqueued_job.on_queue("caching") + end + + it "asks GetAllContentfulEntries for the Contentful entries" do + stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "multiple-entries-example.json" + ) + + described_class.perform_later + perform_enqueued_jobs + + expect(RedisCache.redis.get("contentful:entry:5kZ9hIFDvNCEhjWs72SFwj")) + .to eql( + "\"{\\\"sys\\\":{\\\"space\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Space\\\",\\\"id\\\":\\\"rwl7tyzv9sys\\\"}},\\\"id\\\":\\\"5kZ9hIFDvNCEhjWs72SFwj\\\",\\\"type\\\":\\\"Entry\\\",\\\"createdAt\\\":\\\"2020-12-02T10:48:35.748Z\\\",\\\"updatedAt\\\":\\\"2020-12-02T10:48:35.748Z\\\",\\\"environment\\\":{\\\"sys\\\":{\\\"id\\\":\\\"develop\\\",\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Environment\\\"}},\\\"revision\\\":1,\\\"contentType\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"ContentType\\\",\\\"id\\\":\\\"staticContent\\\"}},\\\"locale\\\":\\\"en-US\\\"},\\\"fields\\\":{\\\"slug\\\":\\\"/timelines\\\",\\\"title\\\":\\\"When you should start\\\",\\\"body\\\":\\\"Procuring a new catering contract can take up to 6 months to consult, create, review and award. \\\\n\\\\nUsually existing contracts start and end in the month of September. We recommend starting this process around March.\\\",\\\"type\\\":\\\"paragraphs\\\",\\\"next\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Entry\\\",\\\"id\\\":\\\"hfjJgWRg4xiiiImwVRDtZ\\\"}}}}\"" + ) + expect(RedisCache.redis.get("contentful:entry:hfjJgWRg4xiiiImwVRDtZ")) + .to eql( + "\"{\\\"sys\\\":{\\\"space\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Space\\\",\\\"id\\\":\\\"rwl7tyzv9sys\\\"}},\\\"id\\\":\\\"hfjJgWRg4xiiiImwVRDtZ\\\",\\\"type\\\":\\\"Entry\\\",\\\"createdAt\\\":\\\"2020-11-04T12:28:30.442Z\\\",\\\"updatedAt\\\":\\\"2020-11-26T16:39:54.188Z\\\",\\\"environment\\\":{\\\"sys\\\":{\\\"id\\\":\\\"develop\\\",\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Environment\\\"}},\\\"revision\\\":6,\\\"contentType\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"ContentType\\\",\\\"id\\\":\\\"question\\\"}},\\\"locale\\\":\\\"en-US\\\"},\\\"fields\\\":{\\\"slug\\\":\\\"/dev-start-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\\\":\\\"52Ni9UFvZj8BYXSbhs373C\\\"}}}}\"" + ) + end + + context "when the journey order cannot be built" do + it "does not add new items to the cache" do + allow_any_instance_of(BuildJourneyOrder) + .to receive(:call) + .and_raise(BuildJourneyOrder::RepeatEntryDetected) + + stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "repeat-entry-example.json" + ) + + described_class.perform_later + perform_enqueued_jobs + + expect(RedisCache.redis).not_to receive(:set).with("contentful:entry:5kZ9hIFDvNCEhjWs72SFwj", anything) + end + + it "extends the TTL on all existing items by 24 hours" do + RedisCache.redis.set("contentful:entry:5kZ9hIFDvNCEhjWs72SFwj", "\"{\\}\"") + + allow_any_instance_of(BuildJourneyOrder) + .to receive(:call) + .and_raise(BuildJourneyOrder::RepeatEntryDetected) + + stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "repeat-entry-example.json" + ) + + freeze_time do + ttl_extension = 60 * 60 * 24 + existing_ttl = -1 + + expect(RedisCache.redis) + .to receive(:expire) + .with("contentful:entry:5kZ9hIFDvNCEhjWs72SFwj", ttl_extension + existing_ttl) + + described_class.perform_later + perform_enqueued_jobs + end + end + end + end +end diff --git a/spec/models/checkbox_answers_spec.rb b/spec/models/checkbox_answers_spec.rb new file mode 100644 index 000000000..e2bf38f8e --- /dev/null +++ b/spec/models/checkbox_answers_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe CheckboxAnswers, type: :model do + it { should belong_to(:step) } + + describe "#response" do + it "returns an array of strings" do + answer = build(:checkbox_answers, response: ["Blue", "Green"]) + expect(answer.response).to eq(["Blue", "Green"]) + end + end + + describe "validations" do + it { is_expected.to validate_presence_of(:response) } + end + + describe "#response=" do + context "when the response only includes blank items" do + it "should be invalid" do + answer = build(:checkbox_answers, response: ["", ""]) + expect(answer.valid?).to eq(false) + end + end + + context "when the response a mix of present and blank items" do + it "should only store the present values" do + answer = build(:checkbox_answers, response: ["Foo", ""]) + expect(answer.response).to eq(["Foo"]) + end + end + end +end diff --git a/spec/models/long_text_answer_spec.rb b/spec/models/long_text_answer_spec.rb new file mode 100644 index 000000000..f71ec374c --- /dev/null +++ b/spec/models/long_text_answer_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +RSpec.describe LongTextAnswer, type: :model do + it { should belong_to(:step) } + + it "captures the users response as a string" do + answer = build(:long_text_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/single_date_answer_spec.rb b/spec/models/single_date_answer_spec.rb new file mode 100644 index 000000000..58d8ac896 --- /dev/null +++ b/spec/models/single_date_answer_spec.rb @@ -0,0 +1,16 @@ +require "rails_helper" + +RSpec.describe SingleDateAnswer, type: :model do + it { should belong_to(:step) } + + describe "#response" do + it "returns a date" do + answer = build(:single_date_answer, response: Date.new(2020, 10, 1)) + expect(answer.response).to eq(Date.new(2020, 10, 1)) + end + end + + describe "validations" do + it { is_expected.to validate_presence_of(:response) } + end +end diff --git a/spec/models/step_spec.rb b/spec/models/step_spec.rb index 11bfb2abe..54c8a1956 100644 --- a/spec/models/step_spec.rb +++ b/spec/models/step_spec.rb @@ -4,6 +4,9 @@ it { should belong_to(:journey) } it { should have_one(:radio_answer) } it { should have_one(:short_text_answer) } + it { should have_one(:long_text_answer) } + it { should have_one(:single_date_answer) } + it { should have_one(:checkbox_answers) } it "store the basic fields of a contentful response" do step = build(:step, @@ -18,11 +21,8 @@ end it "captures the raw contentful response" do - contentful_json_response = JSON("foo": {}) - step = build(:step, - raw: contentful_json_response) - - expect(step.raw).to eql("{\"foo\":{}}") + step = build(:step, raw: {foo: "bar"}) + expect(step.raw).to eql({"foo" => "bar"}) end describe "#answer" do @@ -41,20 +41,28 @@ expect(step.answer).to eq(short_text_answer) end end - end - describe "#question?" do - context "when the contentful model is 'question'" do - it "returns true" do - step = build(:step, :radio, contentful_model: "question") - expect(step.question?).to eq(true) + context "when a LongTextAnswer is associated to the step" do + it "returns the LongTextAnswer object" do + long_text_answer = create(:long_text_answer) + step = create(:step, :long_text, long_text_answer: long_text_answer) + expect(step.answer).to eq(long_text_answer) + end + end + + context "when a SingleDateAnswer is associated to the step" do + it "returns the SingleDateAnswer object" do + single_date_answer = create(:single_date_answer) + step = create(:step, :single_date, single_date_answer: single_date_answer) + expect(step.answer).to eq(single_date_answer) end end - context "when the contentful model is NOT 'question'" do - it "returns false" do - step = build(:step, contentful_model: "staticContent") - expect(step.question?).to eq(false) + context "when a CheckboxAnswers is associated to the step" do + it "returns the CheckboxAnswers object" do + checkbox_answers = create(:checkbox_answers) + step = create(:step, :checkbox_answers, checkbox_answers: checkbox_answers) + expect(step.answer).to eq(checkbox_answers) end end end diff --git a/spec/presenters/step_presenter_spec.rb b/spec/presenters/step_presenter_spec.rb new file mode 100644 index 000000000..1fc44e9e2 --- /dev/null +++ b/spec/presenters/step_presenter_spec.rb @@ -0,0 +1,21 @@ +require "rails_helper" + +RSpec.describe StepPresenter do + describe "#question?" do + context "when the contentful model is 'question'" do + it "returns true" do + step = build(:step, :radio, contentful_model: "question") + presenter = described_class.new(step) + expect(presenter.question?).to eq(true) + end + end + + context "when the contentful model is NOT 'question'" do + it "returns false" do + step = build(:step, contentful_model: "staticContent") + presenter = described_class.new(step) + expect(presenter.question?).to eq(false) + end + end + end +end diff --git a/spec/requests/contentful_caching_spec.rb b/spec/requests/contentful_caching_spec.rb index c723b5684..ef2843584 100644 --- a/spec/requests/contentful_caching_spec.rb +++ b/spec/requests/contentful_caching_spec.rb @@ -40,7 +40,7 @@ RedisCache.redis.del("contentful:entry:1UjQurSOi5MWkcRuGxdXZS") end - it "sets a TTL to 48 hours by default" do + it "sets a TTL to 72 hours by default" do journey = create(:journey, next_entry_id: "1UjQurSOi5MWkcRuGxdXZS") stub_get_contentful_entry( entry_id: "1UjQurSOi5MWkcRuGxdXZS", @@ -51,7 +51,7 @@ get new_journey_step_path(journey) expect(RedisCache.redis.ttl("contentful:entry:1UjQurSOi5MWkcRuGxdXZS")) - .to eq(172_800) + .to eq(60 * 60 * 72) end RedisCache.redis.del("contentful:entry:1UjQurSOi5MWkcRuGxdXZS") diff --git a/spec/services/build_journey_order_spec.rb b/spec/services/build_journey_order_spec.rb new file mode 100644 index 000000000..f4ce4ea2a --- /dev/null +++ b/spec/services/build_journey_order_spec.rb @@ -0,0 +1,94 @@ +require "rails_helper" + +RSpec.describe BuildJourneyOrder do + describe "#call" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + context "when there are multiple entries" do + it "returns each ID in the order they appear" do + fake_entries = fake_contentful_entry_array( + contentful_fixture_filename: "closed-path-with-multiple-example.json" + ) + + result = described_class.new( + entries: fake_entries, + starting_entry_id: "5kZ9hIFDvNCEhjWs72SFwj" + ).call + + expect(result).to match([fake_entries.first, fake_entries.last]) + end + end + + context "when the journey visits the same node twice" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + it "raises a rollbar event" do + fake_entries = fake_contentful_entry_array( + contentful_fixture_filename: "repeat-entry-example.json" + ) + + expect(Rollbar).to receive(:error) + .with("A repeated Contentful entry was found in the same journey", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: "5kZ9hIFDvNCEhjWs72SFwj") + .and_call_original + + expect { + described_class.new( + entries: fake_entries, + starting_entry_id: "5kZ9hIFDvNCEhjWs72SFwj" + ).call + }.to raise_error(BuildJourneyOrder::RepeatEntryDetected) + end + end + + context "when the journey visits more than the maximum permitted number of entries" do + around do |example| + ClimateControl.modify( + CONTENTFUL_PLANNING_START_ENTRY_ID: "5kZ9hIFDvNCEhjWs72SFwj" + ) do + example.run + end + end + + it "raises a rollbar event with the last entry" do + # Creating 50 linked entries in a fixture slows the test suite down + # We assume these 50 entries are chained together in a linear sequence. + fake_entries = fake_contentful_entry_array( + contentful_fixture_filename: "closed-path-with-multiple-example.json" + ) + expect(BuildJourneyOrder::ENTRY_JOURNEY_MAX_LENGTH).to eq(50) + stub_const("BuildJourneyOrder::ENTRY_JOURNEY_MAX_LENGTH", 1) + + expect(Rollbar).to receive(:error) + .with("More than #{BuildJourneyOrder::ENTRY_JOURNEY_MAX_LENGTH} steps were found in a journey map", + contentful_url: ENV["CONTENTFUL_URL"], + contentful_space_id: ENV["CONTENTFUL_SPACE"], + contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"], + contentful_entry_id: "hfjJgWRg4xiiiImwVRDtZ") + .and_call_original + + expect { + described_class.new( + entries: fake_entries, + starting_entry_id: "5kZ9hIFDvNCEhjWs72SFwj" + ).call + }.to raise_error(BuildJourneyOrder::TooManyChainedEntriesDetected) + end + end + end +end diff --git a/spec/services/cache_spec.rb b/spec/services/cache_spec.rb index 52b3b4184..e8c473adc 100644 --- a/spec/services/cache_spec.rb +++ b/spec/services/cache_spec.rb @@ -1,16 +1,19 @@ require "rails_helper" RSpec.describe Cache do + after(:each) do + RedisCache.redis.flushdb + end + it "requires a key, enabled flag and ttl" do - result = described_class.new(key: "redis-key", enabled: "true", ttl: 123) - expect(result.key).to eql("redis-key") + result = described_class.new(enabled: "true", ttl: 123) expect(result.enabled).to eql("true") expect(result.ttl).to eql(123) end describe "#redis_cache" do it "returns an object that quacks like a redis instance (since MockRedis is used in Test)" do - result = described_class.new(key: anything, enabled: anything, ttl: anything) + result = described_class.new(enabled: anything, ttl: anything) .redis_cache expect(result.respond_to?(:get)).to eq(true) @@ -23,23 +26,23 @@ describe "#hit?" do context "when enabled" do it "asks redis if that key exists" do - cache = described_class.new(key: "key-to-find", enabled: "true", ttl: anything) + cache = described_class.new(enabled: "true", ttl: anything) redis = cache.redis_cache expect(redis).to receive(:exists?).with("key-to-find") - cache.hit? + cache.hit?(key: "key-to-find") end end context "when disabled" do it "returns false" do - cache = described_class.new(key: "key-to-find", enabled: "false", ttl: anything) + cache = described_class.new(enabled: "false", ttl: anything) redis = cache.redis_cache expect(redis).not_to receive(:exists?).with("key-to-find") - result = cache.hit? + result = cache.hit?(key: "key-to-find") expect(result).to eq(false) end end @@ -47,37 +50,74 @@ describe "#get" do it "asks redis to get that key" do - cache = described_class.new(key: "key-to-find", enabled: "false", ttl: anything) + cache = described_class.new(enabled: "false", ttl: anything) redis = cache.redis_cache expect(redis).to receive(:get).with("key-to-find") - cache.get + cache.get(key: "key-to-find") end end describe "#set" do context "when enabled" do it "asks redis to set that key with the ttl" do - cache = described_class.new(key: "key-to-set", enabled: "true", ttl: 123) + cache = described_class.new(enabled: "true", ttl: 123) redis = cache.redis_cache expect(redis).to receive(:set).with("key-to-set", "value-to-set") expect(redis).to receive(:expire).with("key-to-set", 123) - cache.set(value: "value-to-set") + cache.set(key: "key-to-set", value: "value-to-set") end end context "when disabled" do it "does not ask redis to set that key" do - cache = described_class.new(key: "key-to-set", enabled: "false", ttl: 123) + cache = described_class.new(enabled: "false", ttl: 123) redis = cache.redis_cache expect(redis).not_to receive(:set).with("key-to-set", "value-to-set") expect(redis).not_to receive(:expire).with("key-to-set", 123) - cache.set(value: "value-to-set") + cache.set(key: "key-to-set", value: "value-to-set") + end + end + end + + describe "#extend_ttl_on_all_entries" do + it "only updates redis keys associated to contentful entries" do + cache = described_class.new(enabled: anything, ttl: anything) + redis = cache.redis_cache + + redis.set("contentful:entry:123", "value") + expect(redis).to receive(:expire).with("contentful:entry:123", (60 * 60 * 24) + -1) + + cache.extend_ttl_on_all_entries + end + + context "when other redis keys exist" do + it "only updates redis keys associated to contentful entries" do + cache = described_class.new(enabled: anything, ttl: anything) + redis = cache.redis_cache + + redis.set("another_key", "another_value") + expect(redis).not_to receive(:expire).with("another_key") + + cache.extend_ttl_on_all_entries + end + end + + context "when the key already has a TTL" do + it "sets a combined TTL with the existing and the extension" do + cache = described_class.new(enabled: anything, ttl: anything) + redis = cache.redis_cache + + redis.set("contentful:entry:123", "value") + redis.expire("contentful:entry:123", 10) + expect(redis).to receive(:expire).with("contentful:entry:123", (60 * 60 * 24) + 10) + + cache.extend_ttl_on_all_entries end end end diff --git a/spec/services/create_journey_step_spec.rb b/spec/services/create_journey_step_spec.rb index 86edab1cf..4d08d6028 100644 --- a/spec/services/create_journey_step_spec.rb +++ b/spec/services/create_journey_step_spec.rb @@ -5,7 +5,7 @@ context "when the new step is of type step" do it "creates a local copy of the new step" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "radio-question-example.json" ) @@ -13,15 +13,53 @@ expect(step.title).to eq("Which service do you need?") expect(step.help_text).to eq("Tell us which service you need.") + expect(step.contentful_id).to eq("1UjQurSOi5MWkcRuGxdXZS") expect(step.contentful_model).to eq("question") expect(step.contentful_type).to eq("radios") expect(step.options).to eq(["Catering", "Cleaning"]) - expect(step.raw).to eq(fake_entry.raw) + expect(step.raw).to eq( + "fields" => { + "helpText" => "Tell us which service you need.", + "options" => ["Catering", "Cleaning"], + "slug" => "/which-service", + "title" => "Which service do you need?", + "type" => "radios" + }, + "sys" => { + "contentType" => { + "sys" => { + "id" => "question", + "linkType" => "ContentType", + "type" => "Link" + } + }, + "createdAt" => "2020-09-07T10:56:40.585Z", + "environment" => { + "sys" => { + "id" => "master", + "linkType" => "Environment", + "type" => "Link" + } + }, + "id" => "1UjQurSOi5MWkcRuGxdXZS", + "locale" => "en-US", + "revision" => 7, + "space" => { + "sys" => { + "id" => "jspwts36h1os", + "linkType" => "Space", + "type" => "Link" + } + }, + "type" => "Entry", + "updatedAt" => "2020-09-14T22:16:54.633Z" + } + ) end it "updates the journey with a new next_entry_id" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "has-next-question-example.json" ) @@ -34,7 +72,7 @@ context "when the question is of type 'short_text'" do it "sets help_text and options to nil" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "short-text-question-example.json" ) @@ -45,7 +83,7 @@ it "replaces spaces with underscores" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "short-text-question-example.json" ) @@ -58,7 +96,7 @@ context "when the new step does not have a following step" do it "updates the journey by setting the next_entry_id to nil" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "radio-question-example.json" ) @@ -71,7 +109,7 @@ context "when the new entry has a body field" do it "updates the step with the body" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "static-content-example.json" ) @@ -86,10 +124,40 @@ end end + context "when the new entry has a 'primaryCallToAction' field" do + it "updates the step with the body" do + journey = create(:journey, :catering) + fake_entry = fake_contentful_entry( + contentful_fixture_filename: "primary-button-example.json" + ) + + step, _answer = described_class.new( + journey: journey, contentful_entry: fake_entry + ).call + + expect(step.primary_call_to_action_text).to eq("Go onwards!") + end + end + + context "when no 'primaryCallToAction' is provided" do + it "default copy is used for the button" do + journey = create(:journey, :catering) + fake_entry = fake_contentful_entry( + contentful_fixture_filename: "no-primary-button-example.json" + ) + + step, _answer = described_class.new( + journey: journey, contentful_entry: fake_entry + ).call + + expect(step.primary_call_to_action_text).to eq(I18n.t("generic.button.next")) + end + end + context "when the new entry has an unexpected content model" do it "raises an error" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "an-unexpected-model-example.json" ) @@ -100,7 +168,7 @@ it "raises a rollbar event" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "an-unexpected-model-example.json" ) @@ -123,7 +191,7 @@ context "when the new step has an unexpected step type" do it "raises an error" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "an-unexpected-question-type-example.json" ) @@ -134,7 +202,7 @@ it "raises a rollbar event" do journey = create(:journey, :catering) - fake_entry = fake_contentful_step_entry( + fake_entry = fake_contentful_entry( contentful_fixture_filename: "an-unexpected-question-type-example.json" ) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2fe7150d9..b5c1e182a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,6 +20,9 @@ SimpleCov.minimum_coverage 99 SimpleCov.start "rails" +require "webmock/rspec" +WebMock.disable_net_connect!(allow_localhost: true) + RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest diff --git a/spec/support/contentful_helpers.rb b/spec/support/contentful_helpers.rb index 819898e5b..7d0389c6b 100644 --- a/spec/support/contentful_helpers.rb +++ b/spec/support/contentful_helpers.rb @@ -6,7 +6,7 @@ def stub_get_contentful_entry( raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{fixture_filename}") contentful_connector = stub_contentful_connector - contentful_response = fake_contentful_step_entry(contentful_fixture_filename: fixture_filename) + contentful_response = fake_contentful_entry(contentful_fixture_filename: fixture_filename) allow(contentful_connector).to receive(:get_entry_by_id) .with(entry_id) .and_return(contentful_response) @@ -15,6 +15,21 @@ def stub_get_contentful_entry( .and_return(raw_response) end + def stub_get_contentful_entries( + entry_id: "5kZ9hIFDvNCEhjWs72SFwj", + fixture_filename: "multiple-entries-example.json" + ) + raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{fixture_filename}") + + contentful_connector = stub_contentful_connector + contentful_response = fake_contentful_entry_array(contentful_fixture_filename: fixture_filename) + allow(contentful_connector).to receive(:get_all_entries) + .and_return(contentful_response) + + allow(contentful_response).to receive(:raw) + .and_return(raw_response) + end + def stub_contentful_connector contentful_connector = instance_double(ContentfulConnector) expect(ContentfulConnector).to receive(:new) @@ -30,13 +45,7 @@ def stub_contentful_client contentful_client end - def stub_contentful_step(fake_entry: fake_contentful_step_entry) - get_contentful_step_double = instance_double(GetContentfulEntry) - allow(GetContentfulEntry).to receive(:new).and_return(get_contentful_step_double) - allow(get_contentful_step_double).to receive(:call).and_return(fake_entry) - end - - def fake_contentful_step_entry(contentful_fixture_filename:) + def fake_contentful_entry(contentful_fixture_filename:) raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{contentful_fixture_filename}") hash_response = JSON.parse(raw_response) @@ -49,8 +58,16 @@ def fake_contentful_step_entry(contentful_fixture_filename:) 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, + primary_call_to_action: hash_response.dig("fields", "primaryCallToAction"), + raw: hash_response, content_type: double(id: hash_response.dig("sys", "contentType", "sys", "id")) ) end + + def fake_contentful_entry_array(contentful_fixture_filename:) + raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/#{contentful_fixture_filename}") + response_hash = JSON.parse(raw_response) + + Contentful::ResourceBuilder.new(response_hash).run + end end