Browse Source

Merge branch 'main' into CLDC-3928-logs-with-manually-entered-address-showing-the-property-information-as-incomplete

# Conflicts:
#	spec/factories/sales_log.rb
pull/3003/head
Manny Dinssa 2 months ago
parent
commit
895d679886
  1. 4
      app/controllers/address_search_controller.rb
  2. 1
      app/models/export.rb
  3. 7
      app/models/validations/soft_validations.rb
  4. 13
      app/services/exports/export_service.rb
  5. 5
      app/services/exports/lettings_log_export_constants.rb
  6. 3
      app/services/exports/lettings_log_export_service.rb
  7. 146
      app/services/exports/sales_log_export_constants.rb
  8. 156
      app/services/exports/sales_log_export_service.rb
  9. 4
      app/services/feature_toggle.rb
  10. 2
      app/views/form/_address_search_question.html.erb
  11. 1
      config/routes.rb
  12. 99
      spec/factories/sales_log.rb
  13. 176
      spec/fixtures/exports/general_needs_log_25_26.xml
  14. 154
      spec/fixtures/exports/sales_log.xml
  15. 154
      spec/fixtures/exports/sales_log_2024.xml
  16. 4
      spec/helpers/tab_nav_helper_spec.rb
  17. 36
      spec/models/validations/soft_validations_spec.rb
  18. 88
      spec/requests/address_search_controller_spec.rb
  19. 2
      spec/requests/bulk_upload_lettings_results_controller_spec.rb
  20. 4
      spec/requests/bulk_upload_sales_results_controller_spec.rb
  21. 247
      spec/services/exports/export_service_spec.rb
  22. 32
      spec/services/exports/lettings_log_export_service_spec.rb
  23. 425
      spec/services/exports/sales_log_export_service_spec.rb

4
app/controllers/address_search_controller.rb

@ -18,8 +18,8 @@ class AddressSearchController < ApplicationController
presenter = UprnDataPresenter.new(service.result) presenter = UprnDataPresenter.new(service.result)
render json: [{ text: presenter.address, value: presenter.uprn }] render json: [{ text: presenter.address, value: presenter.uprn }]
end end
elsif query.match?(/[a-zA-Z]/) elsif query.match?(/\D/)
# Query contains letters, assume it's an address # Query contains any non-digit characters, assume it's an address
service = AddressClient.new(query, { minmatch: 0.2 }) service = AddressClient.new(query, { minmatch: 0.2 })
service.call service.call

1
app/models/export.rb

@ -1,5 +1,6 @@
class Export < ApplicationRecord class Export < ApplicationRecord
scope :lettings, -> { where(collection: "lettings") } scope :lettings, -> { where(collection: "lettings") }
scope :sales, -> { where(collection: "sales") }
scope :organisations, -> { where(collection: "organisations") } scope :organisations, -> { where(collection: "organisations") }
scope :users, -> { where(collection: "users") } scope :users, -> { where(collection: "users") }
end end

7
app/models/validations/soft_validations.rb

@ -103,13 +103,16 @@ module Validations::SoftValidations
TWO_YEARS_IN_DAYS = 730 TWO_YEARS_IN_DAYS = 730
TEN_YEARS_IN_DAYS = 3650 TEN_YEARS_IN_DAYS = 3650
TWENTY_YEARS_IN_DAYS = 7300
def major_repairs_date_in_soft_range? def major_repairs_date_in_soft_range?
mrcdate.present? && startdate.present? && mrcdate.between?(startdate.to_date - TEN_YEARS_IN_DAYS, startdate.to_date - TWO_YEARS_IN_DAYS) upper_limit = form.start_year_2025_or_later? ? TWENTY_YEARS_IN_DAYS : TEN_YEARS_IN_DAYS
mrcdate.present? && startdate.present? && mrcdate.between?(startdate.to_date - upper_limit, startdate.to_date - TWO_YEARS_IN_DAYS)
end end
def voiddate_in_soft_range? def voiddate_in_soft_range?
voiddate.present? && startdate.present? && voiddate.between?(startdate.to_date - TEN_YEARS_IN_DAYS, startdate.to_date - TWO_YEARS_IN_DAYS) upper_limit = form.start_year_2025_or_later? ? TWENTY_YEARS_IN_DAYS : TEN_YEARS_IN_DAYS
voiddate.present? && startdate.present? && voiddate.between?(startdate.to_date - upper_limit, startdate.to_date - TWO_YEARS_IN_DAYS)
end end
def net_income_higher_or_lower_text def net_income_higher_or_lower_text

13
app/services/exports/export_service.rb

@ -11,6 +11,7 @@ module Exports
start_time = Time.zone.now start_time = Time.zone.now
daily_run_number = get_daily_run_number daily_run_number = get_daily_run_number
lettings_archives_for_manifest = {} lettings_archives_for_manifest = {}
sales_archives_for_manifest = {}
users_archives_for_manifest = {} users_archives_for_manifest = {}
organisations_archives_for_manifest = {} organisations_archives_for_manifest = {}
@ -20,16 +21,19 @@ module Exports
users_archives_for_manifest = get_user_archives(start_time, full_update) users_archives_for_manifest = get_user_archives(start_time, full_update)
when "organisations" when "organisations"
organisations_archives_for_manifest = get_organisation_archives(start_time, full_update) organisations_archives_for_manifest = get_organisation_archives(start_time, full_update)
else when "lettings"
lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year) lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year)
when "sales"
sales_archives_for_manifest = get_sales_archives(start_time, full_update, year)
end end
else else
users_archives_for_manifest = get_user_archives(start_time, full_update) users_archives_for_manifest = get_user_archives(start_time, full_update)
organisations_archives_for_manifest = get_organisation_archives(start_time, full_update) organisations_archives_for_manifest = get_organisation_archives(start_time, full_update)
lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year) lettings_archives_for_manifest = get_lettings_archives(start_time, full_update, year)
sales_archives_for_manifest = get_sales_archives(start_time, full_update, year) if FeatureToggle.sales_export_enabled?
end end
write_master_manifest(daily_run_number, lettings_archives_for_manifest.merge(users_archives_for_manifest).merge(organisations_archives_for_manifest)) write_master_manifest(daily_run_number, lettings_archives_for_manifest.merge(sales_archives_for_manifest).merge(users_archives_for_manifest).merge(organisations_archives_for_manifest))
end end
private private
@ -74,5 +78,10 @@ module Exports
lettings_export_service = Exports::LettingsLogExportService.new(@storage_service, start_time) lettings_export_service = Exports::LettingsLogExportService.new(@storage_service, start_time)
lettings_export_service.export_xml_lettings_logs(full_update:, collection_year:) lettings_export_service.export_xml_lettings_logs(full_update:, collection_year:)
end end
def get_sales_archives(start_time, full_update, collection_year)
sales_export_service = Exports::SalesLogExportService.new(@storage_service, start_time)
sales_export_service.export_xml_sales_logs(full_update:, collection_year:)
end
end end
end end

5
app/services/exports/lettings_log_export_constants.rb

@ -193,5 +193,10 @@ module Exports::LettingsLogExportConstants
PRE_2024_EXPORT_FIELDS = Set[ PRE_2024_EXPORT_FIELDS = Set[
"national", "national",
"offered" "offered"
]
PRE_2025_EXPORT_FIELDS = Set[
"carehome_charges_value_check",
"chcharge"
] ]
end end

3
app/services/exports/lettings_log_export_service.rb

@ -142,7 +142,8 @@ module Exports
pattern_age.match(field_name) || pattern_age.match(field_name) ||
!EXPORT_FIELDS.include?(field_name) || !EXPORT_FIELDS.include?(field_name) ||
(lettings_log.form.start_year_2024_or_later? && PRE_2024_EXPORT_FIELDS.include?(field_name)) || (lettings_log.form.start_year_2024_or_later? && PRE_2024_EXPORT_FIELDS.include?(field_name)) ||
(!lettings_log.form.start_year_2024_or_later? && POST_2024_EXPORT_FIELDS.include?(field_name)) (!lettings_log.form.start_year_2024_or_later? && POST_2024_EXPORT_FIELDS.include?(field_name)) ||
(lettings_log.form.start_year_2025_or_later? && PRE_2025_EXPORT_FIELDS.include?(field_name))
end end
def build_export_xml(lettings_logs) def build_export_xml(lettings_logs)

146
app/services/exports/sales_log_export_constants.rb

