Compare commits

...

3 Commits

Author SHA1 Message Date
Oscar Richardson 669f8338c5
CLDC-4461: Hide confidential addresses - fix confidential to non confidential edge case (#3373) 1 week ago
Oscar Richardson 1520767dd1
CLDC-4461: Hide confidential addresses (#3371) 1 week ago
Rebecca Jesson f2259aee8f
CLDC-4506: prevent Sass BOM from corrupting govuk breakpoint custom properties (#3370) 1 week ago
  1. 2
      app/controllers/form_controller.rb
  2. 6
      app/models/derived_variables/lettings_log_variables.rb
  3. 6
      app/models/form/lettings/questions/address_line1.rb
  4. 6
      app/models/form/lettings/questions/address_search.rb
  5. 2
      app/models/form/lettings/questions/declaration.rb
  6. 2
      app/models/form/lettings/questions/uprn.rb
  7. 2
      app/models/form/lettings/questions/uprn_known.rb
  8. 2
      app/models/form/question.rb
  9. 2
      app/models/form/sales/questions/privacy_notice.rb
  10. 2
      app/models/form/sales/questions/uprn.rb
  11. 2
      app/models/form/sales/questions/uprn_known.rb
  12. 8
      app/models/lettings_log.rb
  13. 4
      app/models/scheme.rb
  14. 66
      app/services/bulk_upload/lettings/year2026/row_parser.rb
  15. 38
      app/services/csv/lettings_log_csv_service.rb
  16. 6
      app/views/form/guidance/_address_search.html.erb
  17. 4
      config/locales/forms/2026/lettings/guidance.en.yml
  18. 1
      config/locales/validations/lettings/2026/bulk_upload.en.yml
  19. 4
      config/locales/validations/lettings/property_information.en.yml
  20. 73
      spec/models/form/lettings/pages/address_fallback_spec.rb
  21. 73
      spec/models/form/lettings/pages/address_search_spec.rb
  22. 67
      spec/models/form/lettings/pages/property_local_authority_spec.rb
  23. 30
      spec/models/form/lettings/questions/address_line1_spec.rb
  24. 30
      spec/models/form/lettings/questions/address_search_spec.rb
  25. 141
      spec/models/lettings_log_derived_fields_spec.rb
  26. 19
      spec/models/lettings_log_spec.rb
  27. 29
      spec/requests/form/address_search_guidance_spec.rb
  28. 107
      spec/services/bulk_upload/lettings/year2026/row_parser_spec.rb
  29. 76
      spec/services/csv/lettings_log_csv_service_spec.rb
  30. 6
      webpack.config.js

2
app/controllers/form_controller.rb

@ -35,7 +35,7 @@ class FormController < ApplicationController
else
@log.valid? if mandatory_questions_with_no_response.any?
mandatory_questions_with_no_response.map do |question|
@log.errors.add question.id.to_sym, question.unanswered_error_message, category: :not_answered
@log.errors.add question.id.to_sym, question.unanswered_error_message(@log), category: :not_answered
end
error_attributes = @log.errors.map(&:attribute)
Rails.logger.info "User triggered validation(s) on: #{error_attributes.join(', ')}"

6
app/models/derived_variables/lettings_log_variables.rb

@ -181,6 +181,12 @@ module DerivedVariables::LettingsLogVariables
self.la = nil
end
if scheme_has_confidential_information?
reset_address_fields!
self.uprn_selection = nil
self.postcode_known = nil
end
clear_gender_description_unless_gender_not_same_as_sex! if form.start_year_2026_or_later?
set_checkbox_values!

6
app/models/form/lettings/questions/address_line1.rb

@ -18,5 +18,11 @@ class Form::Lettings::Questions::AddressLine1 < ::Form::Question
].select(&:present?).join("\n")
end
def unanswered_error_message(log = nil)
return super unless log&.is_supported_housing?
I18n.t("validations.lettings.property.address_line1.not_answered_supported_housing")
end
QUESTION_NUMBER_FROM_YEAR = { 2023 => 12, 2024 => 13, 2025 => 17, 2026 => 17 }.freeze
end

6
app/models/form/lettings/questions/address_search.rb

@ -38,5 +38,11 @@ class Form::Lettings::Questions::AddressSearch < ::Form::Question
answer_options(log, user).transform_values { |value| value["value"] } || {}
end
def unanswered_error_message(log = nil)
return super unless log&.is_supported_housing?
I18n.t("validations.lettings.property.address.not_answered_supported_housing")
end
QUESTION_NUMBER_FROM_YEAR = { 2024 => 12, 2025 => 16, 2026 => 16 }.freeze
end

2
app/models/form/lettings/questions/declaration.rb

@ -18,7 +18,7 @@ class Form::Lettings::Questions::Declaration < ::Form::Question
{ "declaration" => { "value" => declaration_text } }.freeze
end
def unanswered_error_message
def unanswered_error_message(_log = nil)
if form.start_year_2024_or_later?
I18n.t("validations.declaration.missing.post_2024")
else

2
app/models/form/lettings/questions/uprn.rb

@ -14,7 +14,7 @@ class Form::Lettings::Questions::Uprn < ::Form::Question
]
end
def unanswered_error_message
def unanswered_error_message(_log = nil)
I18n.t("validations.lettings.property.uprn.invalid")
end

2
app/models/form/lettings/questions/uprn_known.rb

@ -26,7 +26,7 @@ class Form::Lettings::Questions::UprnKnown < ::Form::Question
"0" => { "value" => "No" },
}.freeze
def unanswered_error_message
def unanswered_error_message(_log = nil)
I18n.t("validations.property.uprn_known.invalid")
end

