How to procure a catering contract for your school.
+
+
+
+
+
+
+ Before you start
+
+
+
+
+
A catering contract typically takes between 3 to 6 months to complete.
+
+
If you have an existing contract, you will need to find out the:
+
+
end date
+
notice period
+
+
+
This will help you to plan when to start your procurement. It's best to start a new contract:
+
+
at the beginning of the academic year
+
after long school holiday, for example, Christmas or Easter
+
+
+
+
+
+
+
+
+ Ways to procure a catering contract
+
+
+
+
+
A catering contract is a high value procurement. We generally say high is over 40,000.
+
You must run your own buying process, inviting suppliers to submit bids for the work.
+
If you estimate that the contract is likely to be close to or over £189,330 you may have to run a compliant buying process because it exceeds the procurement thresholds.
+
+
+
+
+
+
+
+ Rules, regulations and requirements
+
+
+
+
+
You'll need to be aware of some of the rules, regulations and requirements that can apply to a catering contract.
If, over the lifetime of your contract, the value of your provision is over the procurement threshold, you'll need to advertise a contract notice on the Find a Tender service.
+
+
TUPE and LGPS (Local Government Pension Scheme)
+
+
Consider whether TUPE and LGPS will apply, especially if you're looking to outsource or come away from your local authority for the first time.
+
When you give notice of termination to your current provider, you should also request details of any staff that will be subject to TUPE transfer.
+
This anonymised information should include:
+
+
salary details
+
terms conditions
+
pension details
+
+
+
Disclosure and Barring Service (DBS) checks
+
You'll need confirmation from suppliers that they've had the relevant DBS checks.
+
It's your responsibility to check that everyone involved in the catering contract is DBS checked.
+
+
+
+
+
+
+
+ In-house catering
+
+
+
+
+
Running the service in-house is another option. You may want to consider whether this is right for your school before you procure a contract. There are benefits and challenges to running the service yourself.
+
Benefits
+
You might be able to:
+
+
increase uptake of school meals
+
negotiate better prices from suppliers
+
increase food quality
+
respond quicker if your equipment is broken or faulty
+
develop staff skills through bespoke training
+
generate income by offering your catering services to other schools or nurseries
+
be in control of your expenditure
+
+
+
Staff training
+
Consider how you will provide initial training on:
+
+
food specifications
+
menu building
+
health and safety
+
food hygiene
+
food preparation skills
+
+
+
Costs and spending
+
You should fully understand your costings so you can:
+
+
plan for increased spending, for example, staffing, equipment, food and utilities
+
plan for maintenance of equipment
+
avoid expensive budgeting errors
+
keep staff up to date with legislation and regulations by allowing time and money for on-going training
+
+
+
Managing demand
+
You should:
+
+
get regular feedback from parents, pupils and governors
+
check you have enough dining space to accommodate increased demand
+
+
+
+
TUPE and LGPS
+
If you're moving away from a local authority, you'll need to obtain TUPE and LGPS information.
+
There may be time frames around when the local authority is obliged to release this information.
+
These time frames can cause delays and further expense if you award the contract without having the details.
+
+
Invite your local authority to tender
+
Invite your local authority to tender and involve them in the process. This way the TUPE and LGPS information must be provided during their bidding process.
+
+
Disclosure and Barring Service (DBS) checks
+
It's your responsibility to check that everyone involved in the catering contract is DBS checked.
+
+
Equipment and maintenance
+
Find out:
+
+
what equipment the current provider may be repossess
+
which maintenance contracts your provider covers
+
whether maintenance checks are up to date
+
+
+
+
+
+
+
+
+ Starting a procurement process
+
+
+
+
+
Who to involve
+
+
Make sure the headteacher and school governors have agreed to go ahead with procuring a new catering contract before you start.
+
Throughout the process you should talk to:
+
+
school governors
+
senior leadership team
+
staff involved in school catering
+
+
+
Try to:
+
+
keep staff informed
+
address any concerns the staff may have
+
be aware that staff may wish to contact their union
+
+
+
Talk to other schools
+
You may find it helpful to:
+
+
ask other schools if they’re looking to renew their catering contract
+
negotiate bulk deals and discounts with suppliers
+
+
This may help you to get better value and spread the workload with other schools.
+
+
Get feedback about supplier
+
When selecting the suppliers, you may want to talk to other schools they serve and get feedback on the service they offer.
+
+
Supplier samples
+
You may want to ask potential suppliers to produce sample meals for you to try as this will form part of your specification and tender requirements
+
+
+
+
+
+
+
+ Writing your requirements
+
+
+
+
+
This is the document that you give to suppliers explaining what you want to buy, sometimes called a specification.
+
+
It should tell potential suppliers what you need so that they can decide if they want to bid for the contract.
Below are some of the things that you should include. You can also <%= link_to "use our tool to create a specification", root_path, class: "govuk-link" %>.
+
+
+
Objectives
+
Your objectives are important because it helps a supplier to decide whether they are a good fit for your school. Include:
+
+
your mission statement
+
goals your school has
+
policies that relate to catering
+
objectives that relates to social value
+
+
+
Contract management
+
Set out how you expect the contract to be managed to ensure a good working relationship. Include:
+
+
your requirements on how the contract should be managed, such as any review meetings, how to monitor performance or costs, how complaints are managed and so on
+
what operational overheads the supplier will cover
+
any staff that the supplier will need to provide, and staff that need to be transferred
+
+
+
Service parameters
+
Tell suppliers what service you want them to provide and who for. Include:
+
+
when you want the service to start
+
current services types, and times they are provided
+
customer numbers
+
free school meal numbers
+
the services you would like the supplier to provide
+
+
+
Menu and ordering
+
Give suppliers some information about the food you would like them to serve and how it should be ordered and paid for. Include:
+
+
any requirements for the food served
+
ingredients or allergens to be avoided
+
ordering and payment systems you have, or would like to be provided
+
who is responsible for any dinner money debt
+
+
+
Facilities
+
Outline the spaces and equipment that you have available for use. Include:
+
+
the condition of any kitchens and if they are ready for service
+
equipment available to a supplier
+
a description of the dining areas
+
who sets up and clears away before and after service
+
cleaning responsibilities
+
+
+
+
+
+
+
+
+ What to do next
+
+
+
+
+
Once you have your specification, you will need to decide if you will be using the open or restricted procedure.
+ <% end %>
+ <% if option["display_further_information"] %>
+ <%= f.govuk_radio_button :response, option["value"], label: { text: option["value"] }, hint: { text: option["help_text"] } do %>
+ <% if option["display_further_information"] == "single" || option["display_further_information"] == true %>
+ <%= f.govuk_text_field "#{machine_value}_further_information", label: { text: option.fetch("further_information_help_text", "Optional further information"), hidden: !option.keys.include?("further_information_help_text") } %>
+ <% elsif option["display_further_information"] == "long" %>
+ <%= f.govuk_text_area "#{machine_value}_further_information", rows: 6, label: { text: option.fetch("further_information_help_text", "Optional further information"), hidden: !option.keys.include?("further_information_help_text") } %>
+ <% end %>
<% end %>
<% else %>
- <%= f.govuk_radio_button :response, option["value"].downcase, label: { text: option["value"] }, hint: { text: option["help_text"] } %>
+
+ <%= f.govuk_radio_button :response, option["value"], label: { text: option["value"] }, hint: { text: option["help_text"] } %>
<% end %>
<% end %>
<% end %>
diff --git a/app/views/steps/short_text.html.erb b/app/views/steps/short_text.html.erb
index 172c6649d..04a495804 100644
--- a/app/views/steps/short_text.html.erb
+++ b/app/views/steps/short_text.html.erb
@@ -1,7 +1,7 @@
<%= render layout: layout do |f| %>
<%= f.govuk_text_field :response,
- label: { text: @step.title, size: "l" },
- hint: { text: @step.help_text },
+ label: { text: @step_presenter.title, size: "l" },
+ hint: -> { @step_presenter.help_text_html },
width: "one-third"
%>
<% end %>
diff --git a/app/views/steps/single_date.html.erb b/app/views/steps/single_date.html.erb
index b09581207..cf8b660fa 100644
--- a/app/views/steps/single_date.html.erb
+++ b/app/views/steps/single_date.html.erb
@@ -1,6 +1,6 @@
<%= render layout: layout do |f| %>
<%= f.govuk_date_field :response,
- legend: { text: @step.title, size: "l" },
- hint: { text: @step.help_text }
+ legend: { text: @step_presenter.title, size: "l" },
+ hint: -> { @step_presenter.help_text_html }
%>
<% end %>
diff --git a/config/application.rb b/config/application.rb
index 0b9010946..c9011c268 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -8,6 +8,8 @@
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
+require "./lib/dfe_sign_in"
+
module BuyForYourSchool
class Application < Rails::Application
config.generators do |g|
@@ -40,5 +42,9 @@ class Application < Rails::Application
end
config.active_job.queue_adapter = :sidekiq
+
+ config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}")]
+ config.i18n.default_locale = :en
+ config.i18n.enforce_available_locales = false
end
end
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
new file mode 100644
index 000000000..5e915be4d
--- /dev/null
+++ b/config/brakeman.ignore
@@ -0,0 +1,57 @@
+{
+ "ignored_warnings": [
+ {
+ "warning_type": "Redirect",
+ "warning_code": 18,
+ "fingerprint": "11f128de39b52c4f17c8ac43be1a514cd57e37b172f37589d54738782abb2665",
+ "check_name": "Redirect",
+ "message": "Possible unprotected redirect",
+ "file": "app/controllers/sessions_controller.rb",
+ "line": 30,
+ "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
+ "code": "redirect_to(UserSession.new(:session => (session)).sign_out_url)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "SessionsController",
+ "method": "destroy"
+ },
+ "user_input": "UserSession.new(:session => (session)).sign_out_url",
+ "confidence": "High",
+ "note": ""
+ },
+ {
+ "warning_type": "Cross-Site Scripting",
+ "warning_code": 2,
+ "fingerprint": "3863b51add0146069c24410ec1bfc99015d680679d275ce49f27cc22b6dc8f9e",
+ "check_name": "CrossSiteScripting",
+ "message": "Unescaped model attribute",
+ "file": "app/views/specifications/show.html.erb",
+ "line": 15,
+ "link": "https://brakemanscanner.org/docs/warning_types/cross_site_scripting",
+ "code": "SpecificationRenderer.new(:template => Journey.find(journey_id).liquid_template, :answers => GetAnswersForSteps.new(:visible_steps => Journey.find(journey_id).visible_steps.includes([:radio_answer, :short_text_answer, :long_text_answer, :single_date_answer, :checkbox_answers, :number_answer, :currency_answer])).call).to_html",
+ "render_path": [
+ {
+ "type": "controller",
+ "class": "SpecificationsController",
+ "method": "show",
+ "line": 25,
+ "file": "app/controllers/specifications_controller.rb",
+ "rendered": {
+ "name": "specifications/show",
+ "file": "app/views/specifications/show.html.erb"
+ }
+ }
+ ],
+ "location": {
+ "type": "template",
+ "template": "specifications/show"
+ },
+ "user_input": "Journey.find(journey_id).liquid_template",
+ "confidence": "Weak",
+ "note": ""
+ }
+ ],
+ "updated": "2021-03-22 16:32:31 +0000",
+ "brakeman_version": "5.0.0"
+}
diff --git a/config/environments/production.rb b/config/environments/production.rb
index d2b469d9b..4713d7a99 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
+# Load environment variables that are created by GPaaS
+require_relative "../../lib/vcap_parser"
+VcapParser.load_service_environment_variables!
+
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 87d513e60..1349c2e8a 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -64,5 +64,7 @@
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
Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :checkbox_answers
+ Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :number_answer
+ Bullet.add_whitelist type: :unused_eager_loading, class_name: "Step", association: :currency_answer
end
end
diff --git a/config/initializers/dotenv.rb b/config/initializers/_dotenv.rb
similarity index 82%
rename from config/initializers/dotenv.rb
rename to config/initializers/_dotenv.rb
index 3986747f7..aca34a326 100644
--- a/config/initializers/dotenv.rb
+++ b/config/initializers/_dotenv.rb
@@ -2,11 +2,12 @@
# https://github.com/bkeepers/dotenv#required-keys
if defined?(Dotenv)
Dotenv.require_keys(
+ "APPLICATION_URL",
"CONTENTFUL_URL",
"CONTENTFUL_SPACE",
"CONTENTFUL_ENVIRONMENT",
"CONTENTFUL_ACCESS_TOKEN",
- "CONTENTFUL_PLANNING_START_ENTRY_ID",
+ "CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID",
"CONTENTFUL_PREVIEW_APP",
"CONTENTFUL_ENTRY_CACHING"
)
diff --git a/config/initializers/default_url_options.rb b/config/initializers/default_url_options.rb
new file mode 100644
index 000000000..7c05ebeae
--- /dev/null
+++ b/config/initializers/default_url_options.rb
@@ -0,0 +1,7 @@
+if ENV["APPLICATION_URL"]
+ uri = URI(ENV["APPLICATION_URL"])
+
+ Rails.application.routes.default_url_options[:host] = uri.host
+ Rails.application.routes.default_url_options[:port] = uri.port
+ Rails.application.routes.default_url_options[:protocol] = uri.scheme
+end
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
new file mode 100644
index 000000000..aadd4aacd
--- /dev/null
+++ b/config/initializers/omniauth.rb
@@ -0,0 +1,35 @@
+OmniAuth.config.logger = Rails.logger
+
+dfe_sign_in_issuer_uri = URI(ENV.fetch("DFE_SIGN_IN_ISSUER", "example"))
+dfe_sign_in_identifier = ENV.fetch("DFE_SIGN_IN_IDENTIFIER", "example")
+dfe_sign_in_secret = ENV.fetch("DFE_SIGN_IN_SECRET", "example")
+dfe_sign_in_redirect_uri = ENV.fetch("DFE_SIGN_IN_REDIRECT_URL", "example")
+
+dfe_sign_in_issuer_url = "#{dfe_sign_in_issuer_uri}:#{dfe_sign_in_issuer_uri.port}" if dfe_sign_in_issuer_uri.port
+
+options = {
+ name: :dfe,
+ discovery: true,
+ response_type: :code,
+ issuer: dfe_sign_in_issuer_url,
+ scope: %i[openid],
+ client_auth_method: :client_secret_post,
+ client_options: {
+ port: dfe_sign_in_issuer_uri.port,
+ scheme: dfe_sign_in_issuer_uri.scheme,
+ host: dfe_sign_in_issuer_uri.host,
+ identifier: dfe_sign_in_identifier,
+ secret: dfe_sign_in_secret,
+ redirect_uri: dfe_sign_in_redirect_uri
+ }
+}
+
+if DfESignIn.bypass?
+ Rails.application.config.middleware.use OmniAuth::Builder do
+ provider :developer,
+ fields: %i[uid],
+ uid_field: :uid
+ end
+else
+ Rails.application.config.middleware.use OmniAuth::Strategies::OpenIDConnect, options
+end
diff --git a/config/initializers/redis.rb b/config/initializers/redis.rb
index 593ba4db3..c97ceae99 100644
--- a/config/initializers/redis.rb
+++ b/config/initializers/redis.rb
@@ -6,7 +6,7 @@ def redis
else
Redis::Namespace.new(
"buy_for_your_school",
- redis: Redis.new(url: ENV["REDIS_CACHE_URL"])
+ redis: Redis.new(url: ENV["REDIS_URL"], db: 1)
)
end
end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index e80787b84..b4b90970f 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -1,5 +1,5 @@
Sidekiq.configure_server do |config|
- config.redis = {url: ENV["REDIS_SIDEKIQ_URL"]}
+ config.redis = {url: "#{ENV["REDIS_URL"]}/2"}
# Sidekiq Cron
schedule_file = "config/schedule.yml"
@@ -9,5 +9,5 @@
end
Sidekiq.configure_client do |config|
- config.redis = {url: ENV["REDIS_SIDEKIQ_URL"]}
+ config.redis = {url: "#{ENV["REDIS_URL"]}/2"}
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 600ad125b..48f6a8fcb 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -6,7 +6,9 @@ en:
default: "%-d %b %Y"
generic:
button:
- start: "Continue"
+ back: "Back"
+ start: "Start"
+ sign_out: "Sign out"
next: "Continue"
change_answer: "Change"
update: "Update"
@@ -19,32 +21,75 @@ en:
message: "This environment is only for previewing Contentful changes before publishing."
planning:
start_page:
- page_title: "Identify the level of support you need for what you're buying"
- overview_title: "Overview"
+ page_title: "Catering services"
+ specifying:
+ start_page:
+ page_title: Create a specification to procure a catering service for your school
overview_body:
- - "Get the right level of support you need to buy goods and services for your school."
- - "By telling us about your experience and confidence levels in buying the goods or service, we will tailor the guidance that will be shown to you on screen."
- - "More guidance will be shown if you need more support. For experienced buyers, the guidance will be hidden, but will be accessible to you within each page if you need it."
- before_you_start_title: "Before you start"
- before_you_start_list_title: "Consider:"
- before_you_start_list_items:
- - any previous experience in buying the goods or services you need
- - your level of understanding of the procurement process
- - your school's procurement policy
- - support you have access to in your school
- - information on the current contract
- before_you_start_body: "It will take around 3 minutes to complete the process"
+ - Use this service to create a specification for a new catering service for a single school in England.
+ who_for_title: Who this service is for
+ who_for_can_use_body: "You can use this service if you:"
+ who_for_can_use_list:
+ - are responsible for procuring a new catering service for a school
+ - are procuring for one school – either a local authority maintained school or an academy in a single or multi-academy trust
+ - intend to run your own procurement process or choose a supplier from a framework – read more about the different procurement routes you can take
+ who_for_cannot_use_body: "You currently cannot use this service to create a specification for any of the following:"
+ who_for_cannot_use_list:
+ - any other service outside of catering
+ - goods, supplies or equipment only
+ - multiple schools at the same time
+ how_service_works_title: How this service works
+ how_service_works_document_body: "Use this service to create a document that:"
+ how_service_works_document_list:
+ - describes the service your school is looking for
+ - collates all the information that any suppliers interested in your contract need to know
+ - includes all the standard regulations and requirements that school catering suppliers must comply with – we’ll add these automatically for you, you do not need to know what these are
+ how_service_works_themes_body: "We’ll guide you through a series of questions about your school and your requirements, including specific questions around the following themes:"
+ how_service_works_themes_list:
+ - any mission statement, food policy or goals your school has that are relevant to the new service
+ - any objectives you may have relating to social value
+ - any requirements you may have relating to management of the contract, operational overheads and staff
+ - the dates, types, times and customer numbers of existing catering services
+ - any requirements for the food served, as well as ordering and payment systems
+ - the catering spaces and equipment available to a supplier in your school
+ pause_and_resume_body: You can pause and resume your specification at any time.
errors:
contentful_entry_not_found: "An unexpected error occurred. The starting step has been revoked by the content team."
journey:
+ index:
+ existing:
+ header: "Existing specifications"
specification:
header: "Your specification"
+ warning: "You have not completed all the tasks. There may be information missing from your specification."
+ button: "View your specification"
+ download:
+ warning:
+ incomplete: "
You have not completed all the tasks in Create a specification. There may be information missing from your specification.
"
+ specification:
+ dashboard:
+ header: "Specifications dashboard"
+ create:
+ header: "Create a new specification"
+ body: "Create a new specification for a catering procurement."
+ link: "Create a new specification"
+ existing:
+ header: "Existing specifications"
+ body: "Continue with a draft specification, and review completed specifications."
+ link: "Existing specifications"
task_list:
status:
not_started: Not started
completed: Completed
journey_map:
page_title: "Contentful entry map"
+ edit_step_link_text: "Edit step in Contentful"
+ preview_step_link_text: "Preview step in service"
+ spec_template_tag_title: "Specification tag"
+ resume:
+ notification:
+ title: "Returning to this specification"
+ body: "You can return to this page to make changes or view your specification at any time, either by bookmarking this page in your browser or by making a note of the following address:"
errors:
contentful_entry_not_found:
page_title: "An unexpected error occurred"
@@ -58,9 +103,10 @@ en:
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}"
specification_template_invalid:
page_title: "An unexpected error occurred"
page_body: "The service has had a problem trying to retrieve a working Specification template. The team have been notified of this problem and you should be able to retry shortly."
+ sign_in:
+ unexpected_failure:
+ page_title: "An unexpected error occurred"
+ page_body: "The service was unable to successfully authenticate you. The team have been notified of this problem and you should be able to retry shortly."
diff --git a/config/locales/models/single_date_answer.en.yml b/config/locales/models/single_date_answer.en.yml
new file mode 100644
index 000000000..05deddf52
--- /dev/null
+++ b/config/locales/models/single_date_answer.en.yml
@@ -0,0 +1,8 @@
+---
+en:
+ activerecord:
+ errors:
+ models:
+ single_date_answer:
+ attributes:
+ response: Provide a real date for this answer
diff --git a/config/routes.rb b/config/routes.rb
index af256d000..5e0b32555 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -2,10 +2,20 @@
Rails.application.routes.draw do
get "health_check" => "application#health_check"
- root to: "high_voltage/pages#show", id: "planning_start_page"
+ root to: "pages#show", id: "specifying_start_page"
+
+ get "planning" => "pages#show", "id" => "planning_start_page"
+ post "/api/contentful/entry_updated" => "api/contentful/entries#changed"
+
+ # DfE Sign In
+ get "/auth/dfe/callback", to: "sessions#create"
+ get "/auth/dfe/signout", to: "sessions#destroy"
+ get "/auth/failure", to: "sessions#failure"
+ post "/auth/developer/callback" => "sessions#bypass_callback" if Rails.env.development?
resource :journey_map, only: [:new]
- resources :journeys, only: [:new, :show] do
+ resources :journeys, only: [:index, :new, :show] do
+ resource :specification, only: [:show]
resources :steps, only: [:new, :show, :edit] do
resources :answers, only: [:create, :update]
end
@@ -14,4 +24,6 @@
namespace :preview do
resources :entries, only: [:show]
end
+
+ get "dashboard", to: "dashboard#show"
end
diff --git a/db/migrate/20210125171840_remove_next_entry_id.rb b/db/migrate/20210125171840_remove_next_entry_id.rb
new file mode 100644
index 000000000..f0883ad67
--- /dev/null
+++ b/db/migrate/20210125171840_remove_next_entry_id.rb
@@ -0,0 +1,5 @@
+class RemoveNextEntryId < ActiveRecord::Migration[6.1]
+ def change
+ remove_column(:journeys, :next_entry_id)
+ end
+end
diff --git a/db/migrate/20210127154732_create_number_answer.rb b/db/migrate/20210127154732_create_number_answer.rb
new file mode 100644
index 000000000..acf25acdb
--- /dev/null
+++ b/db/migrate/20210127154732_create_number_answer.rb
@@ -0,0 +1,13 @@
+class CreateNumberAnswer < ActiveRecord::Migration[6.1]
+ def up
+ create_table :number_answers, id: :uuid do |t|
+ t.references :step, type: :uuid
+ t.integer :response, null: false
+ t.timestamps
+ end
+ end
+
+ def down
+ drop_table :number_answers
+ end
+end
diff --git a/db/migrate/20210202120716_create_currency_answer.rb b/db/migrate/20210202120716_create_currency_answer.rb
new file mode 100644
index 000000000..83b2b6071
--- /dev/null
+++ b/db/migrate/20210202120716_create_currency_answer.rb
@@ -0,0 +1,13 @@
+class CreateCurrencyAnswer < ActiveRecord::Migration[6.1]
+ def up
+ create_table :currency_answers, id: :uuid do |t|
+ t.references :step, type: :uuid
+ t.decimal :response, null: false, precision: 11, scale: 2
+ t.timestamps
+ end
+ end
+
+ def down
+ drop_table :currency_answers
+ end
+end
diff --git a/db/migrate/20210202170113_add_section_order_to_journey.rb b/db/migrate/20210202170113_add_section_order_to_journey.rb
new file mode 100644
index 000000000..02f2e90e7
--- /dev/null
+++ b/db/migrate/20210202170113_add_section_order_to_journey.rb
@@ -0,0 +1,5 @@
+class AddSectionOrderToJourney < ActiveRecord::Migration[6.1]
+ def change
+ add_column :journeys, :section_groups, :jsonb
+ end
+end
diff --git a/db/migrate/20210209111509_add_hidden_state_to_step.rb b/db/migrate/20210209111509_add_hidden_state_to_step.rb
new file mode 100644
index 000000000..b98ee4aa3
--- /dev/null
+++ b/db/migrate/20210209111509_add_hidden_state_to_step.rb
@@ -0,0 +1,5 @@
+class AddHiddenStateToStep < ActiveRecord::Migration[6.1]
+ def change
+ add_column :steps, :hidden, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20210211104039_add_additional_step_rule_to_step.rb b/db/migrate/20210211104039_add_additional_step_rule_to_step.rb
new file mode 100644
index 000000000..6179cddb7
--- /dev/null
+++ b/db/migrate/20210211104039_add_additional_step_rule_to_step.rb
@@ -0,0 +1,5 @@
+class AddAdditionalStepRuleToStep < ActiveRecord::Migration[6.1]
+ def change
+ add_column :steps, :additional_step_rule, :jsonb
+ end
+end
diff --git a/db/migrate/20210217145518_add_further_information_to_checkboxes.rb b/db/migrate/20210217145518_add_further_information_to_checkboxes.rb
new file mode 100644
index 000000000..f10ff1332
--- /dev/null
+++ b/db/migrate/20210217145518_add_further_information_to_checkboxes.rb
@@ -0,0 +1,5 @@
+class AddFurtherInformationToCheckboxes < ActiveRecord::Migration[6.1]
+ def change
+ add_column :checkbox_answers, :further_information, :jsonb
+ end
+end
diff --git a/db/migrate/20210223102716_change_additional_step_rule_name.rb b/db/migrate/20210223102716_change_additional_step_rule_name.rb
new file mode 100644
index 000000000..6aae0970b
--- /dev/null
+++ b/db/migrate/20210223102716_change_additional_step_rule_name.rb
@@ -0,0 +1,5 @@
+class ChangeAdditionalStepRuleName < ActiveRecord::Migration[6.1]
+ def change
+ rename_column :steps, :additional_step_rule, :additional_step_rules
+ end
+end
diff --git a/db/migrate/20210301151653_add_skippable_to_step.rb b/db/migrate/20210301151653_add_skippable_to_step.rb
new file mode 100644
index 000000000..e74a1fa3b
--- /dev/null
+++ b/db/migrate/20210301151653_add_skippable_to_step.rb
@@ -0,0 +1,5 @@
+class AddSkippableToStep < ActiveRecord::Migration[6.1]
+ def change
+ add_column :steps, :skip_call_to_action_text, :string, default: nil
+ end
+end
diff --git a/db/migrate/20210302110746_add_skipped_to_checkbox_answers.rb b/db/migrate/20210302110746_add_skipped_to_checkbox_answers.rb
new file mode 100644
index 000000000..3b5c13256
--- /dev/null
+++ b/db/migrate/20210302110746_add_skipped_to_checkbox_answers.rb
@@ -0,0 +1,5 @@
+class AddSkippedToCheckboxAnswers < ActiveRecord::Migration[6.1]
+ def change
+ add_column :checkbox_answers, :skipped, :boolean, default: false
+ end
+end
diff --git a/db/migrate/20210316114040_change_further_information_to_json_for_radio_answers.rb b/db/migrate/20210316114040_change_further_information_to_json_for_radio_answers.rb
new file mode 100644
index 000000000..b46dd8d32
--- /dev/null
+++ b/db/migrate/20210316114040_change_further_information_to_json_for_radio_answers.rb
@@ -0,0 +1,27 @@
+class ChangeFurtherInformationToJsonForRadioAnswers < ActiveRecord::Migration[6.1]
+ def up
+ add_column :radio_answers, :further_information_jsonb, :jsonb
+ ActiveRecord::Base.transaction do
+ RadioAnswer.all.map do |radio|
+ machine_value = radio.response.parameterize(separator: "_")
+ hash = {machine_value => radio.further_information}
+ radio.update(further_information_jsonb: hash)
+ end
+ end
+ remove_column :radio_answers, :further_information
+ rename_column :radio_answers, :further_information_jsonb, :further_information
+ end
+
+ def down
+ add_column :radio_answers, :further_information_string, :string
+ ActiveRecord::Base.transaction do
+ RadioAnswer.all.map do |radio|
+ machine_value = radio.response.parameterize(separator: "_")
+ matching_further_information = radio.further_information[machine_value]
+ radio.update(further_information_string: matching_further_information)
+ end
+ end
+ remove_column :radio_answers, :further_information
+ rename_column :radio_answers, :further_information_string, :further_information
+ end
+end
diff --git a/db/migrate/20210318105104_create_user_table.rb b/db/migrate/20210318105104_create_user_table.rb
new file mode 100644
index 000000000..3b7b61608
--- /dev/null
+++ b/db/migrate/20210318105104_create_user_table.rb
@@ -0,0 +1,8 @@
+class CreateUserTable < ActiveRecord::Migration[6.1]
+ def change
+ create_table :users, id: :uuid do |t|
+ t.string :dfe_sign_in_uid, null: false
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20210325145645_set_up_user_journey_association.rb b/db/migrate/20210325145645_set_up_user_journey_association.rb
new file mode 100644
index 000000000..bc307a406
--- /dev/null
+++ b/db/migrate/20210325145645_set_up_user_journey_association.rb
@@ -0,0 +1,7 @@
+class SetUpUserJourneyAssociation < ActiveRecord::Migration[6.1]
+ def change
+ change_table :journeys do |t|
+ t.belongs_to :user, type: :uuid
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 17fe498e7..71b77fe97 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2021_01_18_123111) do
+ActiveRecord::Schema.define(version: 2021_03_25_145645) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
@@ -22,15 +22,27 @@
t.string "response", default: [], array: true
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
+ t.jsonb "further_information"
+ t.boolean "skipped", default: false
t.index ["step_id"], name: "index_checkbox_answers_on_step_id"
end
+ create_table "currency_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "step_id"
+ t.decimal "response", precision: 11, scale: 2, null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["step_id"], name: "index_currency_answers_on_step_id"
+ end
+
create_table "journeys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "category", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
- t.string "next_entry_id"
- t.jsonb "liquid_template"
+ t.jsonb "liquid_template", null: false
+ t.jsonb "section_groups"
+ t.uuid "user_id"
+ t.index ["user_id"], name: "index_journeys_on_user_id"
end
create_table "long_text_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -41,12 +53,20 @@
t.index ["step_id"], name: "index_long_text_answers_on_step_id"
end
+ create_table "number_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "step_id"
+ t.integer "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_number_answers_on_step_id"
+ end
+
create_table "radio_answers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "step_id"
t.string "response", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
- t.text "further_information"
+ t.jsonb "further_information"
t.index ["step_id"], name: "index_radio_answers_on_step_id"
end
@@ -79,9 +99,18 @@
t.string "contentful_id", null: false
t.jsonb "raw", null: false
t.jsonb "options"
+ t.boolean "hidden", default: false
+ t.jsonb "additional_step_rules"
+ t.string "skip_call_to_action_text"
t.index ["journey_id"], name: "index_steps_on_journey_id"
end
+ create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "dfe_sign_in_uid", null: false
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ end
+
add_foreign_key "long_text_answers", "steps", on_delete: :cascade
add_foreign_key "radio_answers", "steps", on_delete: :cascade
add_foreign_key "short_text_answers", "steps", on_delete: :cascade
diff --git a/doc/architecture/decisions/0009-create-infrastructure-on-gpaas-cloud-foundry-with-terraform.md b/doc/architecture/decisions/0009-create-infrastructure-on-gpaas-cloud-foundry-with-terraform.md
new file mode 100644
index 000000000..9eaf1ae1e
--- /dev/null
+++ b/doc/architecture/decisions/0009-create-infrastructure-on-gpaas-cloud-foundry-with-terraform.md
@@ -0,0 +1,26 @@
+# 9. Create infrastructure on GPaas Cloud Foundry with Terraform
+
+Date: 2021-03-09
+
+## Status
+
+Accepted
+
+## Context
+
+The early beta was originally hosted on dxw's Heroku to get delivering quickly whilst access to GPaaS could be set up.
+
+Access to GPaaS with approved billing has now been confirmed so we are migrating the service to its longer term home.
+
+## Decision
+
+- Move the service from Heroku to GPaaS for all environments except the ephemeral pull request review environments.
+- Use Terraform to define the infrastructure as code.
+
+
+## Consequences
+
+- the service will be easier to maintain in the long term if it is grouped with the rest of DfE's digital services inside GPaaS
+- the service will need to be Terraformed as other DfE digital services
+- as there are no real users of the service there is less risk to migrating the service now rather than later
+- the pull request environments will no longer have strong parity with the live service on an infrastructure level. This can be changed in future if we have move time to also the approach taken by Teaching Vacancies
diff --git a/doc/architecture/decisions/0010-use-dfe-sign-in-as-auth-provider.md b/doc/architecture/decisions/0010-use-dfe-sign-in-as-auth-provider.md
new file mode 100644
index 000000000..63a93ca94
--- /dev/null
+++ b/doc/architecture/decisions/0010-use-dfe-sign-in-as-auth-provider.md
@@ -0,0 +1,30 @@
+# 10. use-dfe-sign-in-as-auth-provider
+
+Date: 2021-03-22
+
+## Status
+
+Accepted
+
+## Context
+
+The service needs a way to authenticate trusted school buying professionals and to restrict the majority of access to the public.
+
+We believe a simpler password-less authentication mechanism would be all that's required. This service does not need any of the school and user information held within DfE Sign-in (DSI). DfE governance has reviewed our concern and decided this service should use DSI.
+
+There is currently no formal recommendation for a tool of choice in the technical guidance https://github.com/DFE-Digital/technical-guidance.
+
+We want a tool that provides an open and modern security standard.
+
+## Decision
+
+We are going to use DSI as our single sign-on provider using the OIDC standard.
+
+## Consequences
+
+- we think that the additional steps needed for the sign in journey will negatively affect the user experience and the ability for users to succeed first time
+- we think it will be difficult to clearly steer users who don't have a DSI account to create one first before continuing back (this will be a dead end for our service until they talk to their school approver)
+- the DSI has been in service for a couple of years now. We expect that most of our users will already have accounts on sign-in
+- DSI is being used actively by other DfE Digital services such as Claim, Apply and Teaching Vacancies. It is a core part of the DfE's service wide design and infrastructure that should be actively supported
+- in future the service may need more information that DSI can make available such as school information, using DSI already would make this easier to obtain
+- there may be advantages to the user experience for school users who use other DfE digital services. Using DSI would present a single view that may increase trust and allow them to jump between services without repeat sign ins
diff --git a/doc/dfe-sign-in.md b/doc/dfe-sign-in.md
new file mode 100644
index 000000000..020e2dfae
--- /dev/null
+++ b/doc/dfe-sign-in.md
@@ -0,0 +1,25 @@
+# DfE Sign-in
+
+We are using DfE Sign-in as the Single Sign-on provider for this service.
+
+## Onboarding
+
+For development, with `DFE_SIGN_IN_ENABLED` set to false you do not need the following in order to start the app. You can provide _any_ value in the `UID` field to sign in.
+
+If you'd like to sign in on a live environment:
+
+1. DfE need to invite your DfE email address to the [necessary environments](#environments) that you can sign in as a regular user. We asked in [the DfE #digital-tools-support Slack channel](https://ukgovernmentdfe.slack.com/archives/CMS9V0JQL)
+2. Another DfE user that is an approver for at least one school needs to invite you. This could an existing dev on the team. [We have been using this school for staging access](https://test-services.signin.education.gov.uk/approvals/50F4A834-9314-4A66-969E-C86D03821C26/users)
+3. You should now be able to sign into the service's staging environment from this application's sign in journey
+
+## Environments
+
+This service has numerous environments and each needs to be paired and configured with a unique DfE environment.
+
+| | Enabled | DfE Sign-in Env | DfE manage service |
+| :------------- | :----------: | :-------------------------------------------------------------------: | :----------------: |
+| Development | false | | |
+| Staging | true | [test-integration](https://test-interactions.signin.education.gov.uk) | [manage](https://test-manage.signin.education.gov.uk/services/FD39DCFC-9B60-46C4-ACDC-699A2468B46F/service-configuration)
+| Research | false | | |
+| Preview | false | | |
+| Production | false | | |
diff --git a/doc/getting-started.md b/doc/getting-started.md
new file mode 100644
index 000000000..239cde62c
--- /dev/null
+++ b/doc/getting-started.md
@@ -0,0 +1,58 @@
+# Getting started
+
+1. [Install Docker for Mac](https://docs.docker.com/docker-for-mac/install/)
+1. copy `/.env.example` into `/.env.development.local`.
+
+ Our intention is that the example should include enough to get the application started quickly. If this is not the case, please ask another developer for a copy of their `/.env.development.local` file.
+
+1. `script/server`
+1. Visit http://localhost:3000
+
+## Running the tests
+
+### The whole test suite
+
+* Using Docker has high parity, you don't have to install any dependencies but it takes longer to run (~20 seconds):
+
+ ```bash
+ docker-compose -f docker-compose.test.yml run --rm test bundle exec rake
+ ```
+* Without Docker is faster (~5 seconds) but has lower parity and you will need to install local dependencies on your machine first:
+
+ ```bash
+ brew install postgres
+ brew services start postgres
+ brew install redis
+ brew services start redis
+ createuser postgres --super
+ rbenv install 2.6.6 && rbenv local 2.6.6
+ gem install bundle && bundle
+ RAILS_ENV=test rake db:setup
+ ```
+ ```ruby
+ script/test
+ ```
+
+### RSpec only
+
+```
+bundle exec rspec spec/*
+```
+
+## Starting a Rails console
+
+```
+script/console
+```
+
+## Running Brakeman
+
+Run [Brakeman](https://brakemanscanner.org/) to highlight any security vulnerabilities:
+```bash
+brakeman
+```
+
+To pipe the results to a file:
+```bash
+brakeman -o report.text
+```
diff --git a/doc/managing-environment-variables.md b/doc/managing-environment-variables.md
new file mode 100644
index 000000000..2da653fb3
--- /dev/null
+++ b/doc/managing-environment-variables.md
@@ -0,0 +1,10 @@
+# Managing environment variables
+
+We use [Dotenv](https://github.com/bkeepers/dotenv) to manage our environment variables locally.
+
+The repository will include safe defaults for development in `/.env.example` and for test in `/.env.test`. We use 'example' instead of 'development' (from the Dotenv docs) to be consistent with current dxw conventions and to make it more explicit that these values are not to be committed.
+
+To manage sensitive environment variables:
+
+1. Add the new key and safe default value to the `/.env.example` file eg. `ROLLBAR_TOKEN=ROLLBAR_TOKEN`
+2. Add the new key and real value to your local `/.env.development.local` file, which should never be checked into Git. This file will look something like `ROLLBAR_TOKEN=123456789`
diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml
index c2d813f79..71ae4de43 100644
--- a/docker-compose.ci.yml
+++ b/docker-compose.ci.yml
@@ -16,7 +16,7 @@ services:
- .env.test
environment:
DATABASE_URL: postgres://postgres:password@test-db:5432/buy-for-your-school-test
- REDIS_CACHE_URL: redis://test-redis:6379/1
+ REDIS_URL: redis://redis:6379
DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL: "true"
networks:
- test
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index 5ac8bd1ab..a7a2c37f1 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -16,7 +16,7 @@ services:
- .env.test
environment:
DATABASE_URL: postgres://postgres:password@test-db:5432/buy-for-your-school-test
- REDIS_CACHE_URL: redis://test-redis:6379/1
+ REDIS_URL: redis://redis:6379
DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL: "true"
volumes:
- .:/srv/app
diff --git a/docker-compose.yml b/docker-compose.yml
index 563dd002d..4e6d8ed59 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,8 +17,7 @@ services:
- .env.development.local
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
+ REDIS_URL: redis://redis:6379
volumes:
- .:/srv/app
tty: true
diff --git a/lib/dfe_sign_in.rb b/lib/dfe_sign_in.rb
new file mode 100644
index 000000000..e159425e2
--- /dev/null
+++ b/lib/dfe_sign_in.rb
@@ -0,0 +1,5 @@
+module DfESignIn
+ def self.bypass?
+ Rails.env.development? && ENV["DFE_SIGN_IN_ENABLED"] == "false"
+ end
+end
diff --git a/lib/specification_templates/catering.development.liquid b/lib/specification_templates/catering.development.liquid
deleted file mode 100644
index 5ba71c4e5..000000000
--- a/lib/specification_templates/catering.development.liquid
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
Menus and ordering
-
Food standards
-
-
-
It will be the suppliers responsibility to ensure that all food served within the school day complies with both current and future government legislation and guidelines on the provision of healthy school meals.
-
The supplier should encourage the use of seasonal produce and promotion of healthy eating to pupils wherever practical and desirable.
-
It will be the suppliers responsibility to comply fully with DfE food and nutrient based standards, and to promote and comply with this policy throughout the contract term through effective menu planning.
-
-
- {% if answer_NxJWpbiFeEAmvcw17EysX %}
-
-
The school also requires the service to comply with the following non-mandatory food standards or schemes:
-
{{answer_NxJWpbiFeEAmvcw17EysX}}
-
- {% endif %}
-
-
The supplier must work with the school to provide safe and enjoyable meals for any pupils with allergies or intolerances, ensuring that the ingredients, preparation and handling of food for those children are completely allergen-free.
-
-
- {% if answer_5xxbqrasSXH9x9Lt3YhRkX contains "yes" %}
-
-
The supplier is required to track allergen information through their supply chain and must be able to demonstrate their allergen tracking plan.
It will be the suppliers responsibility to ensure that all food served within the school day complies with both current and future government legislation and guidelines on the provision of healthy school meals.
-
The supplier should encourage the use of seasonal produce and promotion of healthy eating to pupils wherever practical and desirable.
-
It will be the suppliers responsibility to comply fully with DfE food and nutrient based standards, and to promote and comply with this policy throughout the contract term through effective menu planning.
-
-
- {% if answer_2fVajdGxgwD58vt4VvAI9Y %}
-
-
The school also requires the service to comply with the following non-mandatory food standards or schemes:
-
{{answer_2fVajdGxgwD58vt4VvAI9Y}}
-
- {% endif %}
-
-
-
The supplier must work with the school to provide safe and enjoyable meals for any pupils with allergies or intolerances, ensuring that the ingredients, preparation and handling of food for those children are completely allergen-free.
-
-
-
-
All food and drink must comply with food labelling law, which says you must provide information to customers on any of the 14 allergens used as ingredients in foods you make and sell. It is important that all staff receive training and information on the 14 allergens contained in food.
-
-
- {% if answer_ypxhCAkhp2qmFiapHpVpK contains "Yes" %}
-
-
All ingredients, handling and preparation of food and drink provided by the supplier must be free from:
It will be the suppliers responsibility to ensure that all food served within the school day complies with both current and future government legislation and guidelines on the provision of healthy school meals.
-
The supplier should encourage the use of seasonal produce and promotion of healthy eating to pupils wherever practical and desirable.
-
It will be the suppliers responsibility to comply fully with DfE food and nutrient based standards, and to promote and comply with this policy throughout the contract term through effective menu planning.
-
- {% if answer_NxJWpbiFeEAmvcw17EysX %}
-
-
The school also requires the service to comply with the following non-mandatory food standards or schemes:
-
{{answer_NxJWpbiFeEAmvcw17EysX}}
-
- {% endif %}
-
-
-
diff --git a/lib/vcap_parser.rb b/lib/vcap_parser.rb
new file mode 100644
index 000000000..be047d9cc
--- /dev/null
+++ b/lib/vcap_parser.rb
@@ -0,0 +1,28 @@
+# Class to parse the VCAP_SERVICES environment variable.
+# Cloud Foundry provides an environment variable called VCAP_SERVICES which
+# contains JSON.
+# The JSON provides details of services bound to the application.
+# We parse this to generate environment variables to be used by Rails.
+class VcapParser
+ def self.load_service_environment_variables!
+ return if ENV["VCAP_SERVICES"].blank?
+
+ vcap_json = JSON.parse(ENV["VCAP_SERVICES"])
+ # Turn user provided service credentials into environment variables
+ vcap_json.fetch("user-provided", []).each do |service|
+ service["credentials"].each_pair do |key, value|
+ ENV[key] = value
+ end
+ end
+
+ load_redis_config(
+ vcap_json.fetch("redis", []).first
+ )
+ end
+
+ def self.load_redis_config(redis_config)
+ return unless redis_config
+ # Generate a REDIS_URL from the redis service uri
+ ENV["REDIS_URL"] = redis_config.fetch("credentials").fetch("uri")
+ end
+end
diff --git a/package-lock.json b/package-lock.json
index 95dc44d75..1447a4746 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,9 +4,9 @@
"lockfileVersion": 1,
"dependencies": {
"govuk-frontend": {
- "version": "3.10.2",
- "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-3.10.2.tgz",
- "integrity": "sha512-MpMymgLsKoMw40MggZ0XLCAj1FY5N2s8Pf3aQR+k0cZOsegjLsnejxNfEB9qEl9jcma2fiiVcvsEZ+Ipo+Oo2g=="
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/govuk-frontend/-/govuk-frontend-3.11.0.tgz",
+ "integrity": "sha512-1hW/3etYBtKPM+PNdWVOijvWVI3mpYL8eb7WLTtlh/Qxf2mCp6LkCsZk9I034n4EJBYQ5jlUWsUlTOOIypftpg=="
}
}
}
diff --git a/package.json b/package.json
index ade382d33..aa1b68d7d 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,6 @@
"name": "rails-template",
"private": true,
"dependencies": {
- "govuk-frontend": "^3.10.2"
+ "govuk-frontend": "^3.11.0"
}
}
diff --git a/public/robots.txt b/public/robots.txt
index 37b576a4a..f7b7e314d 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -1 +1,3 @@
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+
+Disallow: /journeys/
diff --git a/script/deploy-terraform b/script/deploy-terraform
new file mode 100755
index 000000000..31bf595ea
--- /dev/null
+++ b/script/deploy-terraform
@@ -0,0 +1,53 @@
+#!/bin/bash
+# script to deploy terraform
+# exit on error or if a variable is unbound
+set -eu
+
+TF_VAR_environment="${TF_VAR_environment:-}"
+
+if [ -z "$TF_VAR_environment" ]
+then
+ echo "TF_VAR_environment not set. Exiting ..."
+ exit 1
+fi
+
+# Set env_TF_VAR_ environment variables from GITHUB_SECRETS_JSON
+eval $(echo "$GITHUB_SECRETS_JSON" | jq -r --arg e "$(echo "$TF_VAR_environment" | awk '{ print toupper($0) }' )" 'with_entries(select(.key | startswith($e + "_TF_VAR_") ) ) | keys[] as $k | "export \($k[($e + "_" | length):])=\(.[$k])"')
+
+# Disable the shellcheck check for unassigned variables. We export this var
+# in Github Actions, but Shellcheck complains because there are lowercase
+# characters in it
+
+# shellcheck disable=2154
+echo "deploying $TF_VAR_environment"
+
+cd terraform/app
+
+# Create app_env yaml file from GITHUB_SECRETS_JSON
+echo "---" > "${TF_VAR_environment}_app_env.yml"
+echo "$GITHUB_SECRETS_JSON" | jq -r --arg e "$(echo "$TF_VAR_environment" | awk '{ print toupper($0) }' )" 'with_entries(select(.key | startswith("APP_ENV_" + $e) ) ) | keys[] as $k | "\($k[("APP_ENV_" + $e + "_" | length):]): \(.[$k])"' >> "${TF_VAR_environment}_app_env.yml"
+
+# deploy terraform using tfenv
+if [ ! -e ~/.tfenv ]
+then
+git clone https://github.com/tfutils/tfenv.git ~/.tfenv
+fi
+export PATH="$HOME/.tfenv/bin:$PATH"
+tfenv install
+
+# CF_PASSWORD, CF_USER, AWS_SECRET_ACCESS_KEY and AWS_ACCESS_KEY_ID
+# must be set for the following commands to run
+
+# initialise terraform
+terraform init
+
+# select the correct workspace
+terraform workspace select "$TF_VAR_environment"
+
+# plan the terraform
+terraform plan
+
+# apply the terraform
+terraform apply -auto-approve
+
+echo "$TF_VAR_environment has been deployed"
diff --git a/script/docker-push-ghcr b/script/docker-push-ghcr
new file mode 100755
index 000000000..184cd1944
--- /dev/null
+++ b/script/docker-push-ghcr
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# exit on failures
+set -e
+set -o pipefail
+
+echo "$GHCR_PASSWORD" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
+docker build --build-arg RAILS_ENV=production -t "$GHCR_REPO:$DOCKER_TAG" .
+docker push "$GHCR_REPO:$DOCKER_TAG"
diff --git a/script/test b/script/test
index 183f44149..92dd1c595 100755
--- a/script/test
+++ b/script/test
@@ -6,3 +6,4 @@
set -e
bundle exec rake
+bundle exec brakeman -o /dev/stdout
diff --git a/spec/factories/answer.rb b/spec/factories/answer.rb
index 48e361a25..2b174a61e 100644
--- a/spec/factories/answer.rb
+++ b/spec/factories/answer.rb
@@ -27,6 +27,19 @@
factory :checkbox_answers do
association :step, factory: :step, options: [{"value" => "Breakfast"}, {"value" => "Lunch"}], contentful_type: "checkboxes", contentful_model: "question"
- response { ["breakfast", "lunch", ""] }
+ response { ["Breakfast", "Lunch", ""] }
+ skipped { false }
+ end
+
+ factory :number_answer do
+ association :step, factory: :step, contentful_type: "number", contentful_model: "question"
+
+ response { 150 }
+ end
+
+ factory :currency_answer do
+ association :step, factory: :step, contentful_type: "currency", contentful_model: "question"
+
+ response { 1000.01 }
end
end
diff --git a/spec/factories/journey.rb b/spec/factories/journey.rb
index c24800da3..2ca6309be 100644
--- a/spec/factories/journey.rb
+++ b/spec/factories/journey.rb
@@ -1,8 +1,10 @@
FactoryBot.define do
factory :journey do
category { "catering" }
- next_entry_id { "47EI2X2T5EDTpJX9WjRR9p" }
liquid_template { "Your answer was {{ answer_47EI2X2T5EDTpJX9WjRR9p }}" }
+ section_groups { [] }
+
+ association :user, factory: :user
trait :catering do
category { "catering" }
diff --git a/spec/factories/step.rb b/spec/factories/step.rb
index 016694639..c2d75527f 100644
--- a/spec/factories/step.rb
+++ b/spec/factories/step.rb
@@ -2,8 +2,12 @@
factory :step do
title { "What is your favourite colour?" }
help_text { "Choose the primary colour closest to your choice" }
- raw { {"sys": {"id" => "123"}} }
- contentful_id { "123" }
+ contentful_id { SecureRandom.hex }
+ raw { |attrs| {"sys": {"id" => attrs["contentful_id"]}} }
+ hidden { false }
+ additional_step_rules { nil }
+ primary_call_to_action_text { nil }
+ skip_call_to_action_text { nil }
association :journey, factory: :journey
@@ -11,35 +15,42 @@
options { [{"value" => "Red"}, {"value" => "Green"}, {"value" => "Blue"}] }
contentful_model { "question" }
contentful_type { "radios" }
- association :radio_answer
end
trait :short_text do
options { nil }
contentful_model { "question" }
contentful_type { "short_text" }
- association :short_text_answer
end
trait :long_text do
options { nil }
contentful_model { "question" }
contentful_type { "long_text" }
- 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 { [{"value" => "Brown"}, {"value" => "Gold"}] }
contentful_model { "question" }
contentful_type { "checkboxes" }
- association :checkbox_answers
+ end
+
+ trait :number do
+ options { nil }
+ contentful_model { "question" }
+ contentful_type { "number" }
+ end
+
+ trait :currency do
+ options { nil }
+ contentful_model { "question" }
+ contentful_type { "currency" }
end
trait :static_content do
diff --git a/spec/factories/user.rb b/spec/factories/user.rb
new file mode 100644
index 000000000..4bad747aa
--- /dev/null
+++ b/spec/factories/user.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory :user do
+ dfe_sign_in_uid { SecureRandom.uuid }
+ end
+end
diff --git a/spec/features/school_buying_professionals/complete_a_journey_spec.rb b/spec/features/school_buying_professionals/complete_a_journey_spec.rb
new file mode 100644
index 000000000..b54a48218
--- /dev/null
+++ b/spec/features/school_buying_professionals/complete_a_journey_spec.rb
@@ -0,0 +1,472 @@
+require "rails_helper"
+
+feature "Anyone can start a journey" do
+ before { user_is_signed_in }
+ around do |example|
+ ClimateControl.modify(
+ CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
+ ) do
+ example.run
+ end
+ end
+
+ scenario "Start page includes a call to action" do
+ start_journey_from_category(category: "radio-question.json")
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+ expect(page).to have_content("Which service do you need?")
+ expect(page).to have_content("Not started")
+ end
+
+ scenario "an answer must be provided" do
+ start_journey_from_category_and_go_to_question(category: "radio-question.json")
+
+ # Omit a choice
+
+ click_on(I18n.t("generic.button.next"))
+
+ expect(page).to have_content("can't be blank")
+ end
+
+ context "when the Contentful model is of type question" do
+ context "when Contentful entry is of type short_text" do
+ scenario "user can answer using free text" do
+ start_journey_from_category_and_go_to_question(category: "short-text-question.json")
+
+ fill_in "answer[response]", with: "email@example.com"
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(find_field("answer-response-field").value).to eql("email@example.com")
+ end
+ end
+
+ context "when Contentful entry is of type numbers" do
+ scenario "user can answer using a number input" do
+ start_journey_from_category_and_go_to_question(category: "number-question.json")
+
+ fill_in "answer[response]", with: "190"
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(find_field("answer-response-field").value).to eql("190")
+ end
+
+ scenario "users receive an error when not entering a number" do
+ start_journey_from_category_and_go_to_question(category: "number-question.json")
+
+ fill_in "answer[response]", with: "foo"
+ click_on(I18n.t("generic.button.next"))
+
+ expect(page).to have_content("is not a number")
+ end
+
+ scenario "users receive an error when entering a decimal number" do
+ start_journey_from_category_and_go_to_question(category: "number-question.json")
+
+ fill_in "answer[response]", with: "435.65"
+ click_on(I18n.t("generic.button.next"))
+
+ expect(page).to have_content("must be an integer")
+ end
+ end
+
+ context "when Contentful entry is of type currency" do
+ scenario "user can answer using a currency number input" do
+ start_journey_from_category_and_go_to_question(category: "currency-question.json")
+
+ fill_in "answer[response]", with: "1,000.01"
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(find_field("answer-response-field").value).to eql("1000.01")
+ end
+
+ scenario "throws error when non numerical values are entered" do
+ start_journey_from_category_and_go_to_question(category: "currency-question.json")
+
+ fill_in "answer[response]", with: "one hundred pounds"
+ click_on(I18n.t("generic.button.next"))
+
+ expect(page).to have_content("does not accept £ signs or other non numerical characters")
+ end
+ end
+
+ context "when Contentful entry is of type long_text" do
+ scenario "user can answer using free text with multiple lines" do
+ start_journey_from_category_and_go_to_question(category: "long-text-question.json")
+
+ fill_in "answer[response]", with: "We would like a supplier to provide catering from September 2020.\nThey must be able to supply us for 3 years minumum."
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(find_field("answer-response-field").value).to eql("We would like a supplier to provide catering from September 2020.\r\nThey must be able to supply us for 3 years minumum.")
+ end
+ end
+
+ context "when Contentful entry is of type single_date" do
+ scenario "user can answer using a date input" do
+ start_journey_from_category_and_go_to_question(category: "single-date-question.json")
+
+ 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"))
+
+ click_first_link_in_task_list
+
+ expect(find_field("answer_response_3i").value).to eql("12")
+ expect(find_field("answer_response_2i").value).to eql("8")
+ expect(find_field("answer_response_1i").value).to eql("2020")
+ end
+
+ scenario "date validations" do
+ start_journey_from_category_and_go_to_question(category: "single-date-question.json")
+
+ fill_in "answer[response(3i)]", with: "2"
+ fill_in "answer[response(2i)]", with: "0"
+ fill_in "answer[response(1i)]", with: "0"
+
+ click_on(I18n.t("generic.button.next"))
+ expect(page).to have_content(I18n.t("activerecord.errors.models.single_date_answer.attributes.response"))
+ end
+ end
+
+ context "when Contentful entry is of type checkboxes" do
+ scenario "user can select multiple answers" do
+ start_journey_from_category_and_go_to_question(category: "checkboxes-question.json")
+
+ check "Breakfast"
+ check "Lunch"
+
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(page).to have_checked_field("answer-response-breakfast-field")
+ expect(page).to have_checked_field("answer-response-lunch-field")
+ end
+
+ scenario "options follow the capitalisation given" do
+ start_journey_from_category_and_go_to_question(category: "checkboxes-question.json")
+ expect(page).to have_content("Morning break")
+ end
+
+ context "when extra configuration is passed to collect further info" do
+ scenario "asks the user for further information" do
+ start_journey_from_category_and_go_to_question(category: "extended-checkboxes-question.json")
+
+ check("Yes")
+ fill_in "answer[yes_further_information]", with: "The first piece of further information"
+
+ check("No")
+ expect(page).not_to have_content("No_further_information") # It should not create a label when text for one isn't provided
+ within("span.govuk-visually-hidden") do
+ expect(page).to have_content("Optional further information") # Default the hidden label to something understandable for screen readers
+ end
+ fill_in "answer[no_further_information]", with: "A second piece of further information"
+
+ # We are testing a value that includes a comma
+ check("Other, please specify")
+ fill_in "answer[other_please_specify_further_information]", with: "Other information"
+
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(page).to have_checked_field("Yes")
+ expect(find_field("answer-yes-further-information-field").value)
+ .to eql("The first piece of further information")
+
+ expect(page).to have_checked_field("No")
+ expect(find_field("answer-no-further-information-field").value)
+ .to eql("A second piece of further information")
+
+ expect(page).to have_checked_field("Other, please specify")
+ expect(find_field("answer-other-please-specify-further-information-field").value)
+ .to eql("Other information")
+ end
+ end
+
+ context "when extended question is of type single" do
+ scenario "a single text field is displayed" do
+ start_journey_from_category_and_go_to_question(category: "extended-checkboxes-question.json")
+
+ check("Yes")
+
+ expect(page).to have_selector("input#answer-yes-further-information-field")
+ end
+ end
+
+ context "when extended question is of type long" do
+ scenario "a long text area is displayed" do
+ start_journey_from_category_and_go_to_question(category: "extended-long-answer-checkboxes-question.json")
+
+ check("Yes")
+
+ expect(page).to have_selector("textarea#answer-yes-further-information-field")
+ end
+ end
+
+ context "when there is no extended question" do
+ scenario "no extra text field is displayed" do
+ start_journey_from_category_and_go_to_question(category: "checkboxes-question.json")
+
+ expect(page).to_not have_selector("textarea#answer-yes-further-information-field")
+ expect(page).to_not have_selector("input#answer-yes-further-information-field")
+ end
+ end
+ end
+
+ context "when Contentful entry is of type radios" do
+ context "when extra configuration is passed to collect further info" do
+ scenario "asks the user for further information" do
+ start_journey_from_category_and_go_to_question(category: "extended-radio-question.json")
+
+ choose("Catering")
+ expect(page).not_to have_content("No_further_information") # It should not create a label when one isn't specified
+ within("span.govuk-visually-hidden") do
+ expect(page).to have_content("Optional further information") # Default the hidden label to something understandable for screen readers
+ end
+
+ fill_in "answer[catering_further_information]", with: "The school needs the kitchen cleaned once a day"
+
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ expect(page).to have_checked_field("Catering")
+ expect(find_field("answer-catering-further-information-field").value)
+ .to eql("The school needs the kitchen cleaned once a day")
+ end
+ end
+
+ context "when extended question is of type single" do
+ scenario "a single text field is displayed" do
+ start_journey_from_category_and_go_to_question(category: "extended-radio-question.json")
+
+ choose("Catering")
+
+ expect(page).to have_selector("input#answer-catering-further-information-field")
+ end
+ end
+
+ context "when extended question is of type long" do
+ scenario "a long text area is displayed" do
+ start_journey_from_category_and_go_to_question(category: "extended-long-answer-radio-question.json")
+
+ choose("Catering")
+
+ expect(page).to have_selector("textarea#answer-catering-further-information-field")
+ end
+ end
+
+ context "when there is no extended question" do
+ scenario "no extra text field is displayed" do
+ start_journey_from_category_and_go_to_question(category: "radio-question.json")
+
+ expect(page).to_not have_selector("textarea#answer-catering-further-information-field")
+ expect(page).to_not have_selector("input#answer-catering-further-information-field")
+ end
+ end
+
+ context "when an 'or separator' has been configured" do
+ scenario "shows an or separator" do
+ start_journey_from_category_and_go_to_question(category: "radio-question-with-separator.json")
+
+ expect(page).to have_selector("div.govuk-radios__divider")
+ within("div.govuk-radios__divider") do
+ expect(page).to have_content("or")
+ end
+
+ # Check that the "Or" separator appears in the correct position
+ expect(page.body.index("Catering") > page.body.index("or")).to eq(true)
+ expect(page.body.index("or") < page.body.index("Cleaning")).to eq(true)
+ end
+ end
+ end
+
+ context "when Contentful entry includes a 'show additional question' rule" do
+ scenario "additional questions are shown" do
+ start_journey_from_category_and_go_to_question(category: "show-one-additional-question.json")
+
+ choose("School expert")
+ click_on(I18n.t("generic.button.next"))
+
+ # This question should be made visible after the previous step
+ expect(page).not_to have_content("You should NOT be able to see this question?")
+ click_on("What colour is the sky?")
+ choose("Red")
+ click_on(I18n.t("generic.button.next"))
+
+ # This question should only be made visible after the previous step
+ click_on("You should NOT be able to see this question?")
+ choose("School expert")
+ click_on(I18n.t("generic.button.next"))
+ end
+ end
+ end
+
+ context "when the question is skippable" do
+ scenario "allows the user to not select an answer" do
+ start_journey_from_category_and_go_to_question(category: "skippable-checkboxes-question.json")
+
+ click_on(I18n.t("generic.button.next"))
+ expect(page).to have_content("can't be blank")
+
+ check("Lunch")
+ check("Dinner")
+ click_on(I18n.t("generic.button.next"))
+
+ click_first_link_in_task_list
+
+ click_on("None of the above")
+
+ within(".app-task-list") do
+ expect(page).to have_content("Complete")
+ end
+
+ click_first_link_in_task_list
+
+ expect(page).not_to have_checked_field("Lunch")
+ expect(CheckboxAnswers.last.skipped).to be true
+ end
+
+ context "when the question has already been skipped" do
+ scenario "selecting an answer marks the question as not being skipped" do
+ start_journey_from_category_and_go_to_question(category: "skippable-checkboxes-question.json")
+
+ click_on("None of the above")
+
+ within(".app-task-list") do
+ expect(page).to have_content("Complete")
+ end
+
+ click_first_link_in_task_list
+
+ check("Lunch")
+ check("Dinner")
+ click_on(I18n.t("generic.button.update"))
+
+ expect(CheckboxAnswers.last.skipped).to be false
+ end
+ end
+ end
+
+ context "when the Contentful model is of type staticContent" do
+ context "when Contentful entry is of type paragraphs" do
+ scenario "the content is not displayed in the task list" do
+ start_journey_from_category(category: "static-content.json")
+
+ # We should really remove static content entirely, since it doesn't
+ # appear in the task list pattern.
+
+ # TODO: Talk to design and see if static content is actually included
+ # anywhere in the journey, or if we can ditch support.
+
+ expect(page).not_to have_content("When you should start")
+ end
+ end
+ end
+
+ context "when the help text contains Markdown" do
+ scenario "paragraph breaks are parsed as expected" do
+ start_journey_from_category_and_go_to_question(category: "markdown-help-text.json")
+
+ expect(page.html).to include("
Paragraph Test: Paragraph 1
")
+ expect(page.html).to include("
Paragraph Test: Paragraph 2
")
+ end
+
+ scenario "bold text is parsed as expected" do
+ start_journey_from_category_and_go_to_question(category: "markdown-help-text.json")
+
+ expect(page.html).to include("Bold text test")
+ end
+
+ scenario "lists are parsed as expected" do
+ start_journey_from_category_and_go_to_question(category: "markdown-help-text.json")
+
+ expect(page.html).to include("
List item one
")
+ expect(page.html).to include("
List item two
")
+ expect(page.html).to include("
List item three
")
+ end
+ end
+
+ context "when the help text is nil" do
+ scenario "step page still renders when question is a radio" do
+ start_journey_from_category_and_go_to_question(category: "nil-help-text-radios.json")
+
+ expect(page.html).to include("Which service do you need?")
+ end
+
+ scenario "step page still renders when question is a short text" do
+ start_journey_from_category_and_go_to_question(category: "nil-help-text-short-text.json")
+
+ expect(page.html).to include("What email address did you use?")
+ end
+ end
+
+ context "when Contentful entry model wasn't an expected type" do
+ scenario "returns an error message" do
+ stub_contentful_category(fixture_filename: "unexpected-contentful-type.json")
+
+ visit new_journey_path
+
+ expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_title"))
+ expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_body"))
+ end
+ end
+
+ context "when the Contentful Entry wasn't an expected question type" do
+ scenario "returns an error message" do
+ stub_contentful_category(fixture_filename: "unexpected-contentful-question-type.json")
+
+ visit new_journey_path
+
+ expect(page).to have_content(I18n.t("errors.unexpected_contentful_step_type.page_title"))
+ expect(page).to have_content(I18n.t("errors.unexpected_contentful_step_type.page_body"))
+ end
+ end
+
+ context "when the starting entry id doesn't exist" do
+ scenario "a Contentful entry_id does not exist" do
+ allow(stub_contentful_connector).to receive(:get_entry_by_id)
+ .with("contentful-category-entry")
+ .and_return(nil)
+
+ visit new_journey_path
+
+ 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
+
+ context "when the Liquid template was invalid" do
+ it "raises an error" do
+ start_journey_from_category(category: "category-with-invalid-liquid-template.json")
+
+ expect(page).to have_content(I18n.t("errors.specification_template_invalid.page_title"))
+ expect(page).to have_content(I18n.t("errors.specification_template_invalid.page_body"))
+ end
+ end
+
+ context "when a user answers a question" do
+ scenario "the user is returned to the same place in the task list " do
+ start_journey_from_category_and_go_to_question(category: "long-text-question.json")
+ journey = Journey.last
+
+ fill_in "answer[response]", with: "This is my long answer"
+
+ click_on(I18n.t("generic.button.next"))
+
+ answer = LongTextAnswer.last
+
+ expect(page).to have_current_path(journey_url(journey, anchor: answer.step.id))
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/download_their_catering_specification_spec.rb b/spec/features/school_buying_professionals/download_their_catering_specification_spec.rb
new file mode 100644
index 000000000..46ab519db
--- /dev/null
+++ b/spec/features/school_buying_professionals/download_their_catering_specification_spec.rb
@@ -0,0 +1,54 @@
+feature "Users can see their catering specification" do
+ before { user_is_signed_in }
+
+ context "when the journey has been completed" do
+ scenario "HTML" do
+ start_journey_from_category_and_go_to_question(category: "category-with-liquid-template.json")
+
+ common_specification_html = "
Liquid
"
+ expect(Htmltoword::Document)
+ .to receive(:create)
+ .with(common_specification_html, nil, false)
+ .and_call_original
+
+ choose("Catering")
+
+ click_on(I18n.t("generic.button.next"))
+
+ click_on(I18n.t("journey.specification.button"))
+
+ expect(page).to have_content(I18n.t("journey.specification.header"))
+
+ click_on("Download (.docx)")
+
+ expect(page.response_headers["Content-Type"]).to eql("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
+ header = page.response_headers["Content-Disposition"]
+ expect(header).to match(/^attachment/)
+ expect(header).to match(/filename="specification.docx"/)
+ end
+ end
+
+ context "when the journey has not yet been completed" do
+ scenario "includes an incomple warning" do
+ start_journey_from_category(
+ category: "category-with-liquid-template.json"
+ )
+
+ # Omit answering a question to simulate an incomplete spec
+
+ click_on(I18n.t("journey.specification.button"))
+
+ warning_html = I18n.t("journey.specification.download.warning.incomplete")
+ common_specification_html = "
Liquid
"
+ expect(page).not_to have_content(Nokogiri::HTML(warning_html).text)
+ expect(Htmltoword::Document)
+ .to receive(:create)
+ .with(common_specification_html.prepend(warning_html), nil, false)
+ .and_call_original
+
+ click_on("Download (.docx)")
+
+ expect(page.response_headers["Content-Disposition"]).to match(/filename="specification-incomplete.docx"/)
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/edit_their_answers_spec.rb b/spec/features/school_buying_professionals/edit_their_answers_spec.rb
new file mode 100644
index 000000000..8ca1b549d
--- /dev/null
+++ b/spec/features/school_buying_professionals/edit_their_answers_spec.rb
@@ -0,0 +1,147 @@
+require "rails_helper"
+
+feature "Users can edit their answers" do
+ let(:user) { create(:user) }
+ before { user_is_signed_in(user: user) }
+
+ before do
+ journey = answer.step.journey
+ journey.update(
+ user: user,
+ section_groups: [
+ {
+ "order" => 0,
+ "title" => "Section A",
+ "steps" => [
+ {
+ "contentful_id" => answer.step.contentful_id,
+ "order" => 0
+ }
+ ]
+ }
+ ]
+ )
+ end
+ let(:answer) { create(:short_text_answer, response: "answer") }
+
+ context "when the question is short_text" do
+ let(:answer) { create(:short_text_answer, response: "answer") }
+
+ scenario "The edited answer is saved" do
+ visit journey_path(answer.step.journey)
+
+ click_on(answer.step.title)
+
+ fill_in "answer[response]", with: "email@example.com"
+
+ click_on(I18n.t("generic.button.update"))
+
+ click_on(answer.step.title)
+
+ expect(find_field("answer-response-field").value).to eql("email@example.com")
+ end
+ end
+
+ context "when the question is single_date" do
+ let(:answer) { create(:single_date_answer, response: 1.year.ago) }
+
+ scenario "The edited answer is saved" do
+ visit journey_path(answer.step.journey)
+
+ click_on(answer.step.title)
+
+ 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.update"))
+
+ click_on(answer.step.title)
+
+ expect(find_field("answer_response_3i").value).to eql("12")
+ expect(find_field("answer_response_2i").value).to eql("8")
+ expect(find_field("answer_response_1i").value).to eql("2020")
+ end
+ end
+
+ context "when the question is checkbox_answers" do
+ let(:answer) { create(:checkbox_answers, response: ["Breakfast", "Lunch", ""]) }
+
+ scenario "The edited answer is saved" do
+ visit journey_path(answer.step.journey)
+
+ click_on(answer.step.title)
+
+ uncheck "Breakfast"
+
+ click_on(I18n.t("generic.button.update"))
+
+ click_on(answer.step.title)
+
+ expect(page).not_to have_checked_field("answer-response-breakfast-field")
+ expect(page).to have_checked_field("answer-response-lunch-field")
+ end
+ end
+
+ context "An error is thrown" do
+ scenario "When an answer is invalid" do
+ visit journey_path(answer.step.journey)
+
+ click_on(answer.step.title)
+
+ fill_in "answer[response]", with: ""
+
+ click_on(I18n.t("generic.button.update"))
+
+ expect(page).to have_content("can't be blank")
+ end
+ end
+
+ context "when Contentful entry includes a 'show additional question' rule" do
+ scenario "an additional question is shown" do
+ start_journey_from_category_and_go_to_question(category: "show-one-additional-question.json")
+
+ choose("School expert")
+ click_on(I18n.t("generic.button.next"))
+
+ # This question should be made visible after the previous step
+ click_on("What colour is the sky?")
+ choose("Red")
+ click_on(I18n.t("generic.button.next"))
+
+ # This question should be made visible after the previous step
+ click_on("You should NOT be able to see this question?")
+ choose("School expert")
+ click_on(I18n.t("generic.button.next"))
+
+ # Edit the first question to remove the chain of hidden questions
+ click_on("What support do you have available?")
+ choose("None")
+ click_on(I18n.t("generic.button.update"))
+
+ expect(page).not_to have_content("What colour is the sky? ")
+ expect(page).not_to have_content("You should NOT be able to see this question?")
+
+ # Edit the first question to add back the full chain of hidden questions
+ click_on("What support do you have available?")
+ choose("School expert")
+ click_on(I18n.t("generic.button.update"))
+
+ expect(page).to have_content("What colour is the sky? ")
+ expect(page).to have_content("You should NOT be able to see this question?")
+ end
+ end
+ context "when a user edits an answer" do
+ scenario "the user is returned to the same place in the task list " do
+ visit journey_path(answer.step.journey)
+
+ click_on(answer.step.title)
+
+ fill_in "answer[response]", with: "email@example.com"
+
+ click_on(I18n.t("generic.button.update"))
+
+ expect(page).to have_current_path(journey_url(answer.step.journey, anchor: answer.step.id))
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/preview_a_journey_step_spec.rb b/spec/features/school_buying_professionals/preview_a_journey_step_spec.rb
new file mode 100644
index 000000000..e1e2b31d7
--- /dev/null
+++ b/spec/features/school_buying_professionals/preview_a_journey_step_spec.rb
@@ -0,0 +1,16 @@
+feature "Users can preview a journey step" do
+ scenario "the appropriate step is displayed" do
+ stub_contentful_entry(
+ entry_id: "radio-question",
+ fixture_filename: "steps/radio-question.json"
+ )
+
+ user_is_signed_in
+
+ visit preview_entry_path("radio-question")
+
+ expect(page).to have_content("Which service do you need?")
+ expect(page).to have_content("Catering")
+ expect(page).to have_content("Cleaning")
+ end
+end
diff --git a/spec/features/school_buying_professionals/resume_a_journey_spec.rb b/spec/features/school_buying_professionals/resume_a_journey_spec.rb
new file mode 100644
index 000000000..4c5fd4b87
--- /dev/null
+++ b/spec/features/school_buying_professionals/resume_a_journey_spec.rb
@@ -0,0 +1,20 @@
+require "rails_helper"
+
+feature "Users can see how to resume a journey" do
+ let(:user) { create(:user) }
+ before { user_is_signed_in(user: user) }
+
+ context "on the task list" do
+ let(:journey) { create(:journey, user: user) }
+
+ scenario "displays the notification banner" do
+ visit journey_path(journey)
+ expect(page).to have_content(I18n.t("resume.notification.title"))
+ end
+
+ scenario "displays the journey URL" do
+ visit journey_path(journey)
+ expect(page).to have_content(journey_url(journey))
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/see_the_map_of_journey_steps_page_spec.rb b/spec/features/school_buying_professionals/see_the_map_of_journey_steps_page_spec.rb
new file mode 100644
index 000000000..4365369b6
--- /dev/null
+++ b/spec/features/school_buying_professionals/see_the_map_of_journey_steps_page_spec.rb
@@ -0,0 +1,111 @@
+feature "Users can see all the steps of a journey" do
+ before { user_is_signed_in }
+
+ around do |example|
+ ClimateControl.modify(
+ CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
+ ) do
+ example.run
+ end
+ end
+
+ scenario "Multiple journey steps" do
+ stub_contentful_category(
+ fixture_filename: "journey-with-multiple-entries.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[0]) do
+ expect(page)
+ .to have_content(
+ "Which service do you need?"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.edit_step_link_text"),
+ href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/radio-question"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.preview_step_link_text"),
+ href: preview_entry_path("radio-question")
+ )
+ expect(page)
+ .to have_content(
+ "{{ answer_radio-question }}"
+ )
+ end
+ within(list_items[1]) do
+ expect(page)
+ .to have_content(
+ "What email address did you use?"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.edit_step_link_text"),
+ href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/short-text-question"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.preview_step_link_text"),
+ href: preview_entry_path("short-text-question")
+ )
+ expect(page)
+ .to have_content(
+ "{{ answer_short-text-question }}"
+ )
+ end
+ within(list_items[2]) do
+ expect(page)
+ .to have_content(
+ "Describe what you need"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.edit_step_link_text"),
+ href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/long-text-question"
+ )
+ expect(page)
+ .to have_link(
+ I18n.t("journey_map.preview_step_link_text"),
+ href: preview_entry_path("long-text-question")
+ )
+ expect(page)
+ .to have_content(
+ "{{ answer_long-text-question }}"
+ )
+ 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_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
+ ) do
+ example.run
+ end
+ end
+
+ it "returns an error message" do
+ stub_contentful_category(
+ fixture_filename: "journey-with-repeat-entries.json",
+ stub_steps: false
+ )
+
+ 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: "radio-question")
+ )
+ end
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/see_their_catering_specification_spec.rb b/spec/features/school_buying_professionals/see_their_catering_specification_spec.rb
new file mode 100644
index 000000000..4cecd7834
--- /dev/null
+++ b/spec/features/school_buying_professionals/see_their_catering_specification_spec.rb
@@ -0,0 +1,86 @@
+feature "Users can see their catering specification" do
+ before { user_is_signed_in }
+
+ scenario "HTML" do
+ start_journey_from_category_and_go_to_question(category: "category-with-dynamic-liquid-template.json")
+
+ choose("Catering")
+ click_on(I18n.t("generic.button.next"))
+ click_on(I18n.t("journey.specification.button"))
+
+ expect(page).to have_content(I18n.t("journey.specification.header"))
+
+ within("article#specification") do
+ expect(page).to have_content("Menus and ordering")
+ expect(page).to have_content("Food standards")
+ expect(page).to have_content("The school also requires the service to comply with the following non-mandatory food standards or schemes:")
+ expect(page).to have_content("Catering")
+ end
+ end
+
+ scenario "navigates back to the task list" do
+ start_journey_from_category(category: "extended-radio-question.json")
+
+ click_on(I18n.t("journey.specification.button"))
+
+ click_on(I18n.t("generic.button.back"))
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+ end
+
+ scenario "renders radio responses that have futher information" do
+ start_journey_from_category_and_go_to_question(category: "extended-radio-question.json")
+
+ choose("Catering")
+ fill_in "answer[catering_further_information]", with: "The school needs the kitchen cleaned once a day"
+ click_on(I18n.t("generic.button.next"))
+ click_on(I18n.t("journey.specification.button"))
+
+ expect(page).to have_content(I18n.t("journey.specification.header"))
+
+ within("article#specification") do
+ expect(page).to have_content("Catering")
+ expect(page).to have_content("The school needs the kitchen cleaned once a day")
+ end
+ end
+
+ scenario "renders checkbox responses that have further information" do
+ start_journey_from_category_and_go_to_question(category: "extended-checkboxes-question.json")
+
+ check("Yes")
+ fill_in "answer[yes_further_information]", with: "More info for yes"
+ check("No")
+ fill_in "answer[no_further_information]", with: "More info for no"
+
+ click_on(I18n.t("generic.button.next"))
+ click_on(I18n.t("journey.specification.button"))
+
+ expect(page).to have_content(I18n.t("journey.specification.header"))
+
+ within("article#specification") do
+ expect(page).to have_content("yes")
+ expect(page).to have_content("More info for yes")
+ expect(page).to have_content("no")
+ expect(page).to have_content("More info for no")
+ end
+ end
+
+ scenario "questions that are skipped can be identified" do
+ start_journey_from_category_and_go_to_question(category: "skippable-checkboxes-question.json")
+
+ click_on("None of the above")
+ click_on(I18n.t("journey.specification.button"))
+
+ expect(page).to have_content("Skipped question detected")
+ end
+
+ context "when the spec is incomplete" do
+ it "warns the user that the contents are in a partially completed state" do
+ start_journey_from_category(category: "extended-radio-question.json")
+
+ # Don't answer any questions to create a in progress spec
+
+ click_on(I18n.t("journey.specification.button"))
+ expect(page).to have_content("You have not completed all the tasks. There may be information missing from your specification.")
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/view_a_dashboard_spec.rb b/spec/features/school_buying_professionals/view_a_dashboard_spec.rb
new file mode 100644
index 000000000..a55be68e5
--- /dev/null
+++ b/spec/features/school_buying_professionals/view_a_dashboard_spec.rb
@@ -0,0 +1,50 @@
+require "rails_helper"
+
+feature "Anyone can view a dashboard" do
+ before { user_is_signed_in }
+
+ around do |example|
+ ClimateControl.modify(
+ CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
+ ) do
+ example.run
+ end
+ end
+
+ scenario "Dashboard displays the title" do
+ visit dashboard_path
+
+ expect(page).to have_content(I18n.t("dashboard.header"))
+ end
+
+ scenario "user can view existing specifications" do
+ user = create(:user)
+ user_is_signed_in(user: user)
+ create(:journey, user: user, created_at: Time.local(2021, 2, 15, 12, 0, 0))
+
+ visit dashboard_path
+
+ expect(page).to have_content(I18n.t("dashboard.existing.header"))
+ expect(page).to have_content(I18n.t("dashboard.existing.body"))
+
+ click_on(I18n.t("dashboard.existing.link"))
+
+ expect(page).to have_content(I18n.t("journey.index.existing.header"))
+ expect(page).to have_content("15 February 2021")
+ end
+
+ scenario "user can start a new specification" do
+ stub_contentful_category(fixture_filename: "radio-question.json")
+
+ visit dashboard_path
+
+ expect(page).to have_content(I18n.t("dashboard.create.header"))
+ expect(page).to have_content(I18n.t("dashboard.create.body"))
+
+ click_on(I18n.t("dashboard.create.link"))
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+ expect(page).to have_content("Which service do you need?")
+ expect(page).to have_content("Not started")
+ end
+end
diff --git a/spec/features/school_buying_professionals/view_a_list_of_tasks_spec.rb b/spec/features/school_buying_professionals/view_a_list_of_tasks_spec.rb
new file mode 100644
index 000000000..d509dcfbc
--- /dev/null
+++ b/spec/features/school_buying_professionals/view_a_list_of_tasks_spec.rb
@@ -0,0 +1,71 @@
+require "rails_helper"
+
+feature "Users can view the task list" do
+ let(:user) { create(:user) }
+ before { user_is_signed_in(user: user) }
+
+ it "tasks are grouped by their section" do
+ start_journey_from_category(category: "multiple-sections.json")
+
+ within(".app-task-list") do
+ expect(page).to have_content("Section A")
+ expect(page).to have_content("Section B")
+ end
+
+ task_lists = find_all(".app-task-list__items")
+
+ within(task_lists[0]) do
+ expect(page).to have_content("Which service do you need?")
+ end
+
+ within(task_lists[1]) do
+ expect(page).to have_content("Describe what you need")
+ end
+ end
+
+ scenario "user can navigate back to the dashboard" do
+ start_journey_from_category(category: "extended-radio-question.json")
+
+ click_on(I18n.t("generic.button.back"))
+
+ expect(page).to have_content(I18n.t("dashboard.header"))
+ end
+
+ context "When a question has been answered" do
+ scenario "The task is marked as completed" do
+ stub_contentful_category(fixture_filename: "multiple-sections.json")
+
+ answer = create(:short_text_answer, response: "answer")
+ answer.step.journey.update(
+ user: user,
+ section_groups: [
+ {
+ "order" => 0,
+ "title" => "Section A",
+ "steps" => [
+ {
+ "contentful_id" => answer.step.contentful_id,
+ "order" => 0
+ }
+ ]
+ }
+ ]
+ )
+
+ user_starts_the_journey
+
+ visit journey_path(answer.step.journey)
+
+ expect(page).to have_content(I18n.t("task_list.status.completed"))
+ end
+ end
+
+ context "When a question has been hidden" do
+ it "should not appear in the task list" do
+ start_journey_from_category(category: "hidden-field.json")
+
+ expect(page).not_to have_content("You should NOT be able to see this question")
+ expect(page).to have_content("You should be able to see this question")
+ end
+ end
+end
diff --git a/spec/features/school_buying_professionals/view_existing_journeys_spec.rb b/spec/features/school_buying_professionals/view_existing_journeys_spec.rb
new file mode 100644
index 000000000..268d77f6b
--- /dev/null
+++ b/spec/features/school_buying_professionals/view_existing_journeys_spec.rb
@@ -0,0 +1,45 @@
+require "rails_helper"
+
+feature "Users can view their existing journeys" do
+ before { user_is_signed_in }
+
+ it "displays the page header" do
+ visit journeys_path
+ expect(page).to have_content(I18n.t("journey.index.existing.header"))
+ end
+
+ it "lists existing journeys" do
+ user = create(:user)
+ user_is_signed_in(user: user)
+ create(:journey, user: user, created_at: Time.local(2021, 2, 15, 12, 0, 0))
+ create(:journey, user: user, created_at: Time.local(2021, 3, 20, 12, 0, 0))
+
+ visit journeys_path
+
+ expect(page).to have_content("15 February 2021")
+ expect(page).to have_content("20 March 2021")
+ end
+
+ context "when the journey does not belong to the user" do
+ scenario "that journey is not shown" do
+ travel_to Time.zone.local(2021, 2, 15, 12, 0, 0)
+
+ another_user = create(:user)
+ _another_users_journey = create(:journey,
+ user: another_user,
+ created_at: Time.local(2021, 3, 20, 12, 0, 0))
+
+ signed_in_user = create(:user)
+ user_is_signed_in(user: signed_in_user)
+
+ # Start the journey which creates a journey record
+ start_journey_from_category(category: "radio-question.json")
+
+ click_on(I18n.t("generic.button.back"))
+ click_on(I18n.t("dashboard.existing.link"))
+
+ expect(page).to have_content("15 February 2021")
+ expect(page).not_to have_content("20 March 2021")
+ end
+ end
+end
diff --git a/spec/features/visitors/anyone_can_complete_a_journey_spec.rb b/spec/features/visitors/anyone_can_complete_a_journey_spec.rb
deleted file mode 100644
index c175e424a..000000000
--- a/spec/features/visitors/anyone_can_complete_a_journey_spec.rb
+++ /dev/null
@@ -1,316 +0,0 @@
-require "rails_helper"
-
-feature "Anyone can start a journey" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "Start page includes a call to action" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
-
- visit root_path
-
- click_on(I18n.t("generic.button.start"))
-
- expect(page).to have_content("Catering")
- expect(page).to have_content("Which service do you need?")
- expect(page).to have_content("Not started")
- end
-
- scenario "an answer must be provided" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-radio-question")
-
- visit journey_step_path(journey, step)
-
- # Omit a choice
-
- click_on(I18n.t("generic.button.next"))
-
- expect(page).to have_content("can't be blank")
- end
-
- 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: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "user can answer using free text" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-short-text-question")
-
- visit journey_step_path(journey, step)
-
- fill_in "answer[response]", with: "email@example.com"
- click_on(I18n.t("generic.button.next"))
-
- click_on(step.title)
-
- expect(find_field("answer-response-field").value).to eql("email@example.com")
- end
- end
-
- context "when Contentful entry is of type long_text" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "user can answer using free text with multiple lines" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-long-text-question")
-
- visit journey_step_path(journey, step)
-
- fill_in "answer[response]", with: "We would like a supplier to provide catering from September 2020.\nThey must be able to supply us for 3 years minumum."
- click_on(I18n.t("generic.button.next"))
-
- click_on(step.title)
-
- expect(find_field("answer-response-field").value).to eql("We would like a supplier to provide catering from September 2020.\r\nThey must be able to supply us for 3 years minumum.")
- end
- end
-
- context "when Contentful entry is of type single_date" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "user can answer using a date input" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-single-date-question")
-
- visit journey_step_path(journey, step)
-
- 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"))
-
- click_on(step.title)
-
- expect(find_field("answer_response_3i").value).to eql("12")
- expect(find_field("answer_response_2i").value).to eql("8")
- expect(find_field("answer_response_1i").value).to eql("2020")
- end
- end
-
- context "when Contentful entry is of type checkboxes" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "user can select multiple answers" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-checkboxes-question")
-
- visit journey_step_path(journey, step)
-
- check "Breakfast"
- check "Lunch"
-
- click_on(I18n.t("generic.button.next"))
-
- click_on(step.title)
-
- expect(page).to have_checked_field("answer-response-breakfast-field")
- expect(page).to have_checked_field("answer-response-lunch-field")
- end
- end
-
- context "when Contentful entry is of type radios" do
- context "when extra configuration is passed to collect further info" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "asks the user for further information" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-radio-question")
-
- visit journey_step_path(journey, step)
-
- click_on(I18n.t("generic.button.start"))
-
- choose("Catering")
- fill_in "answer[further_information]", with: "The school needs the kitchen cleaned once a day"
-
- click_on(I18n.t("generic.button.next"))
-
- click_on(step.title)
-
- expect(page).to have_checked_field("Catering")
- expect(find_field("answer-further-information-field").value)
- .to eql("The school needs the kitchen cleaned once a day")
- end
- end
- end
- end
-
- 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: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "user can read static content and proceed without answering" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
- journey = CreateJourney.new(category: "catering").call
- step = journey.steps.find_by(contentful_id: "contentful-starting-step")
-
- visit journey_step_path(journey, step)
-
- 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
-
- click_on(I18n.t("generic.button.next"))
-
- expect(page).to have_content("Catering")
- end
- end
- end
-
- context "when Contentful entry model wasn't an expected type" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-unexpected-model"
- ) do
- example.run
- end
- end
-
- scenario "returns an error message" do
- stub_get_contentful_entries(
- entry_id: "contentful-unexpected-model",
- fixture_filename: "path-with-unexpected-model.json"
- )
-
- visit new_journey_path
-
- expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_title"))
- expect(page).to have_content(I18n.t("errors.unexpected_contentful_model.page_body"))
- end
- end
-
- context "when the Contentful Entry wasn't an expected step type" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-unexpected-step-type"
- ) do
- example.run
- end
- end
-
- scenario "returns an error message" do
- stub_get_contentful_entries(
- entry_id: "contentful-unexpected-step-type",
- fixture_filename: "path-with-unexpected-step-type.json"
- )
-
- visit new_journey_path
-
- expect(page).to have_content(I18n.t("errors.unexpected_contentful_step_type.page_title"))
- expect(page).to have_content(I18n.t("errors.unexpected_contentful_step_type.page_body"))
- end
- end
-
- context "when the starting entry id doesn't exist" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-fake-entry-id"
- ) do
- example.run
- end
- end
-
- scenario "a Contentful entry_id does not exist" do
- stub_get_contentful_entries(
- entry_id: "contentful-fake-entry-id",
- fixture_filename: "closed-path-with-multiple-example.json"
- )
-
- visit new_journey_path
-
- 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
-
- context "when the Liquid template was invalid" do
- it "raises an error" do
- fake_liquid_template = File.read("#{Rails.root}/spec/fixtures/specification_templates/invalid.liquid")
- allow_any_instance_of(FindLiquidTemplate).to receive(:file).and_return(fake_liquid_template)
-
- visit root_path
-
- click_on(I18n.t("generic.button.start"))
-
- expect(page).to have_content(I18n.t("errors.specification_template_invalid.page_title"))
- expect(page).to have_content(I18n.t("errors.specification_template_invalid.page_body"))
- end
- end
-end
diff --git a/spec/features/visitors/anyone_can_download_their_catering_specification_spec.rb b/spec/features/visitors/anyone_can_download_their_catering_specification_spec.rb
deleted file mode 100644
index 91ae26a98..000000000
--- a/spec/features/visitors/anyone_can_download_their_catering_specification_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-feature "Users can see their catering specification" do
- scenario "HTML" do
- journey = create(:journey, :catering, liquid_template: stub_liquid_template)
- step = create(:step, :long_text, long_text_answer: nil, journey: journey, contentful_id: "NxJWpbiFeEAmvcw17EysX")
- _answer = create(:long_text_answer, step: step, response: "Red tractor")
-
- visit journey_path(journey)
-
- expect(page).to have_content(I18n.t("journey.specification.header"))
-
- click_on("Download (.docx)")
-
- expect(page.response_headers["Content-Type"]).to eql("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
- header = page.response_headers["Content-Disposition"]
- expect(header).to match(/^attachment/)
- expect(header).to match(/filename="specification.docx"/)
- end
-
- def stub_liquid_template
- fake_liquid_template = File.read("#{Rails.root}/spec/fixtures/specification_templates/food_catering.liquid")
-
- finder = instance_double(FindLiquidTemplate)
- allow(FindLiquidTemplate).to receive(:new).with(category: "catering")
- .and_return(finder)
- allow(finder).to receive(:call).and_return(fake_liquid_template)
-
- fake_liquid_template
- 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
deleted file mode 100644
index 73b8b0ede..000000000
--- a/spec/features/visitors/anyone_can_edit_their_answers_spec.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-require "rails_helper"
-
-feature "Users can edit their answers" do
- let(:answer) { create(:short_text_answer, response: "answer") }
-
- context "when the question is short_text" do
- let(:answer) { create(:short_text_answer, response: "answer") }
-
- scenario "The edited answer is saved" do
- visit journey_path(answer.step.journey)
-
- click_on(answer.step.title)
-
- fill_in "answer[response]", with: "email@example.com"
-
- click_on(I18n.t("generic.button.update"))
-
- click_on(answer.step.title)
-
- expect(find_field("answer-response-field").value).to eql("email@example.com")
- end
- end
-
- context "when the question is single_date" do
- let(:answer) { create(:single_date_answer, response: 1.year.ago) }
-
- scenario "The edited answer is saved" do
- visit journey_path(answer.step.journey)
-
- click_on(answer.step.title)
-
- 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.update"))
-
- click_on(answer.step.title)
-
- expect(find_field("answer_response_3i").value).to eql("12")
- expect(find_field("answer_response_2i").value).to eql("8")
- expect(find_field("answer_response_1i").value).to eql("2020")
- end
- end
-
- context "when the question is checkbox_answers" do
- let(:answer) { create(:checkbox_answers, response: ["breakfast", "lunch", ""]) }
-
- scenario "The edited answer is saved" do
- visit journey_path(answer.step.journey)
-
- click_on(answer.step.title)
-
- uncheck "Breakfast"
-
- click_on(I18n.t("generic.button.update"))
-
- click_on(answer.step.title)
-
- expect(page).not_to have_checked_field("answer-response-breakfast-field")
- expect(page).to have_checked_field("answer-response-lunch-field")
- end
- end
-
- context "An error is thrown" do
- scenario "When an answer is invalid" do
- visit journey_path(answer.step.journey)
-
- click_on(answer.step.title)
-
- 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_a_planning_start_page_spec.rb b/spec/features/visitors/anyone_can_see_a_planning_start_page_spec.rb
deleted file mode 100644
index 31203aa32..000000000
--- a/spec/features/visitors/anyone_can_see_a_planning_start_page_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-require "rails_helper"
-
-feature "Users can see a start page for planning their purchase" do
- scenario "Start page content is shown on the root path" do
- visit root_path
-
- expect(page).to have_content(I18n.t("planning.start_page.page_title"))
-
- expect(page).to have_content(I18n.t("planning.start_page.overview_title"))
- I18n.t("planning.start_page.overview_body").each do |paragraph|
- expect(page).to have_content(paragraph)
- end
-
- expect(page).to have_content(I18n.t("planning.start_page.before_you_start_list_title"))
- I18n.t("planning.start_page.before_you_start_list_items").each do |list_item|
- expect(page).to have_content(list_item)
- end
- expect(page).to have_content(I18n.t("planning.start_page.before_you_start_body"))
- end
-end
diff --git a/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb b/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb
deleted file mode 100644
index cfde7b168..000000000
--- a/spec/features/visitors/anyone_can_see_the_map_of_journey_steps_page_spec.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-feature "Users can see all the steps of a journey" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- scenario "Multiple journey steps" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- 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/contentful-starting-step")
- end
- within(list_items.last) do
- expect(page).to have_link("Everyday services that are required and need to be considered", href: "https://app.contentful.com/spaces/#{ENV["CONTENTFUL_SPACE"]}/environments/#{ENV["CONTENTFUL_ENVIRONMENT"]}/entries/contentful-checkboxes-question")
- 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: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- it "returns an error message" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- 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: "contentful-starting-step")
- )
- end
- end
-
- context "when the chain becomes obviously too long" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) 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: "contentful-radio-question",
- 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: "contentful-radio-question", step_count: 1)
- )
- end
- end
- end
-end
diff --git a/spec/features/visitors/anyone_can_see_their_catering_specification_spec.rb b/spec/features/visitors/anyone_can_see_their_catering_specification_spec.rb
deleted file mode 100644
index b0ec0e1ee..000000000
--- a/spec/features/visitors/anyone_can_see_their_catering_specification_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-feature "Users can see their catering specification" do
- scenario "HTML" do
- liquid_template = stub_liquid_template(filename: "food_catering.liquid")
- journey = create(:journey, :catering, liquid_template: liquid_template)
- step = create(:step, :long_text, long_text_answer: nil, journey: journey, contentful_id: "NxJWpbiFeEAmvcw17EysX")
- answer = create(:long_text_answer, step: step, response: "Red tractor")
-
- visit journey_path(journey)
-
- expect(page).to have_content(I18n.t("journey.specification.header"))
-
- within("article#specification") do
- expect(page).to have_content("Menus and ordering")
- expect(page).to have_content("Food standards")
- expect(page).to have_content("The school also requires the service to comply with the following non-mandatory food standards or schemes:")
- expect(page).to have_content(answer.response)
- end
- end
-
- scenario "renders responses that need extra formatting" do
- liquid_template = stub_liquid_template(filename: "food_catering.liquid")
- journey = create(:journey, :catering, liquid_template: liquid_template)
- step = create(:step, :radio, radio_answer: nil, journey: journey, contentful_id: "NxJWpbiFeEAmvcw17EysX")
- _answer = create(:radio_answer, step: step, response: "Red tractor", further_information: "Lots more detail")
-
- visit journey_path(journey)
-
- expect(page).to have_content(I18n.t("journey.specification.header"))
-
- within("article#specification") do
- expect(page).to have_content("Red tractor - Lots more detail")
- end
- end
-end
diff --git a/spec/features/visitors/anyone_can_sign_in_with_dfe_sign_in_spec.rb b/spec/features/visitors/anyone_can_sign_in_with_dfe_sign_in_spec.rb
new file mode 100644
index 000000000..92f7f8f6f
--- /dev/null
+++ b/spec/features/visitors/anyone_can_sign_in_with_dfe_sign_in_spec.rb
@@ -0,0 +1,67 @@
+require "rails_helper"
+
+feature "Anyone can sign in with DfE Sign-in" do
+ context "when the user exists in the service" do
+ scenario "signs in successfully" do
+ stub_contentful_category(fixture_filename: "radio-question.json")
+ user = create(:user)
+
+ user_exists_in_dfe_sign_in(dsi_uid: user.dfe_sign_in_uid)
+ user_starts_the_journey
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+
+ within("header") do
+ expect(page).to have_content(I18n.t("generic.button.sign_out"))
+ end
+ end
+
+ scenario "can move between pages without reauthenticating by relying on session data" do
+ stub_contentful_category(fixture_filename: "radio-question.json")
+ user = create(:user)
+
+ user_exists_in_dfe_sign_in(dsi_uid: user.dfe_sign_in_uid)
+ user_starts_the_journey
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+
+ # Undo the OmniAuth stub to check we don't require it again
+ OmniAuth.config.mock_auth[:dfe] = OmniAuth::AuthHash.new(foo: :bar)
+
+ journey = create(:journey, user: user)
+ step = create(:step, :radio, journey: journey)
+ visit journey_step_path(journey, step)
+ expect(page).to have_content(step.title)
+ end
+ end
+
+ context "when the user doesn't exist in the service" do
+ scenario "new users can sign in" do
+ stub_contentful_category(fixture_filename: "radio-question.json")
+
+ a_dsi_uid_we_have_not_seen_before = "7f2cbd01-6779-4524-acc4-0c6ef52120b5"
+
+ # Omit the creation of a fake user to simulate a user not found scenario
+
+ user_exists_in_dfe_sign_in(dsi_uid: a_dsi_uid_we_have_not_seen_before)
+ user_starts_the_journey
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+ new_user = User.find_by(dfe_sign_in_uid: a_dsi_uid_we_have_not_seen_before)
+ expect(new_user.dfe_sign_in_uid).to eql(a_dsi_uid_we_have_not_seen_before)
+ end
+ end
+
+ scenario "sign in fails" do
+ user_sign_in_attempt_fails
+
+ expect(Rollbar).to receive(:error)
+ .with("Sign in failed unexpectedly")
+ .and_call_original
+
+ visit root_path
+ click_button I18n.t("generic.button.start")
+
+ expect(page).to have_content(I18n.t("errors.sign_in.unexpected_failure.page_title"))
+ expect(page).to have_content(I18n.t("errors.sign_in.unexpected_failure.page_body"))
+ end
+end
diff --git a/spec/features/visitors/anyone_can_view_a_list_of_tasks_spec.rb b/spec/features/visitors/anyone_can_view_a_list_of_tasks_spec.rb
deleted file mode 100644
index 908283b28..000000000
--- a/spec/features/visitors/anyone_can_view_a_list_of_tasks_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-require "rails_helper"
-
-feature "Users can view the task list" do
- context "When a question has been answered" do
- let(:answer) { create(:short_text_answer, response: "answer") }
-
- scenario "The task is marked as completed" do
- visit journey_path(answer.step.journey)
-
- expect(page).to have_content(I18n.t("task_list.status.completed"))
- end
- end
-end
diff --git a/spec/features/visitors/anyone_navigate_back_to_home_page_spec.rb b/spec/features/visitors/navigate_back_to_home_page_spec.rb
similarity index 100%
rename from spec/features/visitors/anyone_navigate_back_to_home_page_spec.rb
rename to spec/features/visitors/navigate_back_to_home_page_spec.rb
diff --git a/spec/features/visitors/see_a_planning_start_page_spec.rb b/spec/features/visitors/see_a_planning_start_page_spec.rb
new file mode 100644
index 000000000..c9fb749f4
--- /dev/null
+++ b/spec/features/visitors/see_a_planning_start_page_spec.rb
@@ -0,0 +1,57 @@
+require "rails_helper"
+
+feature "Users can see a start page for planning their purchase" do
+ scenario "Start page content is shown on the root path" do
+ visit root_path
+
+ click_on("procuring a new catering service for a school")
+
+ expect(page).to have_content("Catering services")
+ expect(page).to have_content("How to procure a catering contract for your school.")
+
+ expect(page).to have_content("Before you start")
+ page.find(:xpath, "//*[contains(text(),'Before you start')]").click
+ expect(page).to have_content("A catering contract typically takes between 3 to 6 months to complete.")
+
+ expect(page).to have_content("Ways to procure a catering contract")
+ page.find(:xpath, "//*[contains(text(),'Ways to procure a catering contract')]").click
+ expect(page).to have_content("A catering contract is a high value procurement. We generally say high is over 40,000.")
+
+ expect(page).to have_content("Rules, regulations and requirements")
+ page.find(:xpath, "//*[contains(text(),'Rules, regulations and requirements')]").click
+ expect(page).to have_content("You'll need to be aware of some of the rules, regulations and requirements that can apply to a catering contract.")
+
+ expect(page).to have_content("In-house catering")
+ page.find(:xpath, "//*[contains(text(),'In-house catering')]").click
+ expect(page).to have_content("Running the service in-house is another option. You may want to consider whether this is right for your school before you procure a contract. There are benefits and challenges to running the service yourself.")
+
+ expect(page).to have_content("Starting a procurement process")
+ page.find(:xpath, "//*[contains(text(),'Starting a procurement process')]").click
+ expect(page).to have_content("Who to involve")
+
+ expect(page).to have_content("Writing your requirements")
+ page.find(:xpath, "//*[contains(text(),'Writing your requirements')]").click
+ expect(page).to have_content("This is the document that you give to suppliers explaining what you want to buy, sometimes called a specification.")
+ expect(page).to have_link("use our tool to create a specification", href: root_path)
+
+ expect(page).to have_content("What to do next")
+ page.find(:xpath, "//*[contains(text(),'What to do next')]").click
+ expect(page).to have_content("Once you have your specification, you will need to decide if you will be using the open or restricted procedure.")
+
+ expect(page).to have_content("Where to get help")
+ expect(page).to have_content("See where to get help with buying for schools if you need it.")
+ expect(page).to have_link("get help with buying for schools", href: "https://www.gov.uk/guidance/buying-for-schools/get-help-with-buying-for-schools")
+ end
+
+ scenario "can navigate back to the home page" do
+ visit root_path
+
+ click_on("procuring a new catering service for a school")
+
+ expect(page).to have_content(I18n.t("planning.start_page.page_title"))
+
+ click_on(I18n.t("generic.button.back"))
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+ end
+end
diff --git a/spec/features/visitors/see_a_specification_start_page_spec.rb b/spec/features/visitors/see_a_specification_start_page_spec.rb
new file mode 100644
index 000000000..ee376c475
--- /dev/null
+++ b/spec/features/visitors/see_a_specification_start_page_spec.rb
@@ -0,0 +1,46 @@
+require "rails_helper"
+
+feature "Users can see a start page for specifying their purchase" do
+ scenario "Start page content is shown on the root path" do
+ visit root_path
+
+ expect(page).to have_content(I18n.t("specifying.start_page.page_title"))
+
+ I18n.t("specifying.start_page.overview_body").each do |paragraph|
+ expect(page).to have_content(paragraph)
+ end
+
+ expect(page).to have_content(I18n.t("specifying.start_page.who_for_title"))
+ expect(page).to have_content(I18n.t("specifying.start_page.who_for_can_use_body"))
+
+ expect(page).to have_content("are responsible for procuring a new catering service for a school")
+ expect(page).to have_link("procuring a new catering service", href: "/planning")
+ expect(page).to have_content(I18n.t("specifying.start_page.who_for_can_use_list")[1])
+ expect(page).to have_content(I18n.t("specifying.start_page.who_for_can_use_list")[2])
+
+ expect(page).to have_content(I18n.t("specifying.start_page.who_for_cannot_use_body"))
+ I18n.t("specifying.start_page.who_for_cannot_use_list").each do |list_item|
+ expect(page).to have_content(list_item)
+ end
+
+ expect(page).to have_content(I18n.t("specifying.start_page.how_service_works_title"))
+ expect(page).to have_content(I18n.t("specifying.start_page.how_service_works_document_body"))
+ I18n.t("specifying.start_page.how_service_works_document_list").each do |list_item|
+ expect(page).to have_content(list_item)
+ end
+ expect(page).to have_content(I18n.t("specifying.start_page.how_service_works_themes_body"))
+ I18n.t("specifying.start_page.how_service_works_themes_list").each do |list_item|
+ expect(page).to have_content(list_item)
+ end
+
+ expect(page).to have_content(I18n.t("specifying.start_page.pause_and_resume_body"))
+ end
+
+ scenario "The start button takes the user to the dashboard" do
+ visit root_path
+
+ click_on(I18n.t("generic.button.start"))
+
+ expect(page).to have_content(I18n.t("dashboard.header"))
+ end
+end
diff --git a/spec/fixtures/contentful/categories/category-with-dynamic-liquid-template.json b/spec/fixtures/contentful/categories/category-with-dynamic-liquid-template.json
new file mode 100644
index 000000000..f37b3a8ed
--- /dev/null
+++ b/spec/fixtures/contentful/categories/category-with-dynamic-liquid-template.json
@@ -0,0 +1,44 @@
+{
+ "sys": {
+ "space": {
+ "sys": {
+ "type": "Link",
+ "linkType": "Space",
+ "id": "rwl7tyzv9sys"
+ }
+ },
+ "id": "contentful-category-entry",
+ "type": "Entry",
+ "createdAt": "2021-01-20T12:19:10.220Z",
+ "updatedAt": "2021-01-20T15:06:12.067Z",
+ "environment": {
+ "sys": {
+ "id": "develop",
+ "type": "Link",
+ "linkType": "Environment"
+ }
+ },
+ "revision": 2,
+ "contentType": {
+ "sys": {
+ "type": "Link",
+ "linkType": "ContentType",
+ "id": "category"
+ }
+ },
+ "locale": "en-US"
+ },
+ "fields": {
+ "title": "Catering",
+ "sections": [
+ {
+ "sys": {
+ "type": "Link",
+ "linkType": "Entry",
+ "id": "radio-section"
+ }
+ }
+ ],
+ "specification_template": "\n \n
Menus and ordering
\n
Food standards\n \n {% if answer_radio-question %}\n
\n
The school also requires the service to comply with the following non-mandatory food standards or schemes:
"})
+ end
+ end
end
diff --git a/spec/presenters/number_answer_presenter_spec.rb b/spec/presenters/number_answer_presenter_spec.rb
new file mode 100644
index 000000000..32af5c4cd
--- /dev/null
+++ b/spec/presenters/number_answer_presenter_spec.rb
@@ -0,0 +1,19 @@
+require "rails_helper"
+
+RSpec.describe NumberAnswerPresenter do
+ describe "#response" do
+ it "returns the response as a string" do
+ step = build(:number_answer, response: 100)
+ presenter = described_class.new(step)
+ expect(presenter.response).to eq("100")
+ end
+ end
+
+ describe "#to_param" do
+ it "returns a hash of number_answer" do
+ step = build(:number_answer, response: 1)
+ presenter = described_class.new(step)
+ expect(presenter.to_param).to eql({response: "1"})
+ end
+ end
+end
diff --git a/spec/presenters/radio_answer_presenter_spec.rb b/spec/presenters/radio_answer_presenter_spec.rb
index edb83426d..f5b85daa1 100644
--- a/spec/presenters/radio_answer_presenter_spec.rb
+++ b/spec/presenters/radio_answer_presenter_spec.rb
@@ -3,17 +3,26 @@
RSpec.describe RadioAnswerPresenter do
describe "#response" do
it "returns the option chosen" do
- step = build(:radio_answer, response: "Yes", further_information: "")
+ step = build(:radio_answer,
+ response: "Yes",
+ further_information: {yes_further_information: "More yes info"})
presenter = described_class.new(step)
expect(presenter.response).to eq("Yes")
end
+ end
+
+ describe "#to_param" do
+ it "returns a hash of radio_answer" do
+ step = build(:radio_answer,
+ response: "Yes",
+ further_information: {yes_further_information: "More yes info"})
+
+ presenter = described_class.new(step)
- context "when further information is provided" do
- it "returns the option chosen and the further information" do
- step = build(:radio_answer, response: "Yes", further_information: "This is really important")
- presenter = described_class.new(step)
- expect(presenter.response).to eq("Yes - This is really important")
- end
+ expect(presenter.to_param).to eql({
+ response: "Yes",
+ further_information: "More yes info"
+ })
end
end
end
diff --git a/spec/presenters/short_text_answer_presenter_spec.rb b/spec/presenters/short_text_answer_presenter_spec.rb
index 69c223f25..45ee3970a 100644
--- a/spec/presenters/short_text_answer_presenter_spec.rb
+++ b/spec/presenters/short_text_answer_presenter_spec.rb
@@ -8,4 +8,12 @@
expect(presenter.response).to eq("A little of text")
end
end
+
+ describe "#to_param" do
+ it "returns a hash of short_text_answer" do
+ step = build(:short_text_answer, response: "Red")
+ presenter = described_class.new(step)
+ expect(presenter.to_param).to eql({response: "Red"})
+ end
+ end
end
diff --git a/spec/presenters/single_date_answer_presenter_spec.rb b/spec/presenters/single_date_answer_presenter_spec.rb
index 5e0771fb3..cfe91ba54 100644
--- a/spec/presenters/single_date_answer_presenter_spec.rb
+++ b/spec/presenters/single_date_answer_presenter_spec.rb
@@ -8,4 +8,12 @@
expect(presenter.response).to eq("30 Dec 2000")
end
end
+
+ describe "#to_param" do
+ it "returns a hash of single_date_answer" do
+ step = build(:single_date_answer, response: Date.new(2000, 12, 30))
+ presenter = described_class.new(step)
+ expect(presenter.to_param).to eql({response: "30 Dec 2000"})
+ end
+ end
end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index f4b3d5b75..f8ca4b2c6 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -34,8 +34,10 @@
end
RSpec.configure do |config|
config.include ContentfulHelpers
- config.include LiquidHelpers
+ config.include JourneyHelpers, type: :feature
config.include ActiveSupport::Testing::TimeHelpers
+ config.include SignInHelpers, type: :feature
+ config.include SignInHelpers, type: :request
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_path = "#{::Rails.root}/spec/fixtures"
diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb
new file mode 100644
index 000000000..f58d705d5
--- /dev/null
+++ b/spec/requests/authentication_spec.rb
@@ -0,0 +1,107 @@
+require "rails_helper"
+
+RSpec.describe "Authentication", type: :request do
+ describe "Endpoints that don't require authentication" do
+ it "the health_check endpoint is not authenticated" do
+ get health_check_path
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "users can access the specification start page" do
+ get root_path
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "users can access the planning guidance page" do
+ get planning_path
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "users can access the new session endpoint" do
+ post "/auth/dfe"
+ expect(response).to have_http_status(:found)
+ end
+
+ it "DfE Sign-in can redirect users back to the service with the callback endpoint" do
+ get auth_dfe_callback_path
+ expect(response).to have_http_status(:found)
+ end
+
+ it "DfE Sign-in can sign users out" do
+ get auth_dfe_signout_path
+ expect(response).to have_http_status(:found)
+ end
+ end
+
+ describe "Endpoints that do require authentication" do
+ it "users cannot access the new journey path" do
+ get new_journey_path
+ expect(response).to redirect_to(root_path)
+ end
+
+ it "users cannot access an existing journey" do
+ journey = create(:journey)
+ get journey_path(journey)
+ expect(response).to redirect_to(root_path)
+ end
+
+ it "users cannot edit an answer" do
+ answer = create(:radio_answer)
+ get edit_journey_step_path(answer.step.journey, answer.step)
+ expect(response).to redirect_to(root_path)
+ end
+
+ it "users cannot see the journey map" do
+ get new_journey_map_path
+ expect(response).to redirect_to(root_path)
+ end
+
+ it "users cannot see the preview endpoints" do
+ get preview_entry_path("an-entry-id")
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ describe "Sign out" do
+ it "asks UserSession to repudiate the user's session data" do
+ user_exists_in_dfe_sign_in
+ expect_any_instance_of(UserSession).to receive(:repudiate!)
+
+ get auth_dfe_signout_path
+
+ expect(response).to redirect_to(root_path)
+ end
+
+ context "when there is no sign out token (they are already signed out from the applications point of view)" do
+ it "redirects the user to the root path" do
+ user_exists_in_dfe_sign_in
+ allow_any_instance_of(UserSession)
+ .to receive(:should_be_signed_out_of_dsi?).and_return(false)
+
+ get auth_dfe_signout_path
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ context "when there is a sign out token" do
+ around do |example|
+ ClimateControl.modify(
+ DFE_SIGN_IN_ISSUER: "https://test-oidc.signin.education.gov.uk:443"
+ ) do
+ example.run
+ end
+ end
+
+ it "redirects to DSI with the users token" do
+ user_exists_in_dfe_sign_in
+ allow_any_instance_of(UserSession)
+ .to receive(:should_be_signed_out_of_dsi?).and_return(true)
+
+ get auth_dfe_signout_path
+
+ expect(response).to redirect_to("https://test-oidc.signin.education.gov.uk:443/session/end?id_token_hint=&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fdfe%2Fsignout")
+ end
+ end
+ end
+end
diff --git a/spec/requests/authorisation_spec.rb b/spec/requests/authorisation_spec.rb
new file mode 100644
index 000000000..920e9f77e
--- /dev/null
+++ b/spec/requests/authorisation_spec.rb
@@ -0,0 +1,28 @@
+require "rails_helper"
+
+RSpec.describe "Authorisation", type: :request do
+ describe "Users can only see journeys they belong to" do
+ context "when the journey IS theirs" do
+ it "returns 200" do
+ journey = create(:journey)
+ user_is_signed_in(user: journey.user)
+
+ get journey_path(journey)
+
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context "when the journey is NOT theirs" do
+ it "returns 404" do
+ another_user = build(:user)
+ journey = create(:journey)
+ user_is_signed_in(user: another_user)
+
+ get journey_path(journey)
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/cache_invalidation_spec.rb b/spec/requests/cache_invalidation_spec.rb
new file mode 100644
index 000000000..4075ccab4
--- /dev/null
+++ b/spec/requests/cache_invalidation_spec.rb
@@ -0,0 +1,58 @@
+require "rails_helper"
+
+RSpec.describe "Cache invalidation", type: :request do
+ around do |example|
+ ClimateControl.modify(
+ CONTENTFUL_WEBHOOK_API_KEY: "an API key"
+ ) do
+ example.run
+ end
+ end
+
+ it "removes any matching entry ID from the cache" do
+ RedisCache.redis.set("contentful:entry:6zeSz4F4YtD66gT5SFpnSB", "a dummy value")
+
+ fake_contentful_webook_payload = {
+ "entityId": "6zeSz4F4YtD66gT5SFpnSB",
+ "spaceId": "rwl7tyzv9sys",
+ "parameters": {
+ "text": "Entity version: 62"
+ }
+ }
+
+ headers = {"HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic
+ .encode_credentials("api", ENV["CONTENTFUL_WEBHOOK_API_KEY"])}
+
+ post "/api/contentful/entry_updated", {
+ params: fake_contentful_webook_payload,
+ headers: headers,
+ as: :json
+ }
+
+ expect(response).to have_http_status(:ok)
+ expect(RedisCache.redis.get("contentful:entry:6zeSz4F4YtD66gT5SFpnSB")).to eq(nil)
+ end
+
+ context "when no basic auth was provided" do
+ it "does not delete anything from the cache and returns 401" do
+ RedisCache.redis.set("contentful:entry:6zeSz4F4YtD66gT5SFpnSB", "a dummy value")
+
+ fake_contentful_webook_payload = {
+ "entityId": "6zeSz4F4YtD66gT5SFpnSB",
+ "spaceId": "rwl7tyzv9sys",
+ "parameters": {
+ "text": "Entity version: 62"
+ }
+ }
+
+ # No basic auth
+ post "/api/contentful/entry_updated",
+ params: fake_contentful_webook_payload,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(RedisCache.redis.get("contentful:entry:6zeSz4F4YtD66gT5SFpnSB"))
+ .to eq("a dummy value")
+ end
+ end
+end
diff --git a/spec/requests/contentful_caching_spec.rb b/spec/requests/contentful_caching_spec.rb
index c96a38676..5cfe49bae 100644
--- a/spec/requests/contentful_caching_spec.rb
+++ b/spec/requests/contentful_caching_spec.rb
@@ -1,59 +1,73 @@
require "rails_helper"
RSpec.describe "Contentful Caching", type: :request do
+ before { user_is_signed_in }
around do |example|
ClimateControl.modify(
- CONTENTFUL_ENTRY_CACHING: "true"
+ CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
) do
example.run
end
end
- it "checks the Redis cache instead of making an external request" do
- journey = create(:journey, next_entry_id: "contentful-radio-question")
-
- raw_response = File.read("#{Rails.root}/spec/fixtures/contentful/radio-question-example.json")
- RedisCache.redis.set("contentful:entry:contentful-radio-question", JSON.dump(raw_response))
+ context "when caching is enabled" do
+ around do |example|
+ ClimateControl.modify(
+ CONTENTFUL_ENTRY_CACHING: "true"
+ ) do
+ example.run
+ end
+ end
+ after(:each) { RedisCache.redis.flushdb }
- expect_any_instance_of(Contentful::Client).not_to receive(:entry)
+ it "checks the Redis cache instead of making an external request" do
+ # TODO: In reality we do not cache categories, but should
+ raw_category_response = File.read("#{Rails.root}/spec/fixtures/contentful/categories/radio-question.json")
+ RedisCache.redis.set("contentful:entry:contentful-category-entry", JSON.dump(raw_category_response))
- get new_journey_step_path(journey)
+ # TODO: In reality we do not cache sections, but should
+ raw_section_response = File.read("#{Rails.root}/spec/fixtures/contentful/sections/radio-section.json")
+ RedisCache.redis.set("contentful:entry:radio-section", JSON.dump(raw_section_response))
- expect(response).to have_http_status(:found)
+ raw_step_response = File.read("#{Rails.root}/spec/fixtures/contentful/steps/radio-question.json")
+ RedisCache.redis.set("contentful:entry:radio-question", JSON.dump(raw_step_response))
- RedisCache.redis.del("contentful:entry:contentful-radio-question")
- end
+ expect_any_instance_of(Contentful::Client).not_to receive(:entry)
- it "stores the external contentful response in the cache" do
- journey = create(:journey, next_entry_id: "contentful-radio-question")
- stub_get_contentful_entry(
- entry_id: "contentful-radio-question",
- fixture_filename: "radio-question-example.json"
- )
+ get new_journey_path
- get new_journey_step_path(journey)
+ expect(response).to have_http_status(:found)
- expect(RedisCache.redis.get("contentful:entry:contentful-radio-question"))
- .to eq("\"{\\\"sys\\\":{\\\"space\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Space\\\",\\\"id\\\":\\\"jspwts36h1os\\\"}},\\\"id\\\":\\\"contentful-radio-question\\\",\\\"type\\\":\\\"Entry\\\",\\\"createdAt\\\":\\\"2020-09-07T10:56:40.585Z\\\",\\\"updatedAt\\\":\\\"2020-09-14T22:16:54.633Z\\\",\\\"environment\\\":{\\\"sys\\\":{\\\"id\\\":\\\"master\\\",\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Environment\\\"}},\\\"revision\\\":7,\\\"contentType\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"ContentType\\\",\\\"id\\\":\\\"question\\\"}},\\\"locale\\\":\\\"en-US\\\"},\\\"fields\\\":{\\\"slug\\\":\\\"/which-service\\\",\\\"title\\\":\\\"Which service do you need?\\\",\\\"helpText\\\":\\\"Tell us which service you need.\\\",\\\"type\\\":\\\"radios\\\",\\\"extendedOptions\\\":[{\\\"value\\\":\\\"Catering\\\"},{\\\"value\\\":\\\"Cleaning\\\"}]}}\"")
+ RedisCache.redis.del("contentful:entry:radio-question")
+ end
- RedisCache.redis.del("contentful:entry:contentful-radio-question")
- end
+ it "stores the external contentful response in the cache" do
+ stub_contentful_category(
+ fixture_filename: "radio-question.json"
+ )
- it "sets a TTL to 72 hours by default" do
- journey = create(:journey, next_entry_id: "contentful-radio-question")
- stub_get_contentful_entry(
- entry_id: "contentful-radio-question",
- fixture_filename: "radio-question-example.json"
- )
+ get new_journey_path
- freeze_time do
- get new_journey_step_path(journey)
+ expect(RedisCache.redis.get("contentful:entry:radio-question"))
+ .to eq("\"{\\\"sys\\\":{\\\"space\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Space\\\",\\\"id\\\":\\\"jspwts36h1os\\\"}},\\\"id\\\":\\\"radio-question\\\",\\\"type\\\":\\\"Entry\\\",\\\"createdAt\\\":\\\"2020-09-07T10:56:40.585Z\\\",\\\"updatedAt\\\":\\\"2020-09-14T22:16:54.633Z\\\",\\\"environment\\\":{\\\"sys\\\":{\\\"id\\\":\\\"master\\\",\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"Environment\\\"}},\\\"revision\\\":7,\\\"contentType\\\":{\\\"sys\\\":{\\\"type\\\":\\\"Link\\\",\\\"linkType\\\":\\\"ContentType\\\",\\\"id\\\":\\\"question\\\"}},\\\"locale\\\":\\\"en-US\\\"},\\\"fields\\\":{\\\"slug\\\":\\\"/which-service\\\",\\\"title\\\":\\\"Which service do you need?\\\",\\\"helpText\\\":\\\"Tell us which service you need.\\\",\\\"type\\\":\\\"radios\\\",\\\"extendedOptions\\\":[{\\\"value\\\":\\\"Catering\\\"},{\\\"value\\\":\\\"Cleaning\\\"}],\\\"alwaysShowTheUser\\\":true}}\"")
- expect(RedisCache.redis.ttl("contentful:entry:contentful-radio-question"))
- .to eq(60 * 60 * 72)
+ RedisCache.redis.del("contentful:entry:radio-question")
end
- RedisCache.redis.del("contentful:entry:contentful-radio-question")
+ it "sets a TTL to 72 hours by default" do
+ stub_contentful_category(
+ fixture_filename: "radio-question.json"
+ )
+
+ freeze_time do
+ get new_journey_path
+
+ expect(RedisCache.redis.ttl("contentful:entry:radio-question"))
+ .to eq(60 * 60 * 72)
+ end
+
+ RedisCache.redis.del("contentful:entry:radio-question")
+ end
end
context "when caching has been disabled in ENV" do
@@ -66,15 +80,13 @@
end
it "does not interact with the redis cache" do
- journey = create(:journey, next_entry_id: "contentful-radio-question")
- stub_get_contentful_entry(
- entry_id: "contentful-radio-question",
- fixture_filename: "radio-question-example.json"
+ stub_contentful_category(
+ fixture_filename: "radio-question.json"
)
expect(RedisCache).not_to receive(:redis)
- get new_journey_step_path(journey)
+ get new_journey_path
end
end
end
diff --git a/spec/requests/entry_preview_spec.rb b/spec/requests/entry_preview_spec.rb
index 916e3b7cd..8a4c99add 100644
--- a/spec/requests/entry_preview_spec.rb
+++ b/spec/requests/entry_preview_spec.rb
@@ -1,15 +1,17 @@
require "rails_helper"
RSpec.describe "Entry previews", type: :request do
+ before { user_is_signed_in }
+
it "creates a dummy journey and redirects to the question creation flow" do
entry_id = "123"
fake_journey = create(:journey)
expect(Journey).to receive(:create)
- .with(category: anything)
+ .with(category: anything, user: anything, liquid_template: anything)
.and_return(fake_journey)
fake_get_contentful_entry = instance_double(Contentful::Entry)
- allow_any_instance_of(GetContentfulEntry).to receive(:call)
+ allow_any_instance_of(GetEntry).to receive(:call)
.and_return(fake_get_contentful_entry)
fake_step = create(:step, :radio)
diff --git a/spec/services/answer_factory_spec.rb b/spec/services/answer_factory_spec.rb
index 05e16c883..1bc49b48f 100644
--- a/spec/services/answer_factory_spec.rb
+++ b/spec/services/answer_factory_spec.rb
@@ -2,6 +2,15 @@
RSpec.describe AnswerFactory do
describe "#call" do
+ context "when the step is for an unknown question type" do
+ it "raises an unexpected question type error" do
+ step = create(:step, options: nil, contentful_model: "question", contentful_type: "telepathy")
+ expect {
+ described_class.new(step: step).call
+ }.to raise_error(AnswerFactory::UnexpectedQuestionType)
+ end
+ end
+
context "when the step is for radios" do
it "returns a new RadioAnswer object" do
step = create(:step, :radio)
@@ -26,4 +35,20 @@
end
end
end
+
+ context "when the step is for number" do
+ it "returns a new NumberAnswer object" do
+ step = create(:step, :number)
+ result = described_class.new(step: step).call
+ expect(result).to be_kind_of(NumberAnswer)
+ end
+ end
+
+ context "when the step is for currency" do
+ it "returns a new CurrencyAnswer object" do
+ step = create(:step, :currency)
+ result = described_class.new(step: step).call
+ expect(result).to be_kind_of(CurrencyAnswer)
+ end
+ end
end
diff --git a/spec/services/build_journey_order_spec.rb b/spec/services/build_journey_order_spec.rb
deleted file mode 100644
index e00600fb3..000000000
--- a/spec/services/build_journey_order_spec.rb
+++ /dev/null
@@ -1,110 +0,0 @@
-require "rails_helper"
-
-RSpec.describe BuildJourneyOrder do
- describe "#call" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) do
- example.run
- end
- end
-
- context "when the journey includes a node which doesn't exist" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "fake-id"
- ) 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 specified Contentful entry was not found",
- contentful_url: ENV["CONTENTFUL_URL"],
- contentful_space_id: ENV["CONTENTFUL_SPACE"],
- contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
- contentful_entry_id: "fake-id")
- .and_call_original
-
- expect {
- described_class.new(
- entries: fake_entries,
- starting_entry_id: "fake-id"
- ).call
- }.to raise_error(BuildJourneyOrder::MissingEntryDetected)
- end
- end
-
- context "when the journey visits the same node twice" do
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
- ) 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: "contentful-starting-step")
- .and_call_original
-
- expect {
- described_class.new(
- entries: fake_entries,
- starting_entry_id: "contentful-starting-step"
- ).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: "contentful-starting-step"
- ) 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: "contentful-radio-question")
- .and_call_original
-
- expect {
- described_class.new(
- entries: fake_entries,
- starting_entry_id: "contentful-starting-step"
- ).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 e8c473adc..7a6c20ad3 100644
--- a/spec/services/cache_spec.rb
+++ b/spec/services/cache_spec.rb
@@ -85,6 +85,39 @@
end
end
+ describe ".delete" do
+ context "when the key exists" do
+ it "removes the key from redis and returns nil" do
+ redis = RedisCache.redis
+ allow(RedisCache).to receive(:redis).and_return(redis)
+
+ target_key = "a-key:that-exists"
+ redis.set(target_key, "a-dummy-value")
+
+ expect(redis).to receive(:del).with(target_key).and_call_original
+
+ described_class.delete(key: target_key)
+
+ expect(redis.get(target_key)).to eq(nil)
+ end
+ end
+
+ context "when the key doesn't already exist" do
+ it "returns nil" do
+ redis = RedisCache.redis
+ allow(RedisCache).to receive(:redis).and_return(redis)
+
+ target_key = "a-key:that-does-not-exist"
+
+ expect(redis).to receive(:del).with(target_key).and_call_original
+
+ described_class.delete(key: target_key)
+
+ expect(redis.get(target_key)).to eq(nil)
+ 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)
diff --git a/spec/services/create_journey_spec.rb b/spec/services/create_journey_spec.rb
index 7e1259b02..4fda6cdaf 100644
--- a/spec/services/create_journey_spec.rb
+++ b/spec/services/create_journey_spec.rb
@@ -3,7 +3,7 @@
RSpec.describe CreateJourney do
around do |example|
ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: "contentful-starting-step"
+ CONTENTFUL_DEFAULT_CATEGORY_ENTRY_ID: "contentful-category-entry"
) do
example.run
end
@@ -11,30 +11,79 @@
describe "#call" do
it "creates a new journey" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
+ stub_contentful_category(
+ fixture_filename: "category-with-no-steps.json",
+ stub_steps: false
)
- expect { described_class.new(category: "catering").call }
+ expect { described_class.new(category_name: "catering", user: build(:user)).call }
.to change { Journey.count }.by(1)
expect(Journey.last.category).to eql("catering")
end
+ it "associates the new journey with the given user" do
+ stub_contentful_category(
+ fixture_filename: "category-with-no-steps.json",
+ stub_steps: false
+ )
+ user = create(:user)
+
+ described_class.new(category_name: "catering", user: user).call
+
+ expect(Journey.last.user).to eq(user)
+ end
+
it "stores a copy of the Liquid template" do
- stub_get_contentful_entries(
- entry_id: "contentful-starting-step",
- fixture_filename: "closed-path-with-multiple-example.json"
+ stub_contentful_category(
+ fixture_filename: "category-with-liquid-template.json"
)
- fake_liquid_template = File.read("#{Rails.root}/spec/fixtures/specification_templates/basic_catering.liquid")
- finder = instance_double(FindLiquidTemplate)
- allow(FindLiquidTemplate).to receive(:new).with(category: "catering")
- .and_return(finder)
- allow(finder).to receive(:call).and_return(fake_liquid_template)
- described_class.new(category: "catering").call
+ described_class.new(category_name: "catering", user: build(:user)).call
expect(Journey.last.liquid_template)
- .to eql("\n {% if answer_contentful-starting-step %}\n \n
I'm the first article and should be seen
\n \n {% endif %}\n\n")
+ .to eql("
Liquid {{templating}}
")
+ end
+
+ it "stores the section grouping on the journey in the expected order" do
+ stub_contentful_category(
+ fixture_filename: "multiple-sections-and-steps.json"
+ )
+
+ described_class.new(category_name: "catering", user: build(:user)).call
+
+ journey = Journey.last
+
+ expect(journey.reload.section_groups).to eq([
+ {
+ "order" => 0,
+ "title" => "Section A",
+ "steps" => [
+ {"contentful_id" => "radio-question", "order" => 0},
+ {"contentful_id" => "single-date-question", "order" => 1}
+ ]
+ },
+ {
+ "order" => 1,
+ "title" => "Section B",
+ "steps" => [
+ {"contentful_id" => "long-text-question", "order" => 0},
+ {"contentful_id" => "short-text-question", "order" => 1}
+ ]
+ }
+ ])
+ end
+
+ context "when the journey cannot be saved" do
+ it "raises an error" do
+ stub_contentful_category(
+ fixture_filename: "category-with-liquid-template.json",
+ stub_sections: true,
+ stub_steps: false
+ )
+
+ # Force a validation error by not providing a category_name
+ expect { described_class.new(category_name: nil, user: build(:user)).call }
+ .to raise_error(ActiveRecord::RecordInvalid)
+ end
end
end
end
diff --git a/spec/services/create_journey_step_spec.rb b/spec/services/create_journey_step_spec.rb
index f6198446e..b13bbe57f 100644
--- a/spec/services/create_journey_step_spec.rb
+++ b/spec/services/create_journey_step_spec.rb
@@ -5,25 +5,28 @@
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_entry(
- contentful_fixture_filename: "radio-question-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/radio-question.json"
)
step = described_class.new(journey: journey, contentful_entry: fake_entry).call
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("contentful-radio-question")
+ expect(step.contentful_id).to eq("radio-question")
expect(step.contentful_model).to eq("question")
expect(step.contentful_type).to eq("radios")
expect(step.options).to eq([{"value" => "Catering"}, {"value" => "Cleaning"}])
+ expect(step.hidden).to eq(false)
+ expect(step.additional_step_rules).to eq(nil)
expect(step.raw).to eq(
"fields" => {
"helpText" => "Tell us which service you need.",
"extendedOptions" => [{"value" => "Catering"}, {"value" => "Cleaning"}],
"slug" => "/which-service",
"title" => "Which service do you need?",
- "type" => "radios"
+ "type" => "radios",
+ "alwaysShowTheUser" => true
},
"sys" => {
"contentType" => {
@@ -41,7 +44,7 @@
"type" => "Link"
}
},
- "id" => "contentful-radio-question",
+ "id" => "radio-question",
"locale" => "en-US",
"revision" => 7,
"space" => {
@@ -56,24 +59,13 @@
}
)
end
-
- it "updates the journey with a new next_entry_id" do
- journey = create(:journey, :catering)
- fake_entry = fake_contentful_entry(
- contentful_fixture_filename: "has-next-question-example.json"
- )
-
- _step = described_class.new(journey: journey, contentful_entry: fake_entry).call
-
- expect(journey.next_entry_id).to eql("5lYcZs1ootDrOnk09LDLZg")
- end
end
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_entry(
- contentful_fixture_filename: "short-text-question-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/short-text-question.json"
)
step = described_class.new(journey: journey, contentful_entry: fake_entry).call
@@ -83,8 +75,8 @@
it "replaces spaces with underscores" do
journey = create(:journey, :catering)
- fake_entry = fake_contentful_entry(
- contentful_fixture_filename: "short-text-question-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/short-text-question.json"
)
step = described_class.new(journey: journey, contentful_entry: fake_entry).call
@@ -93,24 +85,11 @@
end
end
- 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_entry(
- contentful_fixture_filename: "radio-question-example.json"
- )
-
- _step = described_class.new(journey: journey, contentful_entry: fake_entry).call
-
- expect(journey.next_entry_id).to eql(nil)
- end
- end
-
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_entry(
- contentful_fixture_filename: "static-content-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/static-content.json"
)
step, _answer = described_class.new(
@@ -127,8 +106,8 @@
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"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/primary-button.json"
)
step, _answer = described_class.new(
@@ -142,8 +121,8 @@
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"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/no-primary-button.json"
)
step, _answer = described_class.new(
@@ -154,11 +133,61 @@
end
end
+ context "when no 'skipCallToAction' is provided" do
+ it "default copy is used for the button" do
+ journey = create(:journey, :catering)
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/skippable-checkboxes-question.json"
+ )
+
+ step, _answer = described_class.new(
+ journey: journey, contentful_entry: fake_entry
+ ).call
+
+ expect(step.skip_call_to_action_text).to eq("None of the above")
+ end
+ end
+
+ context "when no 'alwaysShowTheUser' is provided" do
+ it "default hidden to true" do
+ journey = create(:journey, :catering)
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/no-hidden-field.json"
+ )
+
+ step, _answer = described_class.new(
+ journey: journey, contentful_entry: fake_entry
+ ).call
+
+ expect(step.hidden).to eq(false)
+ end
+ end
+
+ context "when 'showAdditionalQuestion' is provided" do
+ it "stores the rule as JSON" do
+ journey = create(:journey, :catering)
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/show-one-additional-question.json"
+ )
+
+ step, _answer = described_class.new(
+ journey: journey, contentful_entry: fake_entry
+ ).call
+
+ expect(step.additional_step_rules).to eql([
+ {
+ "required_answer" => "School expert",
+ "question_identifiers" => ["hidden-field-that-shows-an-additional-question"]
+ }
+ ])
+ 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_entry(
- contentful_fixture_filename: "an-unexpected-model-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/unexpected-contentful-type.json"
)
expect { described_class.new(journey: journey, contentful_entry: fake_entry).call }
@@ -168,8 +197,8 @@
it "raises a rollbar event" do
journey = create(:journey, :catering)
- fake_entry = fake_contentful_entry(
- contentful_fixture_filename: "an-unexpected-model-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/unexpected-contentful-type.json"
)
expect(Rollbar).to receive(:warning)
@@ -177,8 +206,8 @@
contentful_url: ENV["CONTENTFUL_URL"],
contentful_space_id: ENV["CONTENTFUL_SPACE"],
contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
- contentful_entry_id: "6EKsv389ETYcQql3htK3Z2",
- content_model: "unmanagedPage",
+ contentful_entry_id: "unexpected-contentful-type",
+ content_model: "telepathy",
step_type: "radios",
allowed_content_models: CreateJourneyStep::ALLOWED_CONTENTFUL_MODELS.join(", "),
allowed_step_types: CreateJourneyStep::ALLOWED_CONTENTFUL_ENTRY_TYPES.join(", "))
@@ -191,8 +220,8 @@
context "when the new step has an unexpected step type" do
it "raises an error" do
journey = create(:journey, :catering)
- fake_entry = fake_contentful_entry(
- contentful_fixture_filename: "an-unexpected-question-type-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/unexpected-contentful-question-type.json"
)
expect { described_class.new(journey: journey, contentful_entry: fake_entry).call }
@@ -202,8 +231,8 @@
it "raises a rollbar event" do
journey = create(:journey, :catering)
- fake_entry = fake_contentful_entry(
- contentful_fixture_filename: "an-unexpected-question-type-example.json"
+ fake_entry = fake_contentful_step(
+ contentful_fixture_filename: "steps/unexpected-contentful-question-type.json"
)
expect(Rollbar).to receive(:warning)
@@ -211,7 +240,7 @@
contentful_url: ENV["CONTENTFUL_URL"],
contentful_space_id: ENV["CONTENTFUL_SPACE"],
contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
- contentful_entry_id: "8as7df68uhasdnuasdf",
+ contentful_entry_id: "unexpected-contentful-question-type",
content_model: "question",
step_type: "telepathy",
allowed_content_models: CreateJourneyStep::ALLOWED_CONTENTFUL_MODELS.join(", "),
diff --git a/spec/services/find_liquid_template_spec.rb b/spec/services/find_liquid_template_spec.rb
deleted file mode 100644
index 135ddbb6a..000000000
--- a/spec/services/find_liquid_template_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-require "rails_helper"
-
-RSpec.describe FindLiquidTemplate do
- describe "#call" do
- context "when the Liquid contents are invalid (using the gems own parser)" do
- it "raises an error" do
- fake_liquid_template = File.read("#{Rails.root}/spec/fixtures/specification_templates/invalid.liquid")
-
- finder = described_class.new(category: "catering")
- allow(finder).to receive(:file).and_return(fake_liquid_template)
-
- expect { finder.call }.to raise_error(FindLiquidTemplate::InvalidLiquidSyntax)
- end
-
- it "sends an error to rollbar" do
- fake_liquid_template = File.read("#{Rails.root}/spec/fixtures/specification_templates/invalid.liquid")
-
- finder = described_class.new(category: "catering")
- allow(finder).to receive(:file).and_return(fake_liquid_template)
-
- expect(Rollbar).to receive(:error)
- .with("A user couldn't start a journey because of an invalid Specification",
- contentful_url: ENV["CONTENTFUL_URL"],
- contentful_space_id: ENV["CONTENTFUL_SPACE"],
- contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
- category: "catering").and_call_original
-
- expect { finder.call }.to raise_error(FindLiquidTemplate::InvalidLiquidSyntax)
- end
- end
-
- context "when in development" do
- it "loads the development liquid template" do
- category = "catering"
- expect(File).to receive(:read).with("lib/specification_templates/#{category}.development.liquid").at_least(:once)
- described_class.new(category: category, environment: "development").call
- end
- end
-
- context "when in staging" do
- it "loads the staging liquid template" do
- category = "catering"
- expect(File).to receive(:read).with("lib/specification_templates/#{category}.staging.liquid").at_least(:once)
- described_class.new(category: category, environment: "staging").call
- end
- end
-
- context "when in production" do
- it "loads the production liquid template" do
- category = "catering"
- expect(File).to receive(:read).with("lib/specification_templates/#{category}.production.liquid").at_least(:once)
- described_class.new(category: category, environment: "production").call
- end
- end
- end
-end
diff --git a/spec/services/find_or_create_user_from_session_spec.rb b/spec/services/find_or_create_user_from_session_spec.rb
new file mode 100644
index 000000000..44f9007a9
--- /dev/null
+++ b/spec/services/find_or_create_user_from_session_spec.rb
@@ -0,0 +1,31 @@
+require "rails_helper"
+
+RSpec.describe FindOrCreateUserFromSession do
+ describe "#call" do
+ context "when a user exists with this DfE Sign-in UID" do
+ it "returns the user record" do
+ user = create(:user, dfe_sign_in_uid: "03f98d51-5a93-4caa-9ff2-07faff7351d2")
+ session_hash = {"dfe_sign_in_uid" => "03f98d51-5a93-4caa-9ff2-07faff7351d2"}
+ result = described_class.new(session_hash: session_hash).call
+ expect(result).to eq(user)
+ end
+ end
+
+ context "when a user doesn't exist with this DfE Sign-in UID" do
+ it "creates a new user record" do
+ session_hash = {"dfe_sign_in_uid" => "an-unknown-uuid"}
+ result = described_class.new(session_hash: session_hash).call
+ user = User.find_by(dfe_sign_in_uid: "an-unknown-uuid")
+ expect(result).to eq(user)
+ end
+ end
+
+ context "when the session_hash does not include an expected key" do
+ it "returns nil" do
+ session_hash = {"unexpected-key" => "03f98d51-5a93-4caa-9ff2-07faff7351d2"}
+ result = described_class.new(session_hash: session_hash).call
+ expect(result).to eq(nil)
+ end
+ end
+ end
+end
diff --git a/spec/services/get_answers_for_steps_spec.rb b/spec/services/get_answers_for_steps_spec.rb
new file mode 100644
index 000000000..096b61533
--- /dev/null
+++ b/spec/services/get_answers_for_steps_spec.rb
@@ -0,0 +1,209 @@
+require "rails_helper"
+
+RSpec.describe GetAnswersForSteps do
+ describe "#call" do
+ it "only returns answers for the given journey" do
+ relevant_answer = create(:short_text_answer, response: "Red")
+ irrelevant_answer = create(:short_text_answer, response: "Blue")
+
+ result = described_class.new(visible_steps: [relevant_answer.step]).call
+
+ expect(result).to be_a(Hash)
+ expect(result).to include(
+ {"answer_#{relevant_answer.step.contentful_id}" => {response: "Red"}}
+ )
+ expect(result).not_to include(
+ {"answer_#{irrelevant_answer.step.contentful_id}" => {response: "Blue"}}
+ )
+ end
+
+ it "returns a hash with_indifferent_access so Liquid can use the string syntax for access" do
+ answer = create(:short_text_answer, response: "Red")
+ result = described_class.new(visible_steps: [answer.step]).call
+ expect(result).to include({"answer_#{answer.step.contentful_id}" => {"response" => "Red"}})
+ end
+
+ context "when the answer is of type short_text_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:short_text_answer, response: "Red")
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "Red"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type long_text_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:long_text_answer, response: "Red\r\n\r\n\r\nBlue")
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "
Red
\n\n
Blue
"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type single_date_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:single_date_answer, response: Date.new(2000, 12, 30))
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "30 Dec 2000"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type radio_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:radio_answer,
+ response: "Yes please",
+ further_information: {yes_please_further_information: "More yes info"})
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "Yes please",
+ further_information: "More yes info"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type currency_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:currency_answer, response: 100.01)
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "£100.01"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type number_answer" do
+ it "returns the answer information in a hash" do
+ answer = create(:number_answer, response: 2)
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: "2"
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+
+ context "when the answer is of type checkbox_answers" do
+ it "returns the answer information in a hash" do
+ answer = create(:checkbox_answers,
+ response: ["Foo", "Bar"],
+ skipped: false,
+ further_information: {"foo_further_information" => "More yes info"})
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: ["Foo", "Bar"],
+ concatenated_response: "Foo, Bar",
+ skipped: false,
+ selected_answers: [
+ {
+ machine_value: :foo,
+ human_value: "Foo",
+ further_information: "More yes info"
+ },
+ {
+ machine_value: :bar,
+ human_value: "Bar",
+ further_information: nil
+ }
+ ]
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+
+ context "when there is no further_information at all" do
+ it "returns no further_information in selected_answers" do
+ answer = create(:checkbox_answers,
+ response: ["Foo"],
+ skipped: false,
+ further_information: nil)
+
+ result = described_class.new(visible_steps: [answer.step]).call
+ assertion = {
+ "answer_#{answer.step.contentful_id}" => {
+ response: ["Foo"],
+ concatenated_response: "Foo",
+ skipped: false,
+ selected_answers: [
+ {
+ machine_value: :foo,
+ human_value: "Foo",
+ further_information: nil
+ }
+ ]
+ }
+ }
+
+ expect(result).to match(a_hash_including(assertion))
+ end
+ end
+ end
+
+ context "when a step does not have an answer" do
+ it "the step does not have an answer in the result" do
+ unanswered_step = create(:step, :radio)
+ result = described_class.new(visible_steps: [unanswered_step]).call
+ expect(result.keys).not_to include("answer_#{unanswered_step.contentful_id}")
+ end
+ end
+
+ context "when the type of answer is unknown" do
+ it "raises an unexpected error" do
+ allow_any_instance_of(Step).to receive(:answer)
+ .and_return(double(class: double(name: "UnknownClass")))
+ step = create(:step, :radio)
+ expect {
+ described_class.new(visible_steps: [step]).call
+ }.to raise_error(GetAnswersForSteps::UnexpectedAnswer)
+ end
+ end
+
+ context "when there is no answer for a step" do
+ it "does not try to prepare that answer in the result" do
+ journey = create(:journey)
+ answerable_step = create(:step, :radio, journey: journey)
+ _answer = create(:radio_answer, step: answerable_step)
+ unanswerable_step = create(:step, :radio, journey: journey)
+
+ result = described_class.new(visible_steps: [answerable_step]).call
+
+ expect(result.keys).to include("answer_#{answerable_step.contentful_id}")
+ expect(result.keys).not_to include("answer_#{unanswerable_step.contentful_id}")
+ end
+ end
+ end
+end
diff --git a/spec/services/get_category_spec.rb b/spec/services/get_category_spec.rb
new file mode 100644
index 000000000..d63dce078
--- /dev/null
+++ b/spec/services/get_category_spec.rb
@@ -0,0 +1,63 @@
+require "rails_helper"
+
+RSpec.describe GetCategory do
+ describe "#call" do
+ it "returns a Contenetful::Entry for the category_entry_id" do
+ stub_contentful_category(
+ fixture_filename: "static-content.json",
+ stub_sections: false
+ )
+ result = described_class.new(category_entry_id: "contentful-category-entry").call
+ expect(result.id).to eql("contentful-category-entry")
+ end
+
+ context "when the category entry cannot be found" do
+ it "sends a message to rollbar" do
+ contentful_connector = instance_double(ContentfulConnector)
+ expect(ContentfulConnector).to receive(:new)
+ .and_return(contentful_connector)
+
+ allow(contentful_connector).to receive(:get_entry_by_id)
+ .with(anything)
+ .and_return(nil)
+
+ expect(Rollbar).to receive(:error)
+ .with("A Contentful category entry was not found",
+ contentful_url: ENV["CONTENTFUL_URL"],
+ contentful_space_id: ENV["CONTENTFUL_SPACE"],
+ contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
+ contentful_entry_id: "a-category-id-that-does-not-exist")
+ .and_call_original
+
+ expect {
+ described_class.new(category_entry_id: "a-category-id-that-does-not-exist").call
+ }.to raise_error(GetEntry::EntryNotFound)
+ end
+ end
+
+ context "when the Liquid contents are invalid (using the gems own parser)" do
+ it "raises an error" do
+ stub_contentful_category(fixture_filename: "category-with-invalid-liquid-template.json")
+
+ expect {
+ described_class.new(category_entry_id: "contentful-category-entry").call
+ }.to raise_error(GetCategory::InvalidLiquidSyntax)
+ end
+
+ it "sends an error to rollbar" do
+ stub_contentful_category(fixture_filename: "category-with-invalid-liquid-template.json")
+
+ expect(Rollbar).to receive(:error)
+ .with("A user couldn't start a journey because of an invalid Specification",
+ contentful_url: ENV["CONTENTFUL_URL"],
+ contentful_space_id: ENV["CONTENTFUL_SPACE"],
+ contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
+ contentful_entry_id: "contentful-category-entry").and_call_original
+
+ expect {
+ described_class.new(category_entry_id: "contentful-category-entry").call
+ }.to raise_error(GetCategory::InvalidLiquidSyntax)
+ end
+ end
+ end
+end
diff --git a/spec/services/get_contentful_entry_spec.rb b/spec/services/get_entry_spec.rb
similarity index 84%
rename from spec/services/get_contentful_entry_spec.rb
rename to spec/services/get_entry_spec.rb
index 4aeaa4729..5e4a796ce 100644
--- a/spec/services/get_contentful_entry_spec.rb
+++ b/spec/services/get_entry_spec.rb
@@ -1,16 +1,8 @@
require "rails_helper"
-RSpec.describe GetContentfulEntry do
+RSpec.describe GetEntry do
let(:contentful_journey_start_entry_id) { "1a2b3c4d5" }
- around do |example|
- ClimateControl.modify(
- CONTENTFUL_PLANNING_START_ENTRY_ID: contentful_journey_start_entry_id
- ) do
- example.run
- end
- end
-
describe "#call" do
it "requests and returns the required entry from Contentful" do
contentful_connector = instance_double(ContentfulConnector)
@@ -37,7 +29,7 @@
.and_return(nil)
expect { described_class.new(entry_id: missing_entry_id).call }
- .to raise_error(GetContentfulEntry::EntryNotFound)
+ .to raise_error(GetEntry::EntryNotFound)
end
it "raises a rollbar event" do
@@ -55,7 +47,7 @@
contentful_entry_id: "123")
.and_call_original
expect { described_class.new(entry_id: "123").call }
- .to raise_error(GetContentfulEntry::EntryNotFound)
+ .to raise_error(GetEntry::EntryNotFound)
end
end
end
diff --git a/spec/services/get_sections_from_category_spec.rb b/spec/services/get_sections_from_category_spec.rb
new file mode 100644
index 000000000..33c602b4d
--- /dev/null
+++ b/spec/services/get_sections_from_category_spec.rb
@@ -0,0 +1,16 @@
+require "rails_helper"
+
+RSpec.describe GetSectionsFromCategory do
+ describe "#call" do
+ it "returns an array of sections" do
+ category = stub_contentful_category(
+ fixture_filename: "radio-question.json",
+ stub_steps: false
+ )
+
+ result = described_class.new(category: category).call
+
+ expect(result).to eq(category.sections)
+ end
+ end
+end
diff --git a/spec/services/get_steps_from_section_spec.rb b/spec/services/get_steps_from_section_spec.rb
new file mode 100644
index 000000000..557eb2a53
--- /dev/null
+++ b/spec/services/get_steps_from_section_spec.rb
@@ -0,0 +1,40 @@
+require "rails_helper"
+
+RSpec.describe GetStepsFromSection do
+ describe "#call" do
+ it "returns the list of entry objects referenced by the section list" do
+ section = fake_contentful_section(
+ contentful_fixture_filename: "sections/journey-with-multiple-entries-section.json"
+ )
+ stub_contentful_section_steps(sections: [section])
+
+ result = described_class.new(section: section).call
+
+ expect(result).to be_kind_of(Array)
+ # INFO: We should test this is a Contentful::Entry however it wasn't
+ # possible to create an instance_double due to an unusual way the object
+ # is constructed within the gem. Creating the object seems overly complex.
+ expect(result.first.id).to eq("radio-question")
+ end
+
+ context "when the same entry is found twice" do
+ it "returns an error message" do
+ section = fake_contentful_section(
+ contentful_fixture_filename: "sections/journey-with-repeat-entries-section.json"
+ )
+
+ expect(Rollbar).to receive(:error)
+ .with("A repeated Contentful entry was found in the same section",
+ contentful_url: ENV["CONTENTFUL_URL"],
+ contentful_space_id: ENV["CONTENTFUL_SPACE"],
+ contentful_environment: ENV["CONTENTFUL_ENVIRONMENT"],
+ contentful_entry_id: "radio-question")
+ .and_call_original
+
+ expect {
+ described_class.new(section: section).call
+ }.to raise_error(GetStepsFromSection::RepeatEntryDetected)
+ end
+ end
+ end
+end
diff --git a/spec/services/save_answer_spec.rb b/spec/services/save_answer_spec.rb
new file mode 100644
index 000000000..086e81576
--- /dev/null
+++ b/spec/services/save_answer_spec.rb
@@ -0,0 +1,72 @@
+require "rails_helper"
+
+RSpec.describe SaveAnswer do
+ describe "#call" do
+ it "updates the answer with the answer_params" do
+ answer = create(:short_text_answer)
+ params = ActionController::Parameters.new(response: "A little text").permit!
+
+ result = described_class.new(answer: answer).call(answer_params: params)
+
+ expect(result.success?).to eql(true)
+ expect(result.object.response).to eql("A little text")
+ end
+
+ it "checks to see if any other steps need to be updated" do
+ answer = create(:short_text_answer)
+
+ toggle_service = instance_double(ToggleAdditionalSteps)
+ expect(ToggleAdditionalSteps).to receive(:new).with(step: answer.step).and_return(toggle_service)
+ expect(toggle_service).to receive(:call)
+
+ described_class.new(answer: answer).call(answer_params: {})
+ end
+
+ context "when the step is a checkbox question" do
+ it "updates the answer with the checkbox_params" do
+ answer = create(:checkbox_answers)
+ params = ActionController::Parameters.new(response: ["A", "B"]).permit!
+
+ result = described_class.new(answer: answer).call(further_information_params: params)
+
+ expect(result.success?).to eql(true)
+ expect(result.object.response).to eql(["A", "B"])
+ end
+ end
+
+ context "when the step is a date question" do
+ it "updates the answer with the date_params" do
+ answer = build(:single_date_answer, response: nil)
+ date = Date.new(2000, 1, 29)
+ params = {response: date}
+
+ result = described_class.new(answer: answer).call(date_params: params)
+
+ expect(result.success?).to eql(true)
+ expect(result.object.response).to eql(date)
+ end
+ end
+
+ context "when the answer is invalid" do
+ it "does not try to save the answer" do
+ answer = create(:checkbox_answers)
+
+ allow(answer).to receive(:valid?).and_return(false)
+ expect(answer).not_to receive(:save)
+
+ described_class.new(answer: answer).call(answer_params: {})
+ end
+
+ it "returns a failed result" do
+ answer = create(:short_text_answer)
+ params = ActionController::Parameters.new(response: "A").permit!
+
+ allow(answer).to receive(:valid?).and_return(false)
+
+ result = described_class.new(answer: answer).call(answer_params: params)
+
+ expect(result.success?).to eql(false)
+ end
+ end
+ end
+end
diff --git a/spec/services/specification_renderer_spec.rb b/spec/services/specification_renderer_spec.rb
new file mode 100644
index 000000000..9c9665be3
--- /dev/null
+++ b/spec/services/specification_renderer_spec.rb
@@ -0,0 +1,45 @@
+require "rails_helper"
+
+RSpec.describe SpecificationRenderer do
+ describe "#to_html" do
+ it "renders a HTML representation of a template given specific answers" do
+ renderer = described_class.new(
+ template: '
HTML paragraph rendering a variable: "{{ variable_name }}"
HTML paragraph rendering a variable: "variable value"
')
+ end
+ end
+
+ describe "#to_document_html" do
+ context "when the journey is complete" do
+ it "renders HTML for use in rendering a docx file" do
+ renderer = described_class.new(
+ template: '
HTML paragraph rendering a variable: "{{ variable_name }}"
HTML paragraph rendering a variable: "variable value"
')
+ end
+ end
+
+ context "when the journey is NOT complete" do
+ it "renders HTML with an extra warning for use in rendering a docx file" do
+ renderer = described_class.new(
+ template: '
HTML paragraph rendering a variable: "{{ variable_name }}"