@ -0,0 +1,146 @@
module Exports::SalesLogExportConstants
MAX_XML_RECORDS = 10_000
LOG_ID_OFFSET = 300_000_000_000
EXPORT_MODE = {
xml: 1,
csv: 2,
}.freeze
EXPORT_FIELDS = Set["ID",
"STATUS",
"DAY",
"MONTH",
"YEAR",
"DUPLICATESET",
"CREATEDDATE",
"UPLOADDATE",
"OWNINGORGID",
"OWNINGORGNAME",
"MANINGORGID",
"MANINGORGNAME",
"USERNAME",
"USERNAMEID",
"PURCHID",
"TYPE",
"OWNERSHIP",
"COLLECTIONYEAR",
"JOINTMORE",
"JOINT",
"BEDS",
"ETHNIC",
"ETHNICGROUP1",
"LIVEINBUYER1",
"BUILTYPE",
"PROPTYPE",
"NOINT",
"LIVEINBUYER2",
"PRIVACYNOTICE",
"WHEEL",
"HHOLDCOUNT",
"LA",
"INCOME1",
"INC1NK",
"INC1MORT",
"INCOME2",
"INC2NK",
"SAVINGSNK",
"SAVINGS",
"PREVOWN",
"AMENDEDBY",
"AMENDEDBYID",
"MORTGAGE",
"INC2MORT",
"HB",
"FROMBEDS",
"STAIRCASE",
"STAIRBOUGHT",
"STAIROWNED",
"MRENT",
"MRENTPRESTAIRCASING",
"RESALE",
"DEPOSIT",
"CASHDIS",
"DISABLED",
"VALUE",
"EQUITY",
"DISCOUNT",
"GRANT",
"PPCODENK",
"PPOSTC1",
"PPOSTC2",
"PREVLOC",
"PREVLOCNAME",
"PREVIOUSLAKNOWN",
"HHREGRES",
"HHREGRESSTILL",
"PROPLEN",
"HASMSCHARGE",
"MSCHARGE",
"PREVTEN",
"MORTGAGEUSED",
"WCHAIR",
"ARMEDFORCESSPOUSE",
"HODAY",
"HOMONTH",
"HOYEAR",
"FROMPROP",
"SOCPREVTEN",
"MORTLEN1",
"EXTRABOR",
"HHTYPE",
"POSTCODE",
"ISLAINFERRED",
"BULKUPLOADID",
"VALUE_VALUE_CHECK",
"PREVSHARED",
"STAIRCASETOSALE",
"ETHNICGROUP2",
"ETHNIC2",
"BUY2LIVING",
"PREVTEN2",
"UPRN",
"ADDRESS1",
"ADDRESS2",
"TOWNCITY",
"COUNTY",
"LANAME",
"CREATIONMETHOD",
"NATIONALITYALL1",
"NATIONALITYALL2",
"MSCHARGE_VALUE_CHECK",
"ADDRESS1INPUT",
"POSTCODEINPUT",
"ADDRESS_SEARCH_VALUE_CHECK",
"UPRNSELECTED",
"BULKADDRESS1",
"BULKADDRESS2",
"BULKTOWNCITY",
"BULKCOUNTY",
"BULKPOSTCODE",
"BULKLA",
"CREATEDBY",
"CREATEDBYID",
"HASESTATEFEE",
"ESTATEFEE",
"FIRSTSTAIR",
"NUMSTAIR",
"STAIRLASTDAY",
"STAIRLASTMONTH",
"STAIRLASTYEAR",
"STAIRINITIALYEAR",
"STAIRINITIALMONTH",
"STAIRINITIALDAY",
"HASSERVICECHARGES",
"SERVICECHARGES",]
(1..6).each do |index|
EXPORT_FIELDS << "AGE#{index}"
EXPORT_FIELDS << "ECSTAT#{index}"
EXPORT_FIELDS << "SEX#{index}"
end
(2..6).each do |index|
EXPORT_FIELDS << "RELAT#{index}"
end
end

156
app/services/exports/sales_log_export_service.rb

@ -0,0 +1,156 @@
module Exports
class SalesLogExportService < Exports::XmlExportService
include Exports::SalesLogExportConstants
include CollectionTimeHelper
def export_xml_sales_logs(full_update: false, collection_year: nil)
archives_for_manifest = {}
collection_years_to_export(collection_year).each do |year|
recent_export = Export.sales.where(year:).order("started_at").last
base_number = Export.sales.where(empty_export: false, year:).maximum(:base_number) || 1
export = build_export_run("sales", base_number, full_update, year)
archives = write_export_archive(export, year, recent_export, full_update)
archives_for_manifest.merge!(archives)
export.empty_export = archives.empty?
export.save!
end
archives_for_manifest
end
private
def get_archive_name(year, base_number, increment)
return unless year
base_number_str = "f#{base_number.to_s.rjust(4, '0')}"
increment_str = "inc#{increment.to_s.rjust(4, '0')}"
"core_sales_#{year}_#{year + 1}_apr_mar_#{base_number_str}_#{increment_str}".downcase
end
def retrieve_resources(recent_export, full_update, year)
if !full_update && recent_export
params = { from: recent_export.started_at, to: @start_time }
SalesLog.exportable.where("(updated_at >= :from AND updated_at <= :to) OR (values_updated_at IS NOT NULL AND values_updated_at >= :from AND values_updated_at <= :to)", params).filter_by_year(year)
else
params = { to: @start_time }
SalesLog.exportable.where("updated_at <= :to", params).filter_by_year(year)
end
end
def apply_cds_transformation(sales_log, _export_mode)
attribute_hash = sales_log.attributes_before_type_cast
attribute_hash["day"] = sales_log.saledate&.day
attribute_hash["month"] = sales_log.saledate&.month
attribute_hash["year"] = sales_log.saledate&.year
attribute_hash["createddate"] = sales_log.created_at&.iso8601
attribute_hash["createdby"] = sales_log.created_by&.email
attribute_hash["createdbyid"] = sales_log.created_by_id
attribute_hash["username"] = sales_log.assigned_to&.email
attribute_hash["usernameid"] = sales_log.assigned_to_id
attribute_hash["uploaddate"] = sales_log.updated_at&.iso8601
attribute_hash["amendedby"] = sales_log.updated_by&.email
attribute_hash["amendedbyid"] = sales_log.updated_by_id
attribute_hash["owningorgid"] = sales_log.owning_organisation&.id
attribute_hash["owningorgname"] = sales_log.owning_organisation&.name
attribute_hash["maningorgid"] = sales_log.managing_organisation&.id
attribute_hash["maningorgname"] = sales_log.managing_organisation&.name
attribute_hash["creationmethod"] = sales_log.creation_method_before_type_cast
attribute_hash["bulkuploadid"] = sales_log.bulk_upload_id
attribute_hash["collectionyear"] = sales_log.form.start_date.year
attribute_hash["ownership"] = sales_log.ownershipsch
attribute_hash["joint"] = sales_log.jointpur
attribute_hash["ethnicgroup1"] = sales_log.ethnic_group
attribute_hash["ethnicgroup2"] = sales_log.ethnic_group2
attribute_hash["previouslaknown"] = sales_log.previous_la_known
attribute_hash["hasmscharge"] = sales_log.discounted_ownership_sale? ? sales_log.has_mscharge : nil
attribute_hash["mscharge"] = sales_log.discounted_ownership_sale? ? sales_log.mscharge : nil
attribute_hash["hasservicecharges"] = sales_log.shared_ownership_scheme? ? sales_log.has_mscharge : nil
attribute_hash["servicecharges"] = sales_log.shared_ownership_scheme? ? sales_log.mscharge : nil
attribute_hash["hoday"] = sales_log.hodate&.day
attribute_hash["homonth"] = sales_log.hodate&.month
attribute_hash["hoyear"] = sales_log.hodate&.year
attribute_hash["inc1nk"] = sales_log.income1nk
attribute_hash["inc2nk"] = sales_log.income2nk
attribute_hash["postcode"] = sales_log.postcode_full
attribute_hash["islainferred"] = sales_log.is_la_inferred
attribute_hash["mortlen1"] = sales_log.mortlen
attribute_hash["ethnic2"] = sales_log.ethnicbuy2
attribute_hash["prevten2"] = sales_log.prevtenbuy2
attribute_hash["address1"] = sales_log.address_line1
attribute_hash["address2"] = sales_log.address_line2
attribute_hash["towncity"] = sales_log.town_or_city
attribute_hash["laname"] = LocalAuthority.find_by(code: sales_log.la)&.name
attribute_hash["address1input"] = sales_log.address_line1_input
attribute_hash["postcodeinput"] = sales_log.postcode_full_input
attribute_hash["uprnselected"] = sales_log.uprn_selection
attribute_hash["bulkaddress1"] = sales_log.address_line1_as_entered
attribute_hash["bulkaddress2"] = sales_log.address_line2_as_entered
attribute_hash["bulktowncity"] = sales_log.town_or_city_as_entered
attribute_hash["bulkcounty"] = sales_log.county_as_entered
attribute_hash["bulkpostcode"] = sales_log.postcode_full_as_entered
attribute_hash["bulkla"] = sales_log.la_as_entered
attribute_hash["nationalityall1"] = sales_log.nationality_all
attribute_hash["nationalityall2"] = sales_log.nationality_all_buyer2
attribute_hash["prevlocname"] = LocalAuthority.find_by(code: sales_log.prevloc)&.name
attribute_hash["liveinbuyer1"] = sales_log.buy1livein
attribute_hash["liveinbuyer2"] = sales_log.buy2livein
attribute_hash["hasestatefee"] = sales_log.has_management_fee
attribute_hash["estatefee"] = sales_log.management_fee
attribute_hash["stairlastday"] = sales_log.lasttransaction&.day
attribute_hash["stairlastmonth"] = sales_log.lasttransaction&.month
attribute_hash["stairlastyear"] = sales_log.lasttransaction&.year
attribute_hash["stairinitialday"] = sales_log.initialpurchase&.day
attribute_hash["stairinitialmonth"] = sales_log.initialpurchase&.month
attribute_hash["stairinitialyear"] = sales_log.initialpurchase&.year
attribute_hash["mscharge_value_check"] = sales_log.monthly_charges_value_check
attribute_hash["duplicateset"] = sales_log.duplicate_set_id
attribute_hash["staircasetosale"] = sales_log.staircasesale
attribute_hash.transform_keys!(&:upcase)
attribute_hash
end
def is_omitted_field?(field_name, _sales_log)
!EXPORT_FIELDS.include?(field_name)
end
def build_export_xml(sales_logs)
doc = Nokogiri::XML("<forms/>")
sales_logs.each do |sales_log|
attribute_hash = apply_cds_transformation(sales_log, EXPORT_MODE[:xml])
form = doc.create_element("form")
doc.at("forms") << form
attribute_hash.each do |key, value|
if is_omitted_field?(key, sales_log)
next
else
form << doc.create_element(key, value)
end
end
end
xml_doc_to_temp_file(doc)
end
def collection_years_to_export(collection_year)
return [collection_year] if collection_year.present?
FormHandler.instance.sales_forms.values.map { |f| f.start_date.year }.uniq.select { |year| year > 2024 }
end
end
end