2
app/models/form/question.rb

@ -240,7 +240,7 @@ class Form::Question
format_ending(label)
end
def unanswered_error_message
def unanswered_error_message(_log = nil)
question_text = error_display_label.presence || "this question."
I18n.t("validations.not_answered", question: question_text.downcase)
end

2
app/models/form/sales/questions/privacy_notice.rb

@ -19,7 +19,7 @@ class Form::Sales::Questions::PrivacyNotice < ::Form::Question
{ "privacynotice" => { "value" => declaration_text } }.freeze
end
def unanswered_error_message
def unanswered_error_message(_log = nil)
buyer_or_buyers = @joint_purchase ? "buyers" : "buyer"
if form.start_year_2024_or_later?
I18n.t("validations.privacynotice.missing.post_2024", buyer_or_buyers:)

2
app/models/form/sales/questions/uprn.rb

@ -14,7 +14,7 @@ class Form::Sales::Questions::Uprn < ::Form::Question
]
end
def unanswered_error_message
def unanswered_error_message(_log = nil)
I18n.t("validations.sales.property_information.uprn.invalid")
end

2
app/models/form/sales/questions/uprn_known.rb

@ -26,7 +26,7 @@ class Form::Sales::Questions::UprnKnown < ::Form::Question
"0" => { "value" => "No" },
}.freeze
def unanswered_error_message
def unanswered_error_message(_log = nil)
I18n.t("validations.sales.property_information.uprn_known.invalid")
end

8
app/models/lettings_log.rb

@ -822,7 +822,13 @@ class LettingsLog < Log
end
def is_address_asked?
form.start_year_2026_or_later? || !is_supported_housing?
return false if scheme_has_confidential_information?
form.start_year_2026_or_later? || is_general_needs?
end
def scheme_has_confidential_information?
!!scheme&.has_confidential_information?
end
def referral_is_from_local_authority_housing_register?

4
app/models/scheme.rb

@ -367,6 +367,10 @@ class Scheme < ApplicationRecord
status_at(6.months.from_now) == :deactivating_soon
end
def has_confidential_information?
sensitive == "Yes"
end
def discard!
update!(discarded_at: Time.zone.now)
locations.each(&:discard!)

66
app/services/bulk_upload/lettings/year2026/row_parser.rb

@ -493,8 +493,8 @@ class BulkUpload::Lettings::Year2026::RowParser
validate :validate_assigned_to_when_support, on: :after_log
validate :validate_all_charges_given, on: :after_log
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log
validate :validate_address_fields, on: :after_log
validate :validate_uprn_exists_if_any_key_address_fields_are_blank, on: :after_log, unless: :scheme_has_confidential_information?
validate :validate_address_fields, on: :after_log, unless: :scheme_has_confidential_information?
validate :validate_nationality, on: :after_log
validate :validate_reasonpref_reason_values, on: :after_log
@ -688,9 +688,9 @@ private
def validate_uprn_exists_if_any_key_address_fields_are_blank
if field_18.blank? && !key_address_fields_provided?
%i[field_19 field_21 field_23 field_24].each do |field|
errors.add(field, I18n.t("#{ERROR_BASE_KEY}.address.not_answered")) if send(field).blank?
errors.add(field, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.address.not_answered"))) if send(field).blank?
end
errors.add(:field_18, I18n.t("#{ERROR_BASE_KEY}.address.not_answered", question: "UPRN."))
errors.add(:field_18, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.address.not_answered", question: "UPRN.")))
end
end
@ -701,23 +701,27 @@ private
def validate_address_fields
if field_18.blank? || log.errors.attribute_names.include?(:uprn)
if field_19.blank? && errors[:field_19].blank?
errors.add(:field_19, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "address line 1."))
errors.add(:field_19, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "address line 1.")))
end
if field_21.blank? && errors[:field_21].blank?
errors.add(:field_21, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "town or city."))
errors.add(:field_21, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "town or city.")))
end
if field_23.blank? && errors[:field_23].blank?
errors.add(:field_23, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 1 of postcode."))
errors.add(:field_23, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 1 of postcode.")))
end
if field_24.blank? && errors[:field_24].blank?
errors.add(:field_24, I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 2 of postcode."))
errors.add(:field_24, with_confidential_scheme_suffix(I18n.t("#{ERROR_BASE_KEY}.not_answered", question: "part 2 of postcode.")))
end
end
end
def with_confidential_scheme_suffix(message)
"#{message} #{I18n.t("#{ERROR_BASE_KEY}.address.confidential_scheme_suffix")}"
end
def validate_incomplete_soft_validations
routed_to_soft_validation_questions = log.form.questions.filter { |q| q.type == "interruption_screen" && q.page.routed_to?(log, nil) }.compact
routed_to_soft_validation_questions.each do |question|
@ -1491,26 +1495,28 @@ private
attributes["first_time_property_let_as_social_housing"] = first_time_property_let_as_social_housing
attributes["uprn_known"] = field_18.present? ? 1 : 0
attributes["uprn_confirmed"] = 1 if field_18.present?
attributes["skip_update_uprn_confirmed"] = true
attributes["uprn"] = field_18
attributes["address_line1"] = field_19
attributes["address_line1_as_entered"] = field_19
attributes["address_line2"] = field_20
attributes["address_line2_as_entered"] = field_20
attributes["town_or_city"] = field_21
attributes["town_or_city_as_entered"] = field_21
attributes["county"] = field_22
attributes["county_as_entered"] = field_22
attributes["postcode_full"] = postcode_full
attributes["postcode_full_as_entered"] = postcode_full
attributes["postcode_known"] = postcode_known
attributes["la"] = field_25
attributes["la_as_entered"] = field_25
attributes["address_line1_input"] = address_line1_input
attributes["postcode_full_input"] = postcode_full
attributes["select_best_address_match"] = true if field_18.blank?
unless scheme_has_confidential_information?
attributes["uprn_known"] = field_18.present? ? 1 : 0
attributes["uprn_confirmed"] = 1 if field_18.present?
attributes["skip_update_uprn_confirmed"] = true
attributes["uprn"] = field_18
attributes["address_line1"] = field_19
attributes["address_line1_as_entered"] = field_19
attributes["address_line2"] = field_20
attributes["address_line2_as_entered"] = field_20
attributes["town_or_city"] = field_21
attributes["town_or_city_as_entered"] = field_21
attributes["county"] = field_22
attributes["county_as_entered"] = field_22
attributes["postcode_full"] = postcode_full
attributes["postcode_full_as_entered"] = postcode_full
attributes["postcode_known"] = postcode_known
attributes["la"] = field_25
attributes["la_as_entered"] = field_25
attributes["address_line1_input"] = address_line1_input
attributes["postcode_full_input"] = postcode_full
attributes["select_best_address_match"] = true if field_18.blank?
end
attributes["gender_same_as_sex1"] = field_43
attributes["gender_description1"] = field_44
@ -1577,6 +1583,10 @@ private
@scheme ||= Scheme.where(id: (owning_organisation.owned_schemes + managing_organisation.owned_schemes).map(&:id)).find_by_id_on_multiple_fields(field_5.strip, field_6)
end
def scheme_has_confidential_information?
!!scheme&.has_confidential_information?
end
def location
return if scheme.nil?

38
app/services/csv/lettings_log_csv_service.rb

@ -280,6 +280,32 @@ module Csv
SCHEME_AND_LOCATION_ATTRIBUTES = %w[scheme_code scheme_service_name scheme_confidential SCHTYPE scheme_registered_under_care_act scheme_owning_organisation_name scheme_primary_client_group scheme_has_other_client_group scheme_secondary_client_group scheme_support_type scheme_intended_stay scheme_created_at location_code location_postcode location_name location_units location_type_of_unit location_mobility_type location_local_authority location_startdate].freeze
# TODO: (CLDC-4462): delete once address data for logs in confidential schemes is wiped.
# Interim measure: logs in a confidential scheme that were created before the
# confidential address feature may still hold property address/UPRN data that is no
# longer collected. Blank those columns in the download until the data is wiped (the
# local authority, derived from the scheme location, is intentionally retained).
ADDRESS_FIELDS_HIDDEN_FOR_CONFIDENTIAL_SCHEME = %w[
uprn
uprn_known
uprn_confirmed
uprn_selection
address_line1
address_line2
town_or_city
county
postcode_full
postcode_known
address_line1_input
postcode_full_input
address_line1_as_entered
address_line2_as_entered
town_or_city_as_entered
county_as_entered
postcode_full_as_entered
la_as_entered
].freeze
def lettings_log_attributes
ordered_questions = FormHandler.instance.ordered_questions_for_year(@year, "lettings")
soft_validations_attributes = soft_validations_attributes(ordered_questions)
@ -344,6 +370,8 @@ module Csv
def value(attribute, log)
attribute = "rent_type" if attribute == "rent_type_detail" # rent_type_detail is the requested column header for rent_type, so as not to confuse with renttype. It can be exported as label or code.
return nil if hide_confidential_scheme_address?(attribute, log) # TODO: (CLDC-4462): delete once address data for logs in confidential schemes is wiped.
if CUSTOM_CALL_CHAINS.key? attribute.to_sym
call_chain = CUSTOM_CALL_CHAINS[attribute.to_sym][@export_type.to_sym]
call_chain.reduce(log) { |object, next_call| object&.public_send(next_call) }
@ -376,6 +404,16 @@ module Csv
end
end
# TODO: (CLDC-4462): delete once address data for logs in confidential schemes is wiped.
def hide_confidential_scheme_address?(attribute, log)
ADDRESS_FIELDS_HIDDEN_FOR_CONFIDENTIAL_SCHEME.include?(attribute) && confidential_scheme_ids.include?(log.scheme_id)
end
# TODO: (CLDC-4462): delete once address data for logs in confidential schemes is wiped.
def confidential_scheme_ids
@confidential_scheme_ids ||= Scheme.where(sensitive: "Yes").pluck(:id).to_set
end
def person_details_not_known?(log, attribute)
details_known_field = PERSON_DETAILS.find { |key, _value| key == attribute }[1]["details_known_field"]
log[details_known_field] == 1 # 1 for lettings logs, 2 for sales logs

6
app/views/form/guidance/_address_search.html.erb

@ -6,6 +6,12 @@
<%= I18n.t("forms.#{@log.form.start_date.year}.#{@log.form.type}.guidance.address_uprn.content").html_safe %>
<% end %>
<% if @log.form.type == "lettings" && @log.form.start_year_2026_or_later? %>
<%= govuk_details(summary_text: I18n.t("forms.#{@log.form.start_date.year}.#{@log.form.type}.guidance.confidential_supported_lettings.title")) do %>
<%= I18n.t("forms.#{@log.form.start_date.year}.#{@log.form.type}.guidance.confidential_supported_lettings.content").html_safe %>
<% end %>
<% end %>
<div class="govuk-button-group">
<%= govuk_link_to "Enter the address manually instead", address_manual_input_path(@log.log_type, @log.id), class: "govuk-button govuk-button--secondary" %>
</div>

4
config/locales/forms/2026/lettings/guidance.en.yml

@ -73,6 +73,10 @@ en:
content: "<p>The Unique Property Reference Number (UPRN) is a unique number system created by Ordnance Survey and used by housing providers and various industries across the UK. An example is 0010457355.</p>
<p>The UPRN may not be the same as the property reference assigned by your organisation.</p>"
confidential_supported_lettings:
title: "What should I do for confidential supported lettings?"
content: "Full address or UPRN is not required for confidential supported lettings. You are seeing this question because the scheme you chose in the ‘Set up this lettings log’ section does not have the ‘Confidential information’ box ticked. Please check the scheme you chose. If a scheme needs updating to mark it as confidential, a CORE coordinator in your organisation can do this. If you or they need further assistance contact the helpdesk."
needs_type:
title: "What does each need type mean?"
content: "General needs housing includes both self-contained and shared housing without support or specific adaptations.<br><br>Supported housing is housing with special design facilities or features targeted at a specific client group requiring support, for example housing designed for older people, sheltered accommodation, extra care housing. It can include direct access hostels, group homes, and purpose-built self-contained housing. We do not require CORE logs for residential care or nursing homes."

1
config/locales/validations/lettings/2026/bulk_upload.en.yml

@ -53,6 +53,7 @@ en:
one: "There is a possible match for this address which doesn't look right. Check the address data in your CSV file is correct and complete, or confirm the address in the service."
multiple: "There are multiple matches for this address. Check the address data in your CSV file is correct and complete, or select the correct address in the service."
not_answered: "Enter either the UPRN or the full address."
confidential_scheme_suffix: "If your letting is in a confidential scheme, check the scheme you chose. A coordinator can edit a scheme to confidential on the ‘Schemes' page."
nationality:
invalid: "Select a valid nationality."
charges:

4
config/locales/validations/lettings/property_information.en.yml

@ -2,6 +2,10 @@ en:
validations:
lettings:
property:
address:
not_answered_supported_housing: "You must enter address. If your letting is in a confidential scheme, please check the scheme you chose in the ‘Set up this lettings log’ section. If a scheme needs updating to mark it as confidential, a CORE coordinator in your organisation can do this."
address_line1:
not_answered_supported_housing: "You must enter address line 1. If your letting is in a confidential scheme, please check the scheme you chose in the ‘Set up this lettings log’ section. If a scheme needs updating to mark it as confidential, a CORE coordinator in your organisation can do this."
postcode_full:
invalid: "Enter a postcode in the correct format, for example AA1 1AA."
not_in_england: "It looks like you have an entered a postcode outside of England. Only create logs for lettings in England."

73
spec/models/form/lettings/pages/address_fallback_spec.rb

@ -26,4 +26,77 @@ RSpec.describe Form::Lettings::Pages::AddressFallback, type: :model do
it "has correct depends_on" do
expect(page.depends_on).to eq([{ "manual_address_entry_selected" => true, "is_address_asked?" => true }])
end
context "when routing to the page" do
let(:form) { FormHandler.instance.forms["current_lettings"] }
let(:subsection) { instance_double(Form::Subsection, form:, enabled?: true) }
context "when the log is general needs" do
let(:log) { build(:lettings_log, needstype: 1) }
it "is routed to when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).to be_routed_to(log, nil)
end
it "is not routed to when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when `manual_address_entry_selected` is nil" do
log.manual_address_entry_selected = nil
expect(page).not_to be_routed_to(log, nil)
end
end
context "when the log is supported housing" do
let(:log) { build(:lettings_log, needstype: 2) }
context "and the collection year is 2026 or later" do
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(true)
end
it "is routed to when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).to be_routed_to(log, nil)
end
it "is not routed to when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when `manual_address_entry_selected` is nil" do
log.manual_address_entry_selected = nil
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the collection year is before 2026" do
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(false)
end
it "is not routed to, even when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).not_to be_routed_to(log, nil)
end
end
context "when the scheme has confidential information" do
let(:log) { build(:lettings_log, needstype: 2, scheme: build(:scheme, sensitive: 1)) }
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(true)
end
it "is not routed to, even when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).not_to be_routed_to(log, nil)
end
end
end
end
end

73
spec/models/form/lettings/pages/address_search_spec.rb

@ -39,4 +39,77 @@ RSpec.describe Form::Lettings::Pages::AddressSearch, type: :model do
expect(page.question_number).to eq(16)
end
end
context "when routing to the page" do
let(:form) { FormHandler.instance.forms["current_lettings"] }
let(:subsection) { instance_double(Form::Subsection, form:, enabled?: true) }
context "when the log is general needs" do
let(:log) { build(:lettings_log, needstype: 1) }
it "is routed to when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).to be_routed_to(log, nil)
end
it "is not routed to when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when `manual_address_entry_selected` is nil" do
log.manual_address_entry_selected = nil
expect(page).not_to be_routed_to(log, nil)
end
end
context "when the log is supported housing" do
let(:log) { build(:lettings_log, needstype: 2) }
context "and the collection year is 2026 or later" do
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(true)
end
it "is routed to when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).to be_routed_to(log, nil)
end
it "is not routed to when the address is being entered manually" do
log.manual_address_entry_selected = true
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when `manual_address_entry_selected` is nil" do
log.manual_address_entry_selected = nil
expect(page).not_to be_routed_to(log, nil)
end
end
context "and the collection year is before 2026" do
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(false)
end
it "is not routed to, even when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).not_to be_routed_to(log, nil)
end
end
context "when the scheme has confidential information" do
let(:log) { build(:lettings_log, needstype: 2, scheme: build(:scheme, sensitive: 1)) }
before do
allow(form).to receive(:start_year_2026_or_later?).and_return(true)
end
it "is not routed to, even when the address is not being entered manually" do
log.manual_address_entry_selected = false
expect(page).not_to be_routed_to(log, nil)
end
end
end
end
end

67
spec/models/form/lettings/pages/property_local_authority_spec.rb

@ -33,28 +33,65 @@ RSpec.describe Form::Lettings::Pages::PropertyLocalAuthority, type: :model do
end
context "when routing to the page" do
let(:log) { build(:lettings_log) }
before do
allow(form).to receive(:start_year_2025_or_later?).and_return(true)
end
it "is routed to when la is not inferred and it is general needs log" do
log.needstype = 1
log.is_la_inferred = false
expect(page).to be_routed_to(log, nil)
end
context "when the log is general needs" do
let(:log) { build(:lettings_log, needstype: 1) }
it "is not routed to when `is_la_inferred` is nil" do
log.is_la_inferred = nil
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when la is inferred" do
log.needstype = 1
log.is_la_inferred = true
expect(page).not_to be_routed_to(log, nil)
it "is not routed to when LA is inferred" do
log.is_la_inferred = true
expect(page).not_to be_routed_to(log, nil)
end
it "is routed to when LA is not inferred" do
log.is_la_inferred = false
expect(page).to be_routed_to(log, nil)
end
end
it "is not routed to when it's a supported housing log" do
log.needstype = 2
log.is_la_inferred = false
expect(page).not_to be_routed_to(log, nil)
context "when the log is supported housing" do
let(:log) { build(:lettings_log, needstype: 2) }
it "is not routed to when `is_la_inferred` is nil" do
log.is_la_inferred = nil
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when LA is inferred" do
log.is_la_inferred = true
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to, even when LA is not inferred" do
log.is_la_inferred = false
expect(page).not_to be_routed_to(log, nil)
end
context "when the scheme has confidential information" do
let(:log) { build(:lettings_log, needstype: 2, scheme: build(:scheme, sensitive: 1)) }
it "is not routed to when `is_la_inferred` is nil" do
log.is_la_inferred = nil
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to when LA is inferred" do
log.is_la_inferred = true
expect(page).not_to be_routed_to(log, nil)
end
it "is not routed to, even when LA is not inferred" do
log.is_la_inferred = false
expect(page).not_to be_routed_to(log, nil)
end
end
end
end
end

30
spec/models/form/lettings/questions/address_line1_spec.rb

@ -38,4 +38,34 @@ RSpec.describe Form::Lettings::Questions::AddressLine1, type: :model do
it "has the correct check_answers_card_number" do
expect(question.check_answers_card_number).to be_nil
end
describe "#unanswered_error_message" do
context "when the log is supported housing" do
let(:log) { build(:lettings_log, needstype: 2) }
it "returns the confidential-scheme guidance message" do
expect(question.unanswered_error_message(log)).to eq(
"You must enter address line 1. If your letting is in a confidential scheme, please check the scheme you chose in the ‘Set up this lettings log’ section. If a scheme needs updating to mark it as confidential, a CORE coordinator in your organisation can do this.",
)
end
end
context "when the log is general needs" do
let(:log) { build(:lettings_log, needstype: 1) }
it "returns the default unanswered message" do
expect(question.unanswered_error_message(log)).to eq(
I18n.t("validations.not_answered", question: question.error_display_label.downcase),
)
end
end
context "when no log is given" do
it "returns the default unanswered message" do
expect(question.unanswered_error_message).to eq(
I18n.t("validations.not_answered", question: question.error_display_label.downcase),
)
end
end
end
end

30
spec/models/form/lettings/questions/address_search_spec.rb

@ -98,4 +98,34 @@ RSpec.describe Form::Lettings::Questions::AddressSearch, type: :model do
end
end
end
describe "#unanswered_error_message" do
context "when the log is supported housing" do
let(:log) { build(:lettings_log, needstype: 2) }
it "returns the confidential-scheme guidance message" do
expect(question.unanswered_error_message(log)).to eq(
"You must enter address. If your letting is in a confidential scheme, please check the scheme you chose in the ‘Set up this lettings log’ section. If a scheme needs updating to mark it as confidential, a CORE coordinator in your organisation can do this.",
)
end
end
context "when the log is general needs" do
let(:log) { build(:lettings_log, needstype: 1) }
it "returns the default unanswered message" do
expect(question.unanswered_error_message(log)).to eq(
I18n.t("validations.not_answered", question: question.error_display_label.downcase),
)
end
end
context "when no log is given" do
it "returns the default unanswered message" do
expect(question.unanswered_error_message).to eq(
I18n.t("validations.not_answered", question: question.error_display_label.downcase),
)
end
end
end
end

141
spec/models/lettings_log_derived_fields_spec.rb

@ -1585,6 +1585,147 @@ RSpec.describe LettingsLog, type: :model do
end
end
describe "address field behaviour for confidential schemes", metadata: { year: 26 } do
let(:startdate) { collection_start_date_for_year(2026) }
let(:uprn) { "123456789" }
let(:uprn_known) { 1 }
let(:uprn_confirmed) { 1 }
let(:address_line1) { "1 Test Street" }
let(:address_line2) { "Testville" }
let(:town_or_city) { "Testford" }
let(:county) { "Testshire" }
let(:postcode_full) { "SW1 1AA" }
let(:la) { "E07000105" }
let(:uprn_selection) { uprn }
let(:postcode_known) { 1 }
let(:manual_address_entry_selected) { true }
around do |example|
Timecop.freeze(collection_start_date_for_year(2026)) do
Singleton.__init__(FormHandler)
example.run
end
end
before do
log.needstype = 2
log.assign_attributes(uprn:, uprn_known:, uprn_confirmed:, address_line1:, address_line2:, town_or_city:, county:, postcode_full:, la:, uprn_selection:, postcode_known:, manual_address_entry_selected:)
end
context "when the scheme has confidential information" do
let(:confidential_scheme) { create(:scheme, sensitive: 1) }
let(:location) { create(:location, scheme: confidential_scheme) }
before do
log.scheme = confidential_scheme
log.location = location
end
it "does not ask the address or UPRN question" do
expect(log.is_address_asked?).to be false
end
it "resets all the address and UPRN fields to nil" do
expect { log.set_derived_fields! }
.to change { log.read_attribute(:uprn) }.from(uprn).to(nil)
.and change { log.read_attribute(:uprn_known) }.from(uprn_known).to(nil)
.and change { log.read_attribute(:uprn_confirmed) }.from(uprn_confirmed).to(nil)
.and change { log.read_attribute(:address_line1) }.from(address_line1).to(nil)
.and change { log.read_attribute(:address_line2) }.from(address_line2).to(nil)
.and change { log.read_attribute(:town_or_city) }.from(town_or_city).to(nil)
.and change { log.read_attribute(:county) }.from(county).to(nil)
.and change { log.read_attribute(:postcode_full) }.from(postcode_full).to(nil)
.and change { log.read_attribute(:uprn_selection) }.from(uprn_selection).to(nil)
.and(change { log.read_attribute(:postcode_known) }.from(postcode_known).to(nil))
end
it "does not reset `manual_address_entry_selected`" do
expect { log.set_derived_fields! }
.not_to(change { log.read_attribute(:manual_address_entry_selected) })
end
context "when the log is a new-build first let" do
before do
log.rsnvac = 15
end
it "still does not ask the address or UPRN question (confidential overrides new-build)" do
expect(log.is_address_asked?).to be false
end
it "resets all the address and UPRN fields to nil" do
expect { log.set_derived_fields! }
.to change { log.read_attribute(:uprn) }.from(uprn).to(nil)
.and change { log.read_attribute(:uprn_known) }.from(uprn_known).to(nil)
.and change { log.read_attribute(:uprn_confirmed) }.from(uprn_confirmed).to(nil)
.and change { log.read_attribute(:address_line1) }.from(address_line1).to(nil)
.and change { log.read_attribute(:address_line2) }.from(address_line2).to(nil)
.and change { log.read_attribute(:town_or_city) }.from(town_or_city).to(nil)
.and change { log.read_attribute(:county) }.from(county).to(nil)
.and change { log.read_attribute(:postcode_full) }.from(postcode_full).to(nil)
.and change { log.read_attribute(:town_or_city) }.from(town_or_city).to(nil)
.and change { log.read_attribute(:county) }.from(county).to(nil)
.and change { log.read_attribute(:postcode_full) }.from(postcode_full).to(nil)
.and change { log.read_attribute(:uprn_selection) }.from(uprn_selection).to(nil)
.and(change { log.read_attribute(:postcode_known) }.from(postcode_known).to(nil))
end
it "does not reset `manual_address_entry_selected`" do
expect { log.set_derived_fields! }
.not_to(change { log.read_attribute(:manual_address_entry_selected) })
end
end
end
context "when the scheme does not have confidential information" do
let(:non_confidential_scheme) { create(:scheme, sensitive: 0) }
let(:location) { create(:location, scheme: non_confidential_scheme) }
before do
log.scheme = non_confidential_scheme
log.location = location
end
it "ask the address or UPRN question" do
expect(log.is_address_asked?).to be true
end
it "routes a new-build property down the manual address entry route" do
log.assign_attributes(manual_address_entry_selected: false, rsnvac: 15, uprn: nil)
expect { log.set_derived_fields! }
.to change(log, :manual_address_entry_selected).from(false).to(true)
end
end
context "when a log is changed from a confidential to a non-confidential scheme" do
# The confidential logic must leave manual_address_entry_selected in a
# routable state (either true or false), otherwise neither address page routes after the
# switch and the address question is never shown again.
let(:confidential_scheme) { create(:scheme, sensitive: 1) }
let(:non_confidential_scheme) { create(:scheme, sensitive: 0) }
let(:confidential_location) { create(:location, scheme: confidential_scheme) }
let(:non_confidential_location) { create(:location, scheme: non_confidential_scheme) }
before do
log.assign_attributes(manual_address_entry_selected: true)
log.scheme = confidential_scheme
log.location = confidential_location
log.set_derived_fields!
log.scheme = non_confidential_scheme
log.location = non_confidential_location
log.set_derived_fields!
end
it "asks the address question again with a routable value for `manual_address_entry_selected` (i.e., not nil)" do
expect(log.is_address_asked?).to be true
expect(log.manual_address_entry_selected).to be true
end
end
end
describe "#infer_at_most_one_relationship!" do
context "when 2025", metadata: { year: 25 } do
before do

19
spec/models/lettings_log_spec.rb

@ -2221,5 +2221,24 @@ RSpec.describe LettingsLog do
end
end
end
describe "#scheme_has_confidential_information?" do
let(:log) { build(:lettings_log) }
it "returns false when there is no scheme" do
log.scheme = nil
expect(log.scheme_has_confidential_information?).to be false
end
it "returns false when the scheme is not confidential" do
log.scheme = build(:scheme, sensitive: 0)
expect(log.scheme_has_confidential_information?).to be false
end
it "returns true when the scheme is confidential" do
log.scheme = build(:scheme, sensitive: 1)
expect(log.scheme_has_confidential_information?).to be true
end
end
end
# rubocop:enable RSpec/MessageChain

29
spec/requests/form/address_search_guidance_spec.rb

@ -0,0 +1,29 @@
require "rails_helper"
RSpec.describe "Address search bottom guidance", type: :request do
let(:user) { create(:user) }
before { sign_in user }
context "with a 2026 lettings log at the address search question" do
let(:lettings_log) { create(:lettings_log, :completed, assigned_to: user, manual_address_entry_selected: false) }
it "shows the confidential supported lettings guidance drop-down" do
get "/lettings-logs/#{lettings_log.id}/address-search"
expect(response).to have_http_status(:ok)
expect(response.body).to include("What should I do for confidential supported lettings?")
expect(response.body).to include("Full address or UPRN is not required for confidential supported lettings.")
end
end
context "with a 2026 sales log at the address search question" do
let(:sales_log) { create(:sales_log, :completed, assigned_to: user, manual_address_entry_selected: false) }
it "does not show the lettings confidential supported lettings guidance" do
get "/sales-logs/#{sales_log.id}/address-search"
expect(response.body).not_to include("What should I do for confidential supported lettings?")
end
end
end

107
spec/services/bulk_upload/lettings/year2026/row_parser_spec.rb

@ -11,11 +11,14 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
let(:owning_org) { create(:organisation, :with_old_visible_id) }
let(:managing_org) { create(:organisation, :with_old_visible_id, rent_periods: [4, 1]) }
let(:scheme) { create(:scheme, :with_old_visible_id, owning_organisation: owning_org) }
# Pinned non-confidential so the address/UPRN validations run as these tests expect;
# the confidential scheme behaviour is covered by its own describe below (sensitive: 1).
let(:scheme) { create(:scheme, :with_old_visible_id, owning_organisation: owning_org, sensitive: 0) }
let(:postcode_first_part) { "AA1".freeze }
let(:postcode_second_part) { "1AA".freeze }
let(:postcode) { "#{postcode_first_part} #{postcode_second_part}" }
let(:location) { create(:location, :with_old_visible_id, scheme:, postcode:) }
let(:confidential_suffix) { I18n.t("validations.lettings.2026.bulk_upload.address.confidential_scheme_suffix") }
let(:setup_section_params) do
{
@ -1676,10 +1679,10 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
it "adds errors to missing key address fields" do
parser.valid?
expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2026.bulk_upload.not_answered", question: "address line 1.")])
expect(parser.errors[:field_21]).to eql([I18n.t("validations.lettings.2026.bulk_upload.not_answered", question: "town or city.")])
expect(parser.errors[:field_23]).to eql([I18n.t("validations.lettings.2026.bulk_upload.not_answered", question: "part 1 of postcode.")])
expect(parser.errors[:field_24]).to eql([I18n.t("validations.lettings.2026.bulk_upload.not_answered", question: "part 2 of postcode.")])
expect(parser.errors[:field_19]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.not_answered', question: 'address line 1.')} #{confidential_suffix}"])
expect(parser.errors[:field_21]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.not_answered', question: 'town or city.')} #{confidential_suffix}"])
expect(parser.errors[:field_23]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.not_answered', question: 'part 1 of postcode.')} #{confidential_suffix}"])
expect(parser.errors[:field_24]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.not_answered', question: 'part 2 of postcode.')} #{confidential_suffix}"])
end
end
@ -1707,11 +1710,20 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
it "adds appropriate errors to UPRN and key address fields" do
parser.valid?
expect(parser.errors[:field_18]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_21]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_23]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_24]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_18]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_19]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_21]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_23]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_24]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
end
it "appends the confidential scheme guidance to each address error" do
parser.valid?
%i[field_18 field_19 field_21 field_23 field_24].each do |field|
expect(parser.errors[field].first).to end_with(
"If your letting is in a confidential scheme, check the scheme you chose. A coordinator can edit a scheme to confidential on the ‘Schemes' page.",
)
end
end
end
@ -1720,8 +1732,8 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
it "adds errors to UPRN and the missing key address field" do
parser.valid?
expect(parser.errors[:field_18]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_19]).to eql([I18n.t("validations.lettings.2026.bulk_upload.address.not_answered")])
expect(parser.errors[:field_18]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_19]).to eql(["#{I18n.t('validations.lettings.2026.bulk_upload.address.not_answered')} #{confidential_suffix}"])
expect(parser.errors[:field_21]).to be_empty
expect(parser.errors[:field_23]).to be_empty
expect(parser.errors[:field_24]).to be_empty
@ -1864,6 +1876,77 @@ RSpec.describe BulkUpload::Lettings::Year2026::RowParser do
end
end
describe "UPRN and address fields for a supported housing log in a confidential scheme" do
let(:scheme) { create(:scheme, :with_old_visible_id, owning_organisation: owning_org, sensitive: 1) }
let(:base_attributes) do
setup_section_params.merge({
field_4: 2,
field_5: "S#{scheme.id}",
field_6: location.old_visible_id,
})
end
context "when no UPRN or address fields are provided" do
let(:attributes) { base_attributes.merge({ field_18: nil, field_19: nil, field_20: nil, field_21: nil, field_22: nil, field_23: nil, field_24: nil }) }
it "does not require the address or UPRN (no not answered errors)" do
parser.valid?
%i[field_18 field_19 field_20 field_21 field_22 field_23 field_24].each do |field|
expect(parser.errors[field]).to be_empty
end
end
it "does not import any address or UPRN fields" do
log = parser.log
log.valid?
expect(log.read_attribute(:uprn)).to be_nil
expect(log.read_attribute(:address_line1)).to be_nil
expect(log.read_attribute(:address_line2)).to be_nil
expect(log.read_attribute(:town_or_city)).to be_nil
expect(log.read_attribute(:county)).to be_nil
expect(log.read_attribute(:postcode_full)).to be_nil
end
it "derives the local authority from the scheme's location" do
log = parser.log
log.valid?
expect(log.la).to eq(location.location_code)
end
end
context "when address and UPRN fields are provided in the template" do
let(:attributes) do
base_attributes.merge({
field_18: "123456789012",
field_19: "1 Test Street",
field_20: "Test District",
field_21: "Testville",
field_22: "Testshire",
field_23: postcode_first_part,
field_24: postcode_second_part,
field_25: "E09000008",
})
end
it "ignores them and does not import the address or UPRN fields" do
log = parser.log
log.valid?
expect(log.read_attribute(:uprn)).to be_nil
expect(log.read_attribute(:address_line1)).to be_nil
expect(log.read_attribute(:address_line2)).to be_nil
expect(log.read_attribute(:town_or_city)).to be_nil
expect(log.read_attribute(:county)).to be_nil
expect(log.read_attribute(:postcode_full)).to be_nil
end
it "still derives the local authority from the scheme's location, ignoring the template LA" do
log = parser.log
log.valid?
expect(log.la).to eq(location.location_code)
end
end
end
describe "#field_17" do # unitletas
context "when no longer a valid option from previous year" do
let(:attributes) { setup_section_params.merge({ field_17: "4" }) }