4
app/services/feature_toggle.rb

@ -30,4 +30,8 @@ class FeatureToggle
def self.create_test_logs_enabled? def self.create_test_logs_enabled?
Rails.env.development? || Rails.env.review? Rails.env.development? || Rails.env.review?
end end
def self.sales_export_enabled?
Time.zone.now >= Time.zone.local(2025, 4, 1) || (Rails.env.review? || Rails.env.staging?)
end
end end

2
app/views/form/_address_search_question.html.erb

@ -17,7 +17,7 @@
<%= question.answer_selected?(@log, answer) ? "selected" : "" %>><%= answer.name || answer.resource %></option> <%= question.answer_selected?(@log, answer) ? "selected" : "" %>><%= answer.name || answer.resource %></option>
<% end %> <% end %>
<% else %> <% else %>
<option value="" disabled>Javascript is disabled. Please enter the address manually.</option> <option value="" disabled>Javascript is disabled, please enter the address manually.</option>
<% end %> <% end %>
<% end %> <% end %>

1
config/routes.rb

@ -40,7 +40,6 @@ Rails.application.routes.draw do
get "/service-moved", to: "maintenance#service_moved" get "/service-moved", to: "maintenance#service_moved"
get "/service-unavailable", to: "maintenance#service_unavailable" get "/service-unavailable", to: "maintenance#service_unavailable"
get "/address-search", to: "address_search#index" get "/address-search", to: "address_search#index"
get "/address-search/current", to: "address_search#current"
get "/address-search/manual-input/:log_type/:log_id", to: "address_search#manual_input", as: "address_manual_input" get "/address-search/manual-input/:log_type/:log_id", to: "address_search#manual_input", as: "address_manual_input"
get "/address-search/search-input/:log_type/:log_id", to: "address_search#search_input", as: "address_search_input" get "/address-search/search-input/:log_type/:log_id", to: "address_search#search_input", as: "address_search_input"

99
spec/factories/sales_log.rb

@ -251,5 +251,104 @@ FactoryBot.define do
log.postcode_full = "SW1 1AA" log.postcode_full = "SW1 1AA"
end end
end end
trait :export do
purchid { "123" }
ownershipsch { 2 }
type { 8 }
saledate_today
jointpur { 1 }
beds { 2 }
jointmore { 1 }
noint { 2 }
privacynotice { 1 }
age1_known { 0 }
age1 { 27 }
sex1 { "F" }
national { 18 }
buy1livein { 1 }
relat2 { "P" }
proptype { 1 }
age2_known { 0 }
age2 { 33 }
builtype { 1 }
ethnic { 3 }
ethnic_group { 17 }
sex2 { "X" }
buy2livein { "1" }
ecstat1 { "1" }
ecstat2 { "1" }
hholdcount { "4" }
wheel { 1 }
details_known_3 { 1 }
age3_known { 0 }
age3 { 14 }
details_known_4 { 1 }
age4_known { 0 }
age4 { 18 }
details_known_5 { 1 }
age5_known { 0 }
age5 { 40 }
details_known_6 { 1 }
age6_known { 0 }
age6 { 40 }
income1nk { 0 }
income1 { 10_000 }
inc1mort { 1 }
income2nk { 0 }
income2 { 10_000 }
inc2mort { 1 }
uprn_known { 0 }
address_line1 { "Address line 1" }
town_or_city { "City" }
la_known { 1 }
la { "E09000003" }
savingsnk { 1 }
prevown { 1 }
prevshared { 2 }
sex3 { "F" }
sex4 { "X" }
sex5 { "M" }
sex6 { "X" }
mortgage { 20_000 }
ecstat3 { 9 }
ecstat4 { 3 }
ecstat5 { 2 }
ecstat6 { 1 }
disabled { 1 }
deposit { 80_000 }
value { 110_000 }
value_value_check { 0 }
grant { 10_000 }
hhregres { 7 }
ppcodenk { 1 }
prevten { 1 }
previous_la_known { 0 }
relat3 { "X" }
relat4 { "X" }
relat5 { "R" }
relat6 { "R" }
hb { 4 }
mortgageused { 1 }
wchair { 1 }
armedforcesspouse { 5 }
has_mscharge { 1 }
mscharge { 100 }
mortlen { 10 }
pcodenk { 0 }
postcode_full { "SW1A 1AA" }
is_la_inferred { false }
mortgagelender { 5 }
extrabor { 1 }
ethnic_group2 { 17 }
nationalbuy2 { 13 }
buy2living { 3 }
proplen_asked { 1 }
address_line1_input { "Address line 1" }
postcode_full_input { "SW1A 1AA" }
nationality_all_group { 826 }
nationality_all_buyer2_group { 826 }
uprn { "10033558653" }
uprn_selection { 1 }
end
end end
end end

176
spec/fixtures/exports/general_needs_log_25_26.xml vendored

@ -0,0 +1,176 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<status>2</status>
<tenancycode>BZ737</tenancycode>
<age1>35</age1>
<sex1>F</sex1>
<ethnic>2</ethnic>
<prevten>6</prevten>
<ecstat1>0</ecstat1>
<hhmemb>2</hhmemb>
<age2>32</age2>
<sex2>M</sex2>
<ecstat2>6</ecstat2>
<age3/>
<sex3/>
<ecstat3/>
<age4/>
<sex4/>
<ecstat4/>
<age5/>
<sex5/>
<ecstat5/>
<age6/>
<sex6/>
<ecstat6/>
<age7/>
<sex7/>
<ecstat7/>
<age8/>
<sex8/>
<ecstat8/>
<homeless>1</homeless>
<underoccupation_benefitcap>4</underoccupation_benefitcap>
<leftreg>4</leftreg>
<reservist>1</reservist>
<illness>1</illness>
<preg_occ>2</preg_occ>
<startertenancy>1</startertenancy>
<tenancylength>5</tenancylength>
<tenancy>4</tenancy>
<ppostcode_full>A1 1AA</ppostcode_full>
<rsnvac>6</rsnvac>
<unittype_gn>7</unittype_gn>
<beds>3</beds>
<wchair>1</wchair>
<earnings>268</earnings>
<incfreq>1</incfreq>
<benefits>1</benefits>
<period>2</period>
<layear>2</layear>
<waityear>7</waityear>
<postcode_full>AA1 1AA</postcode_full>
<reasonpref>1</reasonpref>
<cbl>0</cbl>
<chr>1</chr>
<cap>0</cap>
<reasonother/>
<housingneeds_a>1</housingneeds_a>
<housingneeds_b>0</housingneeds_b>
<housingneeds_c>0</housingneeds_c>
<housingneeds_f>0</housingneeds_f>
<housingneeds_g>0</housingneeds_g>
<housingneeds_h>0</housingneeds_h>
<illness_type_1>0</illness_type_1>
<illness_type_2>1</illness_type_2>
<illness_type_3>0</illness_type_3>
<illness_type_4>0</illness_type_4>
<illness_type_8>0</illness_type_8>
<illness_type_5>0</illness_type_5>
<illness_type_6>0</illness_type_6>
<illness_type_7>0</illness_type_7>
<illness_type_9>0</illness_type_9>
<illness_type_10>0</illness_type_10>
<rp_homeless>0</rp_homeless>
<rp_insan_unsat>1</rp_insan_unsat>
<rp_medwel>0</rp_medwel>
<rp_hardship>0</rp_hardship>
<rp_dontknow>0</rp_dontknow>
<tenancyother/>
<net_income_value_check/>
<irproduct_other/>
<reason>4</reason>
<propcode>123</propcode>
<la>E09000033</la>
<prevloc>E07000105</prevloc>
<hb>6</hb>
<hbrentshortfall>1</hbrentshortfall>
<mrcdate>2022-05-05T10:36:49+01:00</mrcdate>
<incref>0</incref>
<startdate>2025-04-03T00:00:00+01:00</startdate>
<armedforces>1</armedforces>
<unitletas>2</unitletas>
<builtype>1</builtype>
<voiddate>2021-11-03T00:00:00+00:00</voiddate>
<renttype>2</renttype>
<needstype>1</needstype>
<lettype>7</lettype>
<totchild>0</totchild>
<totelder>0</totelder>
<totadult>2</totadult>
<nocharge>0</nocharge>
<referral>2</referral>
<brent>200.0</brent>
<scharge>50.0</scharge>
<pscharge>40.0</pscharge>
<supcharg>35.0</supcharg>
<tcharge>325.0</tcharge>
<tshortfall>12.0</tshortfall>
<ppcodenk>0</ppcodenk>
<has_benefits>1</has_benefits>
<renewal>0</renewal>
<wrent>100.0</wrent>
<wscharge>25.0</wscharge>
<wpschrge>20.0</wpschrge>
<wsupchrg>17.5</wsupchrg>
<wtcharge>162.5</wtcharge>
<wtshortfall>6.0</wtshortfall>
<refused>0</refused>
<housingneeds>1</housingneeds>
<wchchrg/>
<newprop>2</newprop>
<relat2>P</relat2>
<relat3/>
<relat4/>
<relat5/>
<relat6/>
<relat7/>
<relat8/>
<rent_value_check/>
<lar>2</lar>
<irproduct/>
<joint>3</joint>
<sheltered/>
<hhtype>4</hhtype>
<new_old>2</new_old>
<vacdays>1064</vacdays>
<bulk_upload_id>1</bulk_upload_id>
<uprn>1</uprn>
<uprn_known>1</uprn_known>
<uprn_confirmed>1</uprn_confirmed>
<address_line1>1, Test Street</address_line1>
<address_line2/>
<town_or_city>Test Town</town_or_city>
<county/>
<discarded_at/>
<creation_method>2</creation_method>
<supcharg_value_check/>
<scharge_value_check/>
<pscharge_value_check/>
<duplicate_set_id/>
<accessible_register>0</accessible_register>
<nationality_all>826</nationality_all>
<address_line1_as_entered>address line 1 as entered</address_line1_as_entered>
<address_line2_as_entered>address line 2 as entered</address_line2_as_entered>
<town_or_city_as_entered>town or city as entered</town_or_city_as_entered>
<county_as_entered>county as entered</county_as_entered>
<postcode_full_as_entered>AB1 2CD</postcode_full_as_entered>
<la_as_entered>la as entered</la_as_entered>
<formid>{id}</formid>
<owningorgid>{owning_org_id}</owningorgid>
<owningorgname>{owning_org_name}</owningorgname>
<hcnum>1234</hcnum>
<maningorgid>{managing_org_id}</maningorgid>
<maningorgname>{managing_org_name}</maningorgname>
<manhcnum>1234</manhcnum>
<createddate>2025-04-03T00:00:00+01:00</createddate>
<uploaddate>2025-04-03T00:00:00+01:00</uploaddate>
<log_id>{log_id}</log_id>
<assigned_to>test1@example.com</assigned_to>
<created_by>test1@example.com</created_by>
<amended_by/>
<renttype_detail>2</renttype_detail>
<providertype>1</providertype>
</form>
</forms>

154
spec/fixtures/exports/sales_log.xml vendored

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>1</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEX1>F</SEX1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEX2>X</SEX2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEX3>F</SEX3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEX4>X</SEX4>
<SEX5>M</SEX5>
<SEX6>X</SEX6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<DAY>1</DAY>
<MONTH>3</MONTH>
<YEAR>2026</YEAR>
<CREATEDDATE>2026-03-01T00:00:00+00:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2026-03-01T00:00:00+00:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2025</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