76
spec/services/csv/lettings_log_csv_service_spec.rb

@ -193,6 +193,82 @@ RSpec.describe Csv::LettingsLogCsvService do
end
end
# TODO: (CLDC-4462): delete once address data for logs in confidential schemes is wiped.
describe "confidential scheme behaviour" do
let(:year) { 2026 }
let(:owning_organisation) { create(:organisation) }
let(:scheme) { create(:scheme, sensitive: 1, owning_organisation:) }
let(:location) { create(:location, scheme:) }
# Every hidden field that actually appears as a column in this export.
let(:hidden_columns) { described_class::ADDRESS_FIELDS_HIDDEN_FOR_CONFIDENTIAL_SCHEME & attribute_line }
let(:log) do
create(
:lettings_log,
:ignore_validation_errors,
needstype: 2,
owning_organisation:,
managing_organisation: owning_organisation,
assigned_to: user,
scheme:,
location:,
startdate: Time.zone.local(2026, 5, 1),
).tap do |confidential_log|
# Simulate a log created before the confidential address feature that still holds
# property address data in the database. Populate every hidden address column so
# that the blanking is observable (a nil column would pass the assertion vacuously).
confidential_log.update_columns(
uprn: "123456789012",
uprn_known: 1,
uprn_confirmed: 1,
uprn_selection: "123456789012",
address_line1: "1 Secret Street",
address_line2: "Flat 2",
town_or_city: "Secretville",
county: "Secretshire",
postcode_full: "AB1 2CD",
postcode_known: 1,
address_line1_input: "1 Secret Street input",
postcode_full_input: "AB1 2CD",
address_line1_as_entered: "1 Secret Street as entered",
address_line2_as_entered: "Flat 2 as entered",
town_or_city_as_entered: "Secretville as entered",
county_as_entered: "Secretshire as entered",
postcode_full_as_entered: "AB1 2CD",
la_as_entered: "la as entered",
la: "E09000003",
)
end
end
def csv_value(attribute)
content_line[attribute_line.index(attribute)]
end
context "when a log's scheme is confidential" do
it "blanks every hidden address and UPRN column" do
expect(hidden_columns).not_to be_empty
hidden_columns.each do |attribute|
expect(csv_value(attribute)).to be_nil, "expected the #{attribute} column to be blank for a confidential-scheme log"
end
end
it "still exports the local authority" do
expect(csv_value("la")).to eq("E09000003")
end
end
context "when the scheme is not confidential" do
let(:scheme) { create(:scheme, sensitive: 0, owning_organisation:) }
it "exports every one of those columns as normal" do
expect(hidden_columns).not_to be_empty
hidden_columns.each do |attribute|
expect(csv_value(attribute)).not_to be_nil, "expected the #{attribute} column to be populated for a non-confidential-scheme log"
end
end
end
end
describe "the full CSV output" do
context "when the requested log year is 2026" do
let(:year) { 2026 }

6
webpack.config.js

@ -34,7 +34,11 @@ module.exports = {
},
{
test: /\.(scss|css)/i,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
use: [
MiniCssExtractPlugin.loader,
'css-loader',
{ loader: 'sass-loader', options: { sassOptions: { charset: false } } }
]
}
]
},

Loading…
Cancel
Save