154
spec/fixtures/exports/sales_log_2024.xml vendored

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8"?>
<forms>
<form>
<ID>{id}</ID>
<STATUS>2</STATUS>
<PURCHID>123</PURCHID>
<TYPE>8</TYPE>
<JOINTMORE>1</JOINTMORE>
<BEDS>2</BEDS>
<AGE1>27</AGE1>
<SEX1>F</SEX1>
<ETHNIC>17</ETHNIC>
<BUILTYPE>1</BUILTYPE>
<PROPTYPE>1</PROPTYPE>
<AGE2>33</AGE2>
<RELAT2>P</RELAT2>
<SEX2>X</SEX2>
<NOINT>2</NOINT>
<ECSTAT2>1</ECSTAT2>
<PRIVACYNOTICE>1</PRIVACYNOTICE>
<ECSTAT1>1</ECSTAT1>
<WHEEL>1</WHEEL>
<HHOLDCOUNT>4</HHOLDCOUNT>
<AGE3>14</AGE3>
<LA>E09000033</LA>
<INCOME1>10000</INCOME1>
<AGE4>18</AGE4>
<AGE5>40</AGE5>
<AGE6>40</AGE6>
<INC1MORT>1</INC1MORT>
<INCOME2>10000</INCOME2>
<SAVINGSNK>1</SAVINGSNK>
<SAVINGS/>
<PREVOWN>1</PREVOWN>
<SEX3>F</SEX3>
<MORTGAGE>20000.0</MORTGAGE>
<INC2MORT>1</INC2MORT>
<ECSTAT3>9</ECSTAT3>
<ECSTAT4>3</ECSTAT4>
<ECSTAT5>2</ECSTAT5>
<ECSTAT6>1</ECSTAT6>
<RELAT3>X</RELAT3>
<RELAT4>X</RELAT4>
<RELAT5>R</RELAT5>
<RELAT6>R</RELAT6>
<HB>4</HB>
<SEX4>X</SEX4>
<SEX5>M</SEX5>
<SEX6>X</SEX6>
<FROMBEDS/>
<STAIRCASE/>
<STAIRBOUGHT/>
<STAIROWNED/>
<MRENT/>
<RESALE/>
<DEPOSIT>80000.0</DEPOSIT>
<CASHDIS/>
<DISABLED>1</DISABLED>
<VALUE>110000.0</VALUE>
<EQUITY/>
<DISCOUNT/>
<GRANT>10000.0</GRANT>
<PPCODENK>0</PPCODENK>
<PPOSTC1>SW1A</PPOSTC1>
<PPOSTC2>1AA</PPOSTC2>
<PREVLOC>E09000033</PREVLOC>
<HHREGRES>7</HHREGRES>
<HHREGRESSTILL/>
<PROPLEN/>
<MSCHARGE>100.0</MSCHARGE>
<PREVTEN>1</PREVTEN>
<MORTGAGEUSED>1</MORTGAGEUSED>
<WCHAIR>1</WCHAIR>
<ARMEDFORCESSPOUSE>5</ARMEDFORCESSPOUSE>
<HODAY/>
<HOMONTH/>
<HOYEAR/>
<FROMPROP/>
<SOCPREVTEN/>
<EXTRABOR>1</EXTRABOR>
<HHTYPE>6</HHTYPE>
<VALUE_VALUE_CHECK/>
<PREVSHARED>2</PREVSHARED>
<BUY2LIVING>3</BUY2LIVING>
<UPRN/>
<COUNTY/>
<ADDRESS_SEARCH_VALUE_CHECK/>
<FIRSTSTAIR/>
<NUMSTAIR/>
<MRENTPRESTAIRCASING/>
<DAY>3</DAY>
<MONTH>4</MONTH>
<YEAR>2024</YEAR>
<CREATEDDATE>2024-04-03T00:00:00+01:00</CREATEDDATE>
<CREATEDBY>{created_by_email}</CREATEDBY>
<CREATEDBYID>{created_by_id}</CREATEDBYID>
<USERNAME>{assigned_to_email}</USERNAME>
<USERNAMEID>{assigned_to_id}</USERNAMEID>
<UPLOADDATE>2024-04-03T00:00:00+01:00</UPLOADDATE>
<AMENDEDBY/>
<AMENDEDBYID/>
<OWNINGORGID>{owning_org_id}</OWNINGORGID>
<OWNINGORGNAME>{owning_org_name}</OWNINGORGNAME>
<MANINGORGID>{managing_org_id}</MANINGORGID>
<MANINGORGNAME>{managing_org_name}</MANINGORGNAME>
<CREATIONMETHOD>1</CREATIONMETHOD>
<BULKUPLOADID/>
<COLLECTIONYEAR>2024</COLLECTIONYEAR>
<OWNERSHIP>2</OWNERSHIP>
<JOINT>1</JOINT>
<ETHNICGROUP1>17</ETHNICGROUP1>
<ETHNICGROUP2>17</ETHNICGROUP2>
<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>
<HASMSCHARGE>1</HASMSCHARGE>
<HASSERVICECHARGES/>
<SERVICECHARGES/>
<INC1NK>0</INC1NK>
<INC2NK>0</INC2NK>
<POSTCODE>SW1A 1AA</POSTCODE>
<ISLAINFERRED>true</ISLAINFERRED>
<MORTLEN1>10</MORTLEN1>
<ETHNIC2/>
<PREVTEN2/>
<ADDRESS1>Address line 1</ADDRESS1>
<ADDRESS2/>
<TOWNCITY>City</TOWNCITY>
<LANAME>Westminster</LANAME>
<ADDRESS1INPUT>Address line 1</ADDRESS1INPUT>
<POSTCODEINPUT>SW1A 1AA</POSTCODEINPUT>
<UPRNSELECTED/>
<BULKADDRESS1/>
<BULKADDRESS2/>
<BULKTOWNCITY/>
<BULKCOUNTY/>
<BULKPOSTCODE/>
<BULKLA/>
<NATIONALITYALL1>826</NATIONALITYALL1>
<NATIONALITYALL2>826</NATIONALITYALL2>
<PREVLOCNAME>Westminster</PREVLOCNAME>
<LIVEINBUYER1>1</LIVEINBUYER1>
<LIVEINBUYER2>1</LIVEINBUYER2>
<HASESTATEFEE/>
<ESTATEFEE/>
<STAIRLASTDAY/>
<STAIRLASTMONTH/>
<STAIRLASTYEAR/>
<STAIRINITIALDAY/>
<STAIRINITIALMONTH/>
<STAIRINITIALYEAR/>
<MSCHARGE_VALUE_CHECK/>
<DUPLICATESET/>
<STAIRCASETOSALE/>
</form>
</forms>

4
spec/helpers/tab_nav_helper_spec.rb

@ -16,7 +16,7 @@ RSpec.describe TabNavHelper do
describe "#org_cell" do describe "#org_cell" do
it "returns the users org name and role separated by a newline character" do it "returns the users org name and role separated by a newline character" do
expected_html = "#{organisation.name}\n<span class=\"app-!-colour-muted\">Data provider</span>" expected_html = "#{organisation.name}\n<span class=\"app-!-colour-muted\">Data provider</span>"
expect(org_cell(current_user)).to match(expected_html) expect(CGI.unescapeHTML(org_cell(current_user))).to match(expected_html)
end end
end end
@ -30,7 +30,7 @@ RSpec.describe TabNavHelper do
describe "#scheme_cell" do describe "#scheme_cell" do
it "returns the scheme link service name and primary user group separated by a newline character" do it "returns the scheme link service name and primary user group separated by a newline character" do
expected_html = "<a class=\"govuk-link\" href=\"/schemes/#{scheme.id}\">#{scheme.service_name}</a>\n<span class=\"govuk-visually-hidden\">Scheme</span>" expected_html = "<a class=\"govuk-link\" href=\"/schemes/#{scheme.id}\">#{scheme.service_name}</a>\n<span class=\"govuk-visually-hidden\">Scheme</span>"
expect(scheme_cell(scheme)).to match(expected_html) expect(CGI.unescapeHTML(scheme_cell(scheme))).to match(expected_html)
end end
end end
end end

36
spec/models/validations/soft_validations_spec.rb

@ -265,6 +265,24 @@ RSpec.describe Validations::SoftValidations do
expect(record.major_repairs_date_in_soft_range?).to be false expect(record.major_repairs_date_in_soft_range?).to be false
end end
end end
context "with 2025 logs" do
context "when the void date is within 20 years of the tenancy start date" do
it "shows the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.mrcdate = Time.zone.local(2007, 2, 1)
expect(record.major_repairs_date_in_soft_range?).to be true
end
end
context "when the void date is less than 2 years before the tenancy start date" do
it "does not show the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.mrcdate = Time.zone.local(2025, 2, 1)
expect(record.major_repairs_date_in_soft_range?).to be false
end
end
end
end end
describe "void date soft validations" do describe "void date soft validations" do
@ -283,6 +301,24 @@ RSpec.describe Validations::SoftValidations do
expect(record.voiddate_in_soft_range?).to be false expect(record.voiddate_in_soft_range?).to be false
end end
end end
context "with 2025 logs" do
context "when the void date is within 20 years of the tenancy start date" do
it "shows the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.voiddate = Time.zone.local(2007, 2, 1)
expect(record.voiddate_in_soft_range?).to be true
end
end
context "when the void date is less than 2 years before the tenancy start date" do
it "does not show the interruption screen" do
record.startdate = Time.zone.local(2026, 2, 1)
record.voiddate = Time.zone.local(2025, 2, 1)
expect(record.voiddate_in_soft_range?).to be false
end
end
end
end end
describe "old persons shared ownership soft validations" do describe "old persons shared ownership soft validations" do

88
spec/requests/address_search_controller_spec.rb

@ -25,7 +25,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(sales_log.town_or_city).to eq(nil) expect(sales_log.town_or_city).to eq(nil)
expect(sales_log.la).to eq(nil) expect(sales_log.la).to eq(nil)
get "/address-search/manual-input/sales_log/#{sales_log.id}" get address_manual_input_path(log_type: "sales_log", log_id: sales_log.id)
sales_log.reload sales_log.reload
expect(sales_log.manual_address_entry_selected).to eq(true) expect(sales_log.manual_address_entry_selected).to eq(true)
@ -58,7 +58,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(lettings_log.town_or_city).to eq("London") expect(lettings_log.town_or_city).to eq("London")
expect(lettings_log.la).to eq("E09000033") expect(lettings_log.la).to eq("E09000033")
get "/address-search/manual-input/lettings_log/#{lettings_log.id}" get address_manual_input_path(log_type: "lettings_log", log_id: lettings_log.id)
lettings_log.reload lettings_log.reload
expect(lettings_log.manual_address_entry_selected).to eq(true) expect(lettings_log.manual_address_entry_selected).to eq(true)
@ -94,7 +94,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(lettings_log.town_or_city).to eq(nil) expect(lettings_log.town_or_city).to eq(nil)
expect(lettings_log.la).to eq(nil) expect(lettings_log.la).to eq(nil)
get "/address-search/search-input/lettings_log/#{lettings_log.id}" get address_search_input_path(log_type: "lettings_log", log_id: lettings_log.id)
lettings_log.reload lettings_log.reload
expect(lettings_log.manual_address_entry_selected).to eq(false) expect(lettings_log.manual_address_entry_selected).to eq(false)
@ -128,7 +128,7 @@ RSpec.describe AddressSearchController, type: :request do
expect(sales_log.town_or_city).to eq("Test Town") expect(sales_log.town_or_city).to eq("Test Town")
expect(sales_log.la).to eq("E09000033") expect(sales_log.la).to eq("E09000033")
get "/address-search/search-input/sales_log/#{sales_log.id}" get address_search_input_path(log_type: "sales_log", log_id: sales_log.id)
sales_log.reload sales_log.reload
expect(sales_log.manual_address_entry_selected).to eq(false) expect(sales_log.manual_address_entry_selected).to eq(false)
@ -150,7 +150,7 @@ RSpec.describe AddressSearchController, type: :request do
context "and theres no uprn returned" do context "and theres no uprn returned" do
before do before do
body = { results: [{ DPA: { "ADDRESS": "1, Test Street", "UPRN": "123" } }] }.to_json body = { results: [{ DPA: { "ADDRESS": "100, Test Street", "UPRN": "100" } }] }.to_json
uprn_body = { results: [{ DPA: nil }] }.to_json uprn_body = { results: [{ DPA: nil }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100") WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100")
.to_return(status: 200, body:, headers: {}) .to_return(status: 200, body:, headers: {})
@ -159,30 +159,94 @@ RSpec.describe AddressSearchController, type: :request do
end end
it "returns the address results" do it "returns the address results" do
get "/address-search?query=100" get address_search_path, params: { query: "100" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to eq([{ text: "1, Test Street", value: "123" }].to_json) expect(response.body).to eq([{ text: "100, Test Street", value: "100" }].to_json)
end end
end end
context "and theres no address returned" do context "and theres no address returned" do
before do before do
body = { results: [{ DPA: nil }] }.to_json body = { results: [{ DPA: nil }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "2, Test Street", UPRN: "321" } }] }.to_json uprn_body = { results: [{ DPA: { "ADDRESS": "321, Test Street", UPRN: "321" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=100") WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=321")
.to_return(status: 200, body:, headers: {}) .to_return(status: 200, body:, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=100") WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=321")
.to_return(status: 200, body: uprn_body, headers: {}) .to_return(status: 200, body: uprn_body, headers: {})
end end
it "returns the address results" do it "returns the address results" do
get "/address-search?query=100" get address_search_path, params: { query: "321" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to eq([{ text: "2, Test Street", value: "321" }].to_json) expect(response.body).to eq([{ text: "321, Test Street", value: "321" }].to_json)
end end
end end
end end
end end
describe "GET #index" do
context "when query is nil" do
it "returns a bad request error" do
get address_search_path, params: { query: nil }
expect(response).to have_http_status(:bad_request)
expect(response.body).to include("Query cannot be blank.")
end
end
context "when query is all numbers and greater than 5 digits" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "2, Test Street", UPRN: "123456" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=123456")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=123456")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "assumes it's a UPRN and returns the address" do
get address_search_path, params: { query: "123456" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("2, Test Street")
expect(response.body).not_to include("Path not taken")
end
end
context "when query contains any non-digit characters" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "70, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "Path not taken", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=70,")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=70,")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "assumes it's an address and returns the address results" do
get address_search_path, params: { query: "70," }
expect(response).to have_http_status(:ok)
expect(response.body).to include("70, Test Street")
expect(response.body).not_to include("Path not taken")
end
end
context "when query is ambiguous" do
before do
address_body = { results: [{ DPA: { "ADDRESS": "111, Test Street", UPRN: "123777" } }] }.to_json
uprn_body = { results: [{ DPA: { "ADDRESS": "70 Bean Road", UPRN: "111" } }] }.to_json
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/find?key=OS_DATA_KEY&maxresults=10&minmatch=0.2&query=111")
.to_return(status: 200, body: address_body, headers: {})
WebMock.stub_request(:get, "https://api.os.uk/search/places/v1/uprn?dataset=DPA,LPI&key=OS_DATA_KEY&uprn=111")
.to_return(status: 200, body: uprn_body, headers: {})
end
it "uses both APIs and merges results" do
get address_search_path, params: { query: "111" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("111, Test Street")
expect(response.body).to include("70 Bean Road")
end
end
end
end end

2
spec/requests/bulk_upload_lettings_results_controller_spec.rb

@ -138,7 +138,7 @@ RSpec.describe BulkUploadLettingsResultsController, type: :request do
get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary" get "/lettings-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.") expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.") expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end end
end end

4
spec/requests/bulk_upload_sales_results_controller_spec.rb

@ -30,7 +30,7 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary" get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.") expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.") expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end end
end end
@ -113,7 +113,7 @@ RSpec.describe BulkUploadSalesResultsController, type: :request do
get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary" get "/sales-logs/bulk-upload-results/#{bulk_upload.id}/summary"
expect(response.body).to include("This error report is out of date.") expect(response.body).to include("This error report is out of date.")
expect(response.body).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.") expect(CGI.unescapeHTML(response.body)).to include("Some logs in this upload are assigned to #{other_user.name}, who has moved to a different organisation since this file was uploaded. Upload the file again to get an accurate error report.")
end end
end end

247
spec/services/exports/export_service_spec.rb

@ -9,12 +9,14 @@ RSpec.describe Exports::ExportService do
let(:user) { FactoryBot.create(:user, email: "test1@example.com") } let(:user) { FactoryBot.create(:user, email: "test1@example.com") }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: {}) } let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: {}) }
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: {}) } let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: {}) }
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
before do before do
Timecop.freeze(start_time) Timecop.freeze(start_time)
Singleton.__init__(FormHandler) Singleton.__init__(FormHandler)
allow(storage_service).to receive(:write_file) allow(storage_service).to receive(:write_file)
allow(Exports::LettingsLogExportService).to receive(:new).and_return(lettings_logs_export_service) allow(Exports::LettingsLogExportService).to receive(:new).and_return(lettings_logs_export_service)
allow(Exports::SalesLogExportService).to receive(:new).and_return(sales_logs_export_service)
allow(Exports::UserExportService).to receive(:new).and_return(users_export_service) allow(Exports::UserExportService).to receive(:new).and_return(users_export_service)
allow(Exports::OrganisationExportService).to receive(:new).and_return(organisations_export_service) allow(Exports::OrganisationExportService).to receive(:new).and_return(organisations_export_service)
end end
@ -23,7 +25,9 @@ RSpec.describe Exports::ExportService do
Timecop.return Timecop.return
end end
context "when exporting daily XMLs" do context "when exporting daily XMLs before 2025" do
let(:start_time) { Time.zone.local(2022, 5, 1) }
context "and no lettings archives get created in lettings logs export" do context "and no lettings archives get created in lettings logs export" do
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) } let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
@ -193,6 +197,64 @@ RSpec.describe Exports::ExportService do
end end
end end
end end
context "and multiple sales archives get created in sales logs export" do
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time, "second_file_base_name" => start_time }) }
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time, "second_sales_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_user_file_base_name,2022-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2022-05-01 00:00:00 +0100,second_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user and organisation archives gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: { "some_organisation_file_base_name" => start_time, "second_organisation_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2022-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_user_file_base_name,2022-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2022-05-01 00:00:00 +0100,second_user_file_base_name.zip\nsome_organisation_file_base_name,2022-05-01 00:00:00 +0100,some_organisation_file_base_name.zip\nsecond_organisation_file_base_name,2022-05-01 00:00:00 +0100,second_organisation_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
end end
context "when exporting specific lettings log collection" do context "when exporting specific lettings log collection" do
@ -204,7 +266,7 @@ RSpec.describe Exports::ExportService do
it "generates a master manifest with the correct name" do it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "2022") export_service.export_xml(full_update: true, collection: "lettings", year: "2022")
end end
it "does not write user data" do it "does not write user data" do
@ -212,7 +274,7 @@ RSpec.describe Exports::ExportService do
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n" expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string } allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "2022") export_service.export_xml(full_update: true, collection: "lettings", year: "2022")
expect(actual_content).to eq(expected_content) expect(actual_content).to eq(expected_content)
end end
end end
@ -226,7 +288,7 @@ RSpec.describe Exports::ExportService do
it "generates a master manifest with the correct name" do it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "2023") export_service.export_xml(full_update: true, collection: "lettings", year: "2023")
end end
it "does not write user data" do it "does not write user data" do
@ -234,7 +296,7 @@ RSpec.describe Exports::ExportService do
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\n" expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2022-05-01 00:00:00 +0100,some_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string } allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "2023") export_service.export_xml(full_update: true, collection: "lettings", year: "2023")
expect(actual_content).to eq(expected_content) expect(actual_content).to eq(expected_content)
end end
end end
@ -330,4 +392,179 @@ RSpec.describe Exports::ExportService do
end end
end end
end end
context "with date after 2025-04-01" do
let(:start_time) { Time.zone.local(2025, 5, 1) }
let(:expected_master_manifest_filename) { "Manifest_2025_05_01_0001.csv" }
context "when exporting daily XMLs" do
context "and no sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
context "and no user or organisation archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers but no data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
context "and one sales archive gets created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time }) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and one user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
context "and multiple sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time, "second_sales_file_base_name" => start_time }) }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: { "some_file_base_name" => start_time, "second_file_base_name" => start_time }) }
context "and no user archives get created in user export" do
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2025-05-01 00:00:00 +0100,second_user_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
context "and multiple user and organisation archives gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time, "second_user_file_base_name" => start_time }) }
let(:organisations_export_service) { instance_double("Exports::OrganisationExportService", export_xml_organisations: { "some_organisation_file_base_name" => start_time, "second_organisation_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml
end
it "generates a master manifest with CSV headers and correct data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_file_base_name,2025-05-01 00:00:00 +0100,some_file_base_name.zip\nsecond_file_base_name,2025-05-01 00:00:00 +0100,second_file_base_name.zip\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\nsecond_sales_file_base_name,2025-05-01 00:00:00 +0100,second_sales_file_base_name.zip\nsome_user_file_base_name,2025-05-01 00:00:00 +0100,some_user_file_base_name.zip\nsecond_user_file_base_name,2025-05-01 00:00:00 +0100,second_user_file_base_name.zip\nsome_organisation_file_base_name,2025-05-01 00:00:00 +0100,some_organisation_file_base_name.zip\nsecond_organisation_file_base_name,2025-05-01 00:00:00 +0100,second_organisation_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml
expect(actual_content).to eq(expected_content)
end
end
end
end
end
context "when exporting specific sales log collection" do
let(:start_time) { Time.zone.local(2025, 5, 1) }
let(:expected_master_manifest_filename) { "Manifest_2025_05_01_0001.csv" }
let(:lettings_logs_export_service) { instance_double("Exports::LettingsLogExportService", export_xml_lettings_logs: {}) }
context "and no sales archives get created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: {}) }
context "and user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "sales", year: "2022")
end
it "does not write user data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "sales", year: "2022")
expect(actual_content).to eq(expected_content)
end
end
end
context "and sales archive gets created in sales logs export" do
let(:sales_logs_export_service) { instance_double("Exports::SalesLogExportService", export_xml_sales_logs: { "some_sales_file_base_name" => start_time }) }
context "and user archive gets created in user export" do
let(:users_export_service) { instance_double("Exports::UserExportService", export_xml_users: { "some_user_file_base_name" => start_time }) }
it "generates a master manifest with the correct name" do
expect(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args)
export_service.export_xml(full_update: true, collection: "sales", year: "2023")
end
it "does not write user data" do
actual_content = nil
expected_content = "zip-name,date-time zipped folder generated,zip-file-uri\nsome_sales_file_base_name,2025-05-01 00:00:00 +0100,some_sales_file_base_name.zip\n"
allow(storage_service).to receive(:write_file).with(expected_master_manifest_filename, any_args) { |_, arg2| actual_content = arg2&.string }
export_service.export_xml(full_update: true, collection: "sales", year: "2023")
expect(actual_content).to eq(expected_content)
end
end
end
end
end end

32
spec/services/exports/lettings_log_export_service_spec.rb

@ -448,6 +448,38 @@ RSpec.describe Exports::LettingsLogExportService do
end end
end end
end end
context "with 25/26 collection period" do
let(:start_time) { Time.zone.local(2025, 4, 3) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one lettings log is available for export" do
let!(:lettings_log) { FactoryBot.create(:lettings_log, :completed, startdate: Time.zone.local(2025, 4, 3), assigned_to: user, age1: 35, sex1: "F", age2: 32, sex2: "M", ppostcode_full: "A1 1AA", nationality_all_group: 13, propcode: "123", postcode_full: "SE2 6RT", tenancycode: "BZ737", voiddate: Time.zone.local(2021, 11, 3), mrcdate: Time.zone.local(2022, 5, 5, 10, 36, 49), tenancylength: 5, underoccupation_benefitcap: 4, creation_method: 2, bulk_upload_id: 1, address_line1_as_entered: "address line 1 as entered", address_line2_as_entered: "address line 2 as entered", town_or_city_as_entered: "town or city as entered", county_as_entered: "county as entered", postcode_full_as_entered: "AB1 2CD", la_as_entered: "la as entered", manual_address_entry_selected: false, uprn: "1", uprn_known: 1) }
let(:expected_zip_filename) { "core_2025_2026_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_2025_2026_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/general_needs_log_25_26.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(lettings_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_lettings_logs
end
end
end
end end
context "when exporting a supported housing lettings logs in XML" do context "when exporting a supported housing lettings logs in XML" do

425
spec/services/exports/sales_log_export_service_spec.rb

@ -0,0 +1,425 @@
require "rails_helper"
RSpec.describe Exports::SalesLogExportService do
subject(:export_service) { described_class.new(storage_service, start_time) }
let(:storage_service) { instance_double(Storage::S3Service) }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log.xml", "r:UTF-8") }
let(:local_manifest_file) { File.open("spec/fixtures/exports/manifest.xml", "r:UTF-8") }
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0001_pt001.xml" }
let(:expected_manifest_filename) { "manifest.xml" }
let(:start_time) { Time.zone.local(2026, 3, 1) }
let(:organisation) { create(:organisation, name: "MHCLG", housing_registration_no: 1234) }
let(:user) { FactoryBot.create(:user, email: "test1@example.com", organisation:) }
def replace_entity_ids(sales_log, export_template)
export_template.sub!(/\{owning_org_id\}/, sales_log["owning_organisation_id"].to_s)
export_template.sub!(/\{owning_org_name\}/, sales_log.owning_organisation.name.to_s)
export_template.sub!(/\{managing_org_id\}/, sales_log["managing_organisation_id"].to_s)
export_template.sub!(/\{managing_org_name\}/, sales_log.managing_organisation.name.to_s)
export_template.sub!(/\{assigned_to_id\}/, sales_log["assigned_to_id"].to_s)
export_template.sub!(/\{assigned_to_email\}/, sales_log.assigned_to&.email.to_s)
export_template.sub!(/\{created_by_id\}/, sales_log["created_by_id"].to_s)
export_template.sub!(/\{created_by_email\}/, sales_log.created_by&.email.to_s)
export_template.sub!(/\{id\}/, sales_log["id"].to_s)
end
def replace_record_number(export_template, record_number)
export_template.sub!(/\{recno\}/, record_number.to_s)
end
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
allow(storage_service).to receive(:write_file)
end
after do
Timecop.return
end
context "when exporting daily sales logs in XML" do
context "and no sales logs are available for export" do
it "returns an empty archives list" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
context "when one pending sales log exists" do
before do
FactoryBot.create(
:sales_log,
:export,
status: "pending",
skip_update_status: true,
)
end
it "returns empty archives list for archives manifest" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, assigned_to: user) }
it "generates a ZIP export file with the expected filename" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
export_service.export_xml_sales_logs
end
it "generates an XML export file with the expected filename within the ZIP file" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.name).to eq(expected_data_filename)
end
export_service.export_xml_sales_logs
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 1)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "returns the list with correct archive" do
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "") => start_time })
end
end
context "and multiple sales logs are available for export on different periods" do
let(:previous_zip_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001.zip" }
let(:next_zip_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001.zip" }
before do
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2024, 5, 1))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 5, 1))
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2026, 4, 1))
end
context "when sales logs are across multiple years" do
it "generates multiple ZIP export files with the expected filenames" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
expect(storage_service).not_to receive(:write_file).with(previous_zip_filename, any_args)
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2025")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2025_2026_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2025_2026_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2025_2026_apr_mar_f0001_inc0001.zip")
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs
end
it "generates zip export files only for specified year" do
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs(collection_year: 2026)
end
context "and previous full exports are different for previous years" do
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0007_inc0004.zip" }
let(:next_zip_filename) { "core_sales_2026_2027_apr_mar_f0001_inc0001.zip" }
before do
Export.new(started_at: Time.zone.yesterday, base_number: 7, increment_number: 3, collection: "sales", year: 2025).save!
end
it "generates multiple ZIP export files with different base numbers in the filenames" do
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args)
expect(storage_service).to receive(:write_file).with(next_zip_filename, any_args)
expect(Rails.logger).to receive(:info).with("Building export run for sales 2025")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2025_2026_apr_mar_f0007_inc0004 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2025_2026_apr_mar_f0007_inc0004_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2025_2026_apr_mar_f0007_inc0004.zip")
expect(Rails.logger).to receive(:info).with("Building export run for sales 2026")
expect(Rails.logger).to receive(:info).with("Creating core_sales_2026_2027_apr_mar_f0001_inc0001 - 1 resources")
expect(Rails.logger).to receive(:info).with("Added core_sales_2026_2027_apr_mar_f0001_inc0001_pt001.xml")
expect(Rails.logger).to receive(:info).with("Writing core_sales_2026_2027_apr_mar_f0001_inc0001.zip")
export_service.export_xml_sales_logs
end
end
end
end
context "and multiple sales logs are available for export on same quarter" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 4, 1))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 4, 20))
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 2)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
it "creates a logs export record in a database with correct time" do
expect { export_service.export_xml_sales_logs }
.to change(Export, :count).by(2)
expect(Export.last.started_at).to be_within(2.seconds).of(start_time)
end
context "when this is the first export (full)" do
it "returns a ZIP archive for the master manifest (existing sales logs)" do
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "").gsub(".zip", "") => start_time })
end
end
context "and underlying data changes between getting the logs and writting the manifest" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1))
FactoryBot.create(:sales_log, :ignore_validation_errors, saledate: Time.zone.local(2026, 4, 1))
end
def remove_logs(logs)
logs.each(&:destroy)
file = Tempfile.new
doc = Nokogiri::XML("<forms/>")
doc.write_xml_to(file, encoding: "UTF-8")
file.rewind
file
end
def create_fake_maifest
file = Tempfile.new
doc = Nokogiri::XML("<forms/>")
doc.write_xml_to(file, encoding: "UTF-8")
file.rewind
file
end
it "maintains the same record number" do
# rubocop:disable RSpec/SubjectStub
allow(export_service).to receive(:build_export_xml) do |logs|
remove_logs(logs)
end
allow(export_service).to receive(:build_manifest_xml) do
create_fake_maifest
end
expect(export_service).to receive(:build_manifest_xml).with(1)
# rubocop:enable RSpec/SubjectStub
export_service.export_xml_sales_logs
end
end
context "when this is a second export (partial)" do
before do
start_time = Time.zone.local(2026, 6, 1)
Export.new(started_at: start_time, collection: "sales", year: 2025).save!
end
it "does not add any entry for the master manifest (no sales logs)" do
expect(storage_service).not_to receive(:write_file)
expect(export_service.export_xml_sales_logs).to eq({})
end
end
end
context "and a previous export has run the same day having sales logs" do
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2025, 5, 1))
export_service.export_xml_sales_logs
end
context "and we trigger another full update" do
it "increments the base number" do
export_service.export_xml_sales_logs(full_update: true)
expect(Export.last.base_number).to eq(2)
end
it "resets the increment number" do
export_service.export_xml_sales_logs(full_update: true)
expect(Export.last.increment_number).to eq(1)
end
it "returns a correct archives list for manifest file" do
expect(export_service.export_xml_sales_logs(full_update: true)).to eq({ "core_sales_2025_2026_apr_mar_f0002_inc0001" => start_time })
end
it "generates a ZIP export file with the expected filename" do
expect(storage_service).to receive(:write_file).with("core_sales_2025_2026_apr_mar_f0002_inc0001.zip", any_args)
export_service.export_xml_sales_logs(full_update: true)
end
end
end
context "and a previous export has run having no sales logs" do
before { export_service.export_xml_sales_logs }
it "doesn't increment the manifest number by 1" do
export_service.export_xml_sales_logs
expect(Export.last.increment_number).to eq(1)
end
end
context "and a log has been manually updated since the previous partial export" do
let(:expected_zip_filename) { "core_sales_2025_2026_apr_mar_f0001_inc0002.zip" }
before do
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1), updated_at: Time.zone.local(2026, 2, 27), values_updated_at: Time.zone.local(2026, 2, 29))
FactoryBot.create(:sales_log, saledate: Time.zone.local(2026, 2, 1), updated_at: Time.zone.local(2026, 2, 27), values_updated_at: Time.zone.local(2026, 2, 29))
Export.create!(started_at: Time.zone.local(2026, 2, 28), base_number: 1, increment_number: 1, collection: "sales", year: 2025)
end
it "generates an XML manifest file with the expected content within the ZIP file" do
expected_content = replace_record_number(local_manifest_file.read, 2)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_manifest_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
expect(export_service.export_xml_sales_logs).to eq({ expected_zip_filename.gsub(".zip", "") => start_time })
end
end
context "and one sales log with duplicate reference is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, duplicate_set_id: 123) }
def replace_duplicate_set_id(export_file)
export_file.sub!("<DUPLICATESET/>", "<DUPLICATESET>123</DUPLICATESET>")
end
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_duplicate_set_id(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
context "when exporting only 24/25 collection period" do
let(:start_time) { Time.zone.local(2024, 4, 3) }
before do
Timecop.freeze(start_time)
Singleton.__init__(FormHandler)
end
after do
Timecop.unfreeze
Singleton.__init__(FormHandler)
end
context "and one sales log is available for export" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export) }
let(:expected_zip_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001.zip" }
let(:expected_data_filename) { "core_sales_2024_2025_apr_mar_f0001_inc0001_pt001.xml" }
let(:xml_export_file) { File.open("spec/fixtures/exports/sales_log_2024.xml", "r:UTF-8") }
it "generates an XML export file with the expected content within the ZIP file" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs(full_update: true, collection_year: 2024)
end
end
end
context "when exporting various fees, correctly maps the values" do
context "with discounted ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, mscharge: 123) }
def replace_mscharge_value(export_file)
export_file.sub!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE>123.0</MSCHARGE>")
end
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_mscharge_value(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
context "with shared ownership and mscharge" do
let!(:sales_log) { FactoryBot.create(:sales_log, :export, ownershipsch: 1, staircase: 2, type: 30, mscharge: 321, has_management_fee: 1, management_fee: 222) }
def replace_mscharge_and_shared_ownership_values(export_file)
export_file.sub!("<HASSERVICECHARGES/>", "<HASSERVICECHARGES>1</HASSERVICECHARGES>")
export_file.sub!("<SERVICECHARGES/>", "<SERVICECHARGES>321.0</SERVICECHARGES>")
export_file.sub!("<HASESTATEFEE/>", "<HASESTATEFEE>1</HASESTATEFEE>")
export_file.sub!("<ESTATEFEE/>", "<ESTATEFEE>222.0</ESTATEFEE>")
export_file.sub!("<MSCHARGE>100.0</MSCHARGE>", "<MSCHARGE/>")
export_file.sub!("<HASMSCHARGE>1</HASMSCHARGE>", "<HASMSCHARGE/>")
export_file.sub!("<TYPE>8</TYPE>", "<TYPE>30</TYPE>")
export_file.sub!("<STAIRCASE/>", "<STAIRCASE>2</STAIRCASE>")
export_file.sub!("<GRANT>10000.0</GRANT>", "<GRANT/>")
export_file.sub!("<PPCODENK>0</PPCODENK>", "<PPCODENK>1</PPCODENK>")
export_file.sub!("<PPOSTC1>SW1A</PPOSTC1>", "<PPOSTC1/>")
export_file.sub!("<PPOSTC2>1AA</PPOSTC2>", "<PPOSTC2/>")
export_file.sub!("<PREVLOC>E09000033</PREVLOC>", "<PREVLOC/>")
export_file.sub!("<EXTRABOR>1</EXTRABOR>", "<EXTRABOR/>")
export_file.sub!("<OWNERSHIP>2</OWNERSHIP>", "<OWNERSHIP>1</OWNERSHIP>")
export_file.sub!("<PREVIOUSLAKNOWN>1</PREVIOUSLAKNOWN>", "<PREVIOUSLAKNOWN>0</PREVIOUSLAKNOWN>")
export_file.sub!("<PREVLOCNAME>Westminster</PREVLOCNAME>", "<PREVLOCNAME/>")
end
it "exports mscharge fields as hasmscharge and mscharge" do
expected_content = replace_entity_ids(sales_log, xml_export_file.read)
expected_content = replace_mscharge_and_shared_ownership_values(expected_content)
expect(storage_service).to receive(:write_file).with(expected_zip_filename, any_args) do |_, content|
entry = Zip::File.open_buffer(content).find_entry(expected_data_filename)
expect(entry).not_to be_nil
expect(entry.get_input_stream.read).to eq(expected_content)
end
export_service.export_xml_sales_logs
end
end
end
end
end
Loading…
Cancel
Save