@@ -51,6 +22,7 @@
<%= render AppSessionNeedsReviewWarningComponent.new(session: @session) %>
+ <%= render AppVaccinationsSummaryTableComponent.new(current_user:, session: @session, request_session: session) %>
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %>
<% @patient_sessions.each do |patient_session| %>
<%= render AppPatientSessionSearchResultCardComponent.new(
diff --git a/app/views/vaccination_reports/file_format.html.erb b/app/views/vaccination_reports/file_format.html.erb
index 84774f6a9e..8cd313a710 100644
--- a/app/views/vaccination_reports/file_format.html.erb
+++ b/app/views/vaccination_reports/file_format.html.erb
@@ -8,7 +8,11 @@
<%= form_with model: @vaccination_report, url: wizard_path, method: :put do |f| %>
<%= f.govuk_error_summary %>
- <%= f.govuk_collection_radio_buttons :file_format, VaccinationReport.file_formats(@programme), :itself, legend: { text: title, size: "l", tag: "h1" }, caption: { text: @programme.name } %>
+ <%= f.govuk_collection_radio_buttons :file_format,
+ VaccinationReport::FILE_FORMATS,
+ :itself,
+ legend: { text: title, size: "l", tag: "h1" },
+ caption: { text: @programme.name } %>
<%= f.govuk_submit %>
<% end %>
diff --git a/config/credentials/staging.yml.enc b/config/credentials/staging.yml.enc
index 152a691011..d127099166 100644
--- a/config/credentials/staging.yml.enc
+++ b/config/credentials/staging.yml.enc
@@ -1 +1 @@
-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--oGZuhmG882bJYh3A--1wFkLK48lRGFfOOQlCJwyw==
\ No newline at end of file
+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--phWJb4+t2Rx6FelG--7d3+ecnoHZtuZdq3kyM4Cg==
\ No newline at end of file
diff --git a/config/environments/test.rb b/config/environments/test.rb
index d91a781d5a..21549b8d52 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -53,4 +53,6 @@
# Enable strict loading to catch N+1 problems.
config.active_record.strict_loading_by_default = true
config.active_record.strict_loading_mode = :n_plus_one_only
+
+ config.middleware.use RackSessionAccess::Middleware
end
diff --git a/config/feature_flags.yml b/config/feature_flags.yml
index 7c9788e2c8..dc5bb54aa7 100644
--- a/config/feature_flags.yml
+++ b/config/feature_flags.yml
@@ -14,7 +14,7 @@ imms_api_sync_job: Flag to switch off any automated sending of vaccination recor
import_choose_academic_year: Add an option to choose the academic year when
importing patients during the preparation period.
-offline_working: Prototype support for using Mavis offline.
+import_low_pds_match_rate: Prevent processing uploads with a low pds match rate.
pds_lookup_during_import: Perform PDS lookups as part of the patient import
processing.
diff --git a/config/locales/en.yml b/config/locales/en.yml
index f799bf7878..6eab080388 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -269,6 +269,11 @@ en:
pending: Sync pending
synced: Synced
failed: Sync failed
+ sources:
+ service: Recorded in Mavis
+ historical_upload: Uploaded as a historical vaccination
+ nhs_immunisations_api: External source such as GP practice
+ consent_refusal: Parent reported already vaccinated
vaccine:
methods:
injection: Injection
@@ -720,6 +725,15 @@ en:
session_dates: Session dates
pre_confirm: Mavis will skip the next automatic reminder if it's scheduled to be sent within 3 days.
confirm: Send manual consent reminders
+ patient_specific_directions:
+ show:
+ eligibility_message:
+ one: >-
+ There is 1 child with consent for the nasal flu vaccine who
+ does not require triage and does not yet have a PSD in place.
+ other: >-
+ There are %{count} children with consent for the nasal flu vaccine
+ who do not require triage and do not yet have a PSD in place.
table:
no_filtered_results: We couldn’t find any children that matched your filters.
no_results: No results
diff --git a/config/routes.rb b/config/routes.rb
index fa915e4a33..40573f46e8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -60,7 +60,8 @@
get "/dashboard", to: "dashboard#index"
get "/accessibility-statement", to: "content#accessibility_statement"
- get "/manifest/:name.json", to: "manifest#show", as: :manifest
+ get "/manifest/:name-:digest.json", to: "manifest#show", as: :manifest
+ get "/manifest/:name.json", to: "manifest#show"
get "/up", to: "rails/health#show", as: :rails_health_check
@@ -270,11 +271,6 @@
constraints -> { Flipper.enabled?(:dev_tools) } do
put "make-in-progress", to: "sessions#make_in_progress"
end
-
- constraints -> { Flipper.enabled?(:offline_working) } do
- get "setup-offline", to: "offline_passwords#new"
- post "setup-offline", to: "offline_passwords#create"
- end
end
resource :dates, controller: "session_dates", only: %i[show update]
@@ -285,7 +281,7 @@
only: [],
module: :patient_sessions do
resource :activity, only: %i[show create]
- resource :session_attendance, path: "attendance", only: %i[edit update]
+ resource :attendance, only: %i[edit update]
resources :programmes, path: "", param: :type, only: :show do
get "record-already-vaccinated"
diff --git a/db/migrate/20250908112554_rename_session_attendance.rb b/db/migrate/20250908112554_rename_session_attendance.rb
new file mode 100644
index 0000000000..9564d9c589
--- /dev/null
+++ b/db/migrate/20250908112554_rename_session_attendance.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class RenameSessionAttendance < ActiveRecord::Migration[8.0]
+ def change
+ rename_table :session_attendances, :attendance_records
+ end
+end
diff --git a/db/migrate/20250908125713_remove_session_from_attendance_record.rb b/db/migrate/20250908125713_remove_session_from_attendance_record.rb
new file mode 100644
index 0000000000..fed23a070e
--- /dev/null
+++ b/db/migrate/20250908125713_remove_session_from_attendance_record.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class RemoveSessionFromAttendanceRecord < ActiveRecord::Migration[8.0]
+ def up
+ change_table :attendance_records, bulk: true do |t|
+ t.date :date
+ t.references :location, foreign_key: true
+ end
+
+ execute <<-SQL
+ UPDATE attendance_records
+ SET location_id = sessions.location_id, date = session_dates.value
+ FROM session_dates
+ JOIN sessions ON sessions.id = session_dates.session_id
+ WHERE session_dates.id = attendance_records.session_date_id
+ SQL
+
+ change_table :attendance_records, bulk: true do |t|
+ t.change_null :date, false
+ t.change_null :location_id, false
+ end
+
+ remove_column :attendance_records, :session_date_id
+
+ execute <<-SQL
+ DELETE FROM attendance_records a
+ USING attendance_records b
+ WHERE a.id < b.id
+ AND a.patient_id = b.patient_id
+ AND a.location_id = b.location_id
+ AND a.date = b.date
+ SQL
+
+ add_index :attendance_records, %i[patient_id location_id date], unique: true
+ end
+end
diff --git a/db/migrate/20250912134432_drop_offline_passwords.rb b/db/migrate/20250912134432_drop_offline_passwords.rb
new file mode 100644
index 0000000000..82dfa34be5
--- /dev/null
+++ b/db/migrate/20250912134432_drop_offline_passwords.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DropOfflinePasswords < ActiveRecord::Migration[8.0]
+ def up
+ drop_table :offline_passwords
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 86ccc8931d..e0520ce23d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_09_095902) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_12_134432) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm"
@@ -49,6 +49,18 @@
t.index ["team_id"], name: "index_archive_reasons_on_team_id"
end
+ create_table "attendance_records", force: :cascade do |t|
+ t.boolean "attending", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "patient_id", null: false
+ t.date "date", null: false
+ t.bigint "location_id", null: false
+ t.index ["location_id"], name: "index_attendance_records_on_location_id"
+ t.index ["patient_id", "location_id", "date"], name: "idx_on_patient_id_location_id_date_e5912f40c4", unique: true
+ t.index ["patient_id"], name: "index_attendance_records_on_patient_id"
+ end
+
create_table "audits", force: :cascade do |t|
t.integer "auditable_id"
t.string "auditable_type"
@@ -554,12 +566,6 @@
t.index ["sent_by_user_id"], name: "index_notify_log_entries_on_sent_by_user_id"
end
- create_table "offline_passwords", force: :cascade do |t|
- t.string "password", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- end
-
create_table "organisations", force: :cascade do |t|
t.string "ods_code", null: false
t.datetime "created_at", null: false
@@ -839,17 +845,6 @@
t.index ["team_id"], name: "index_school_moves_on_team_id"
end
- create_table "session_attendances", force: :cascade do |t|
- t.bigint "session_date_id", null: false
- t.boolean "attending", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.bigint "patient_id", null: false
- t.index ["patient_id", "session_date_id"], name: "index_session_attendances_on_patient_id_and_session_date_id", unique: true
- t.index ["patient_id"], name: "index_session_attendances_on_patient_id"
- t.index ["session_date_id"], name: "index_session_attendances_on_session_date_id"
- end
-
create_table "session_dates", force: :cascade do |t|
t.bigint "session_id", null: false
t.date "value", null: false
@@ -1055,6 +1050,8 @@
add_foreign_key "archive_reasons", "patients"
add_foreign_key "archive_reasons", "teams"
add_foreign_key "archive_reasons", "users", column: "created_by_user_id"
+ add_foreign_key "attendance_records", "locations"
+ add_foreign_key "attendance_records", "patients"
add_foreign_key "batches", "teams"
add_foreign_key "batches", "vaccines"
add_foreign_key "batches_immunisation_imports", "batches"
@@ -1153,8 +1150,6 @@
add_foreign_key "school_moves", "locations", column: "school_id"
add_foreign_key "school_moves", "patients"
add_foreign_key "school_moves", "teams"
- add_foreign_key "session_attendances", "patients"
- add_foreign_key "session_attendances", "session_dates"
add_foreign_key "session_dates", "sessions"
add_foreign_key "session_notifications", "patients"
add_foreign_key "session_notifications", "sessions"
diff --git a/lib/tasks/data_migrations.rake b/lib/tasks/data_migrations.rake
new file mode 100644
index 0000000000..69aa3ba9b3
--- /dev/null
+++ b/lib/tasks/data_migrations.rake
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+namespace :data_migrations do
+ desc "Remove trailing dots from all parent emails"
+ task remove_trailing_dots_from_parent_emails: :environment do
+ parents = Parent.where.not(email: nil).select { it.email.ends_with?(".") }
+
+ puts "#{parents.count} parents with trailing dots in their email addresses"
+
+ parents.each do |parent|
+ email = parent.email.delete_suffix(".")
+ parent.update_column(:email, email)
+ end
+ end
+
+ desc "Removes school moves from any archived patients"
+ task remove_school_moves_from_archived_patients: :environment do
+ puts "#{ArchiveReason.count} archived patients"
+
+ ArchiveReason
+ .includes(:patient, :team)
+ .find_each do |archive_reason|
+ patient = archive_reason.patient
+ team = archive_reason.team
+
+ PatientArchiver.send(:new, patient:, team:, type: nil).send(
+ :destroy_school_moves!
+ )
+ end
+ end
+end
diff --git a/spec/components/app_import_errors_component_spec.rb b/spec/components/app_import_errors_component_spec.rb
index 4fd8ec27df..23822b55ae 100644
--- a/spec/components/app_import_errors_component_spec.rb
+++ b/spec/components/app_import_errors_component_spec.rb
@@ -3,7 +3,7 @@
describe AppImportErrorsComponent do
subject(:rendered) { render_inline(component) }
- let(:component) { described_class.new(errors) }
+ let(:component) { described_class.new(errors:) }
let(:errors) do
[
@@ -20,4 +20,6 @@
it { should have_text("blank") }
it { should have_text("invalid") }
+
+ it { should have_text("Records could not be imported") }
end
diff --git a/spec/components/app_import_pds_unmatched_summary_component_spec.rb b/spec/components/app_import_pds_unmatched_summary_component_spec.rb
new file mode 100644
index 0000000000..85a37269df
--- /dev/null
+++ b/spec/components/app_import_pds_unmatched_summary_component_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+describe AppImportPDSUnmatchedSummaryComponent, type: :component do
+ let(:import) { create(:cohort_import) }
+
+ let(:rendered) { render_inline(component) }
+
+ let(:component) { described_class.new(changesets: changesets) }
+
+ let(:changesets) { [changeset] }
+
+ let(:changeset) do
+ create(
+ :patient_changeset,
+ pending_changes: {
+ child: {
+ "given_name" => "Alice",
+ "family_name" => "Smith",
+ "date_of_birth" => Date.new(2010, 5, 15),
+ "address_postcode" => "AB1 2CD"
+ }
+ },
+ import:
+ )
+ end
+
+ it "renders the table headers" do
+ expect(rendered).to have_content("First name")
+ expect(rendered).to have_content("Last name")
+ expect(rendered).to have_content("Date of birth")
+ expect(rendered).to have_content("Postcode")
+ end
+
+ it "renders the record details" do
+ expect(rendered).to have_content("Alice")
+ expect(rendered).to have_content("Smith")
+ expect(rendered).to have_content("15 May 2010")
+ expect(rendered).to have_content("AB1 2CD")
+ end
+
+ context "with multiple records" do
+ let(:changesets) { [changeset, other_changeset] }
+
+ let(:other_changeset) do
+ create(
+ :patient_changeset,
+ pending_changes: {
+ child: {
+ "given_name" => "Bob",
+ "family_name" => "Jones",
+ "date_of_birth" => Date.new(2011, 8, 20),
+ "address_postcode" => "ZZ9 9ZZ"
+ }
+ },
+ import:
+ )
+ end
+
+ it "renders all records" do
+ expect(rendered).to have_content("Alice")
+ expect(rendered).to have_content("Bob")
+ expect(rendered).to have_content("Jones")
+ expect(rendered).to have_content("20 August 2011")
+ expect(rendered).to have_content("ZZ9 9ZZ")
+ end
+ end
+
+ context "when values are blank" do
+ let(:changeset) do
+ create(:patient_changeset, pending_changes: { child: {} }, import:)
+ end
+
+ it "renders empty cells" do
+ expect(rendered).to have_css("table")
+ end
+ end
+end
diff --git a/spec/components/app_patient_session_record_component_spec.rb b/spec/components/app_patient_session_record_component_spec.rb
index b63caa08fd..a2183ec54d 100644
--- a/spec/components/app_patient_session_record_component_spec.rb
+++ b/spec/components/app_patient_session_record_component_spec.rb
@@ -43,10 +43,30 @@
it { should be(false) }
end
+ context "patient is fully vaccinated" do
+ let(:patient) { create(:patient, :vaccinated, programmes:) }
+
+ before { patient.registration_statuses.first.completed! }
+
+ it { should be(false) }
+
+ context "but the session was yesterday" do
+ let(:session) { create(:session, :yesterday, programmes:) }
+
+ it { should be(false) }
+ end
+ end
+
context "session requires no registration" do
let(:session) { create(:session, :requires_no_registration, programmes:) }
it { should be(true) }
+
+ context "but the session was yesterday" do
+ let(:session) { create(:session, :yesterday, programmes:) }
+
+ it { should be(false) }
+ end
end
end
end
diff --git a/spec/components/app_vaccination_record_summary_component_spec.rb b/spec/components/app_vaccination_record_summary_component_spec.rb
index 9de8044be3..13e85014b1 100644
--- a/spec/components/app_vaccination_record_summary_component_spec.rb
+++ b/spec/components/app_vaccination_record_summary_component_spec.rb
@@ -319,6 +319,15 @@
)
end
+ describe "source row" do
+ it do
+ expect(rendered).to have_css(
+ ".nhsuk-summary-list__row",
+ text: "SourceRecorded in Mavis"
+ )
+ end
+ end
+
context "when the notes are not present" do
let(:notes) { nil }
diff --git a/spec/components/app_vaccinations_summary_table_component_spec.rb b/spec/components/app_vaccinations_summary_table_component_spec.rb
new file mode 100644
index 0000000000..4bef1fc76d
--- /dev/null
+++ b/spec/components/app_vaccinations_summary_table_component_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+describe AppVaccinationsSummaryTableComponent do
+ subject(:rendered) { render_inline(component) }
+
+ let(:request_session) { {} }
+ let(:current_user) { build(:user) }
+
+ let(:flu_programme) { create(:programme, :flu, vaccines: []) }
+ let(:hpv_programme) { create(:programme, :hpv, vaccines: []) }
+ let(:programmes) { [hpv_programme] }
+ let(:session) { create(:session, :today, programmes:, team:) }
+ let(:team) { create(:team, :with_generic_clinic, programmes:) }
+
+ let(:component) do
+ described_class.new(current_user:, session:, request_session:)
+ end
+
+ before { stub_authorization(allowed: true) }
+
+ context "with an active vaccine" do
+ let(:hpv_vaccine) { create(:vaccine, programme: hpv_programme) }
+
+ it { should have_content(hpv_vaccine.brand) }
+ end
+
+ context "with a discontinued vaccine" do
+ let(:hpv_vaccine) do
+ create(:vaccine, :discontinued, programme: hpv_programme)
+ end
+
+ it { should_not have_content(hpv_vaccine.brand) }
+ end
+
+ context "bad data exists where we have Flu vaccination records in an HPV session" do
+ let(:hpv_vaccine) { create(:vaccine, programme: hpv_programme) }
+ let(:flu_vaccine) { create(:vaccine, programme: flu_programme) }
+ let(:hpv_batch) { create(:batch, :not_expired, vaccine: hpv_vaccine) }
+ let(:flu_batch) { create(:batch, :not_expired, vaccine: flu_vaccine) }
+
+ before do
+ create(
+ :vaccination_record,
+ vaccine: hpv_vaccine,
+ batch: hpv_batch,
+ session: session,
+ programme: hpv_programme,
+ performed_by_user: current_user
+ )
+
+ create(
+ :vaccination_record,
+ vaccine: flu_vaccine,
+ batch: flu_batch,
+ session: session,
+ programme: flu_programme,
+ performed_by_user: current_user
+ )
+ end
+
+ it "renders without errors" do
+ expect { rendered }.not_to raise_error
+ end
+ end
+end
diff --git a/spec/factories/attendance_records.rb b/spec/factories/attendance_records.rb
new file mode 100644
index 0000000000..d9f3237e85
--- /dev/null
+++ b/spec/factories/attendance_records.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: attendance_records
+#
+# id :bigint not null, primary key
+# attending :boolean not null
+# date :date not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# location_id :bigint not null
+# patient_id :bigint not null
+#
+# Indexes
+#
+# idx_on_patient_id_location_id_date_e5912f40c4 (patient_id,location_id,date) UNIQUE
+# index_attendance_records_on_location_id (location_id)
+# index_attendance_records_on_patient_id (patient_id)
+#
+# Foreign Keys
+#
+# fk_rails_... (location_id => locations.id)
+# fk_rails_... (patient_id => patients.id)
+#
+FactoryBot.define do
+ factory :attendance_record do
+ patient
+ session
+
+ location { session.location }
+ date { session.dates.first }
+
+ trait :today do
+ date { Date.current }
+ end
+
+ trait :yesterday do
+ date { Date.yesterday }
+ end
+
+ trait :present do
+ attending { true }
+ end
+
+ trait :absent do
+ attending { false }
+ end
+ end
+end
diff --git a/spec/factories/offline_passwords.rb b/spec/factories/offline_passwords.rb
deleted file mode 100644
index 9ceb802ee9..0000000000
--- a/spec/factories/offline_passwords.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-# == Schema Information
-#
-# Table name: offline_passwords
-#
-# id :bigint not null, primary key
-# password :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-#
-FactoryBot.define do
- factory :offline_password do
- password { "MyString" }
- end
-end
diff --git a/spec/factories/patient_changesets.rb b/spec/factories/patient_changesets.rb
index 9da0e1f2a3..68c028d459 100644
--- a/spec/factories/patient_changesets.rb
+++ b/spec/factories/patient_changesets.rb
@@ -57,7 +57,14 @@
},
pds: {
},
- search_results: []
+ search_results: [
+ {
+ step: :no_fuzzy_with_history,
+ result: :no_matches,
+ nhs_number: nil,
+ created_at: Time.current
+ }
+ ]
}
end
@@ -71,6 +78,34 @@
end
end
+ trait :with_pds_match do
+ after(:build) do |changeset|
+ changeset.pending_changes["search_results"] = [
+ {
+ step: :no_fuzzy_with_history,
+ result: :one_match,
+ nhs_number: "1234567890",
+ created_at: Time.current
+ }
+ ]
+ changeset.pds_nhs_number = "1234567890"
+ end
+ end
+
+ trait :without_pds_search_attempted do
+ after(:build) do |changeset|
+ changeset.pending_changes["search_results"] = [
+ {
+ step: :no_fuzzy_with_history,
+ result: :no_postcode,
+ nhs_number: nil,
+ created_at: Time.current
+ }
+ ]
+ changeset.pds_nhs_number = nil
+ end
+ end
+
trait :processed do
status { :processed }
end
diff --git a/spec/factories/patient_sessions.rb b/spec/factories/patient_sessions.rb
index 56f3283050..597abf6630 100644
--- a/spec/factories/patient_sessions.rb
+++ b/spec/factories/patient_sessions.rb
@@ -55,7 +55,7 @@
trait :in_attendance do
after(:create) do |patient_session|
create(
- :session_attendance,
+ :attendance_record,
:present,
patient: patient_session.patient,
session: patient_session.session
diff --git a/spec/factories/patients.rb b/spec/factories/patients.rb
index a626f468b0..9ec9194ffa 100644
--- a/spec/factories/patients.rb
+++ b/spec/factories/patients.rb
@@ -129,7 +129,7 @@
if evaluator.in_attendance
create(
- :session_attendance,
+ :attendance_record,
:present,
patient:,
session: evaluator.session
@@ -234,19 +234,6 @@
end
end
- trait :triage_safe_to_vaccinate_nasal do
- triage_statuses do
- programmes.map do |programme|
- association(
- :patient_triage_status,
- :safe_to_vaccinate_nasal,
- patient: instance,
- programme:
- )
- end
- end
- end
-
trait :triage_required do
triage_statuses do
programmes.map do |programme|
diff --git a/spec/factories/session_attendances.rb b/spec/factories/session_attendances.rb
deleted file mode 100644
index ba17f19af0..0000000000
--- a/spec/factories/session_attendances.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# frozen_string_literal: true
-
-# == Schema Information
-#
-# Table name: session_attendances
-#
-# id :bigint not null, primary key
-# attending :boolean not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# patient_id :bigint not null
-# session_date_id :bigint not null
-#
-# Indexes
-#
-# index_session_attendances_on_patient_id (patient_id)
-# index_session_attendances_on_patient_id_and_session_date_id (patient_id,session_date_id) UNIQUE
-# index_session_attendances_on_session_date_id (session_date_id)
-#
-# Foreign Keys
-#
-# fk_rails_... (patient_id => patients.id)
-# fk_rails_... (session_date_id => session_dates.id)
-#
-FactoryBot.define do
- factory :session_attendance do
- transient { session { association(:session) } }
-
- patient
- session_date { session.session_dates.first }
-
- trait :present do
- attending { true }
- end
-
- trait :absent do
- attending { false }
- end
- end
-end
diff --git a/spec/factories/sessions.rb b/spec/factories/sessions.rb
index 09100f8d1e..99ea8b046b 100644
--- a/spec/factories/sessions.rb
+++ b/spec/factories/sessions.rb
@@ -75,6 +75,10 @@
date { Date.yesterday }
end
+ trait :tomorrow do
+ date { Date.tomorrow }
+ end
+
trait :unscheduled do
date { nil }
end
diff --git a/spec/features/cli_gias_check_import_spec.rb b/spec/features/cli_gias_check_import_spec.rb
index e123a352c3..e3b6412459 100644
--- a/spec/features/cli_gias_check_import_spec.rb
+++ b/spec/features/cli_gias_check_import_spec.rb
@@ -78,25 +78,22 @@ def when_i_run_the_check_import_command
end
def then_i_should_see_the_correct_counts
- expect(@output).to eq <<~OUTPUT
+ expect(@output).to include("New schools (total): 1")
+ expect(@output).to include("Closed schools (total): 1")
+ expect(@output).to include("Proposed to be closed schools (total): 1")
- Progress: | New schools (total): 1
- Closed schools (total): 1
- Proposed to be closed schools (total): 1
+ expect(@output).to include("Existing schools with future sessions: 2")
+ expect(@output).to include("That are closed in import: 1")
+ expect(@output).to include("That have year group changes: 1")
- Existing schools with future sessions: 2
- That are closed in import: 1 (50.0%)
- That are proposed to be closed in import: 1 (50.0%)
- That have year group changes: 1 (50.0%)
-
- URNs of closed schools with future sessions:
- 100000
-
- URNs of schools that will be closing, with future sessions:
- 100002
-
- URNs of schools with year group changes, with future sessions:
- 100002
- OUTPUT
+ expect(@output).to include(
+ "URNs of closed schools with future sessions:\n 100000"
+ )
+ expect(@output).to include(
+ "URNs of schools that will be closing, with future sessions:\n 100002"
+ )
+ expect(@output).to include(
+ "URNs of schools with year group changes, with future sessions:\n 100002"
+ )
end
end
diff --git a/spec/features/cli_stats_consents_by_school_spec.rb b/spec/features/cli_stats_consents_by_school_spec.rb
index aacc3dedce..c3584ce52c 100644
--- a/spec/features/cli_stats_consents_by_school_spec.rb
+++ b/spec/features/cli_stats_consents_by_school_spec.rb
@@ -38,7 +38,7 @@
private
- def command(args = [])
+ def command(*args)
Dry::CLI.new(MavisCLI).call(
arguments: ["stats", "consents-by-school", *args]
)
@@ -159,13 +159,13 @@ def given_organisation_exists
end
def when_i_run_the_command
- @output = capture_output { command(["--ods_code", @organisation.ods_code]) }
+ @output = capture_output { command("--ods_code", @organisation.ods_code) }
end
def when_i_run_the_command_with_flu_programme
@output =
capture_output do
- command(["--ods_code", @organisation.ods_code, "--programme", "flu"])
+ command("--ods_code", @organisation.ods_code, "--programme", "flu")
end
end
@@ -174,12 +174,10 @@ def when_i_run_the_command_with_previous_academic_year
@output =
capture_output do
command(
- [
- "--ods_code",
- @organisation.ods_code,
- "--academic_year",
- previous_year.to_s
- ]
+ "--ods_code",
+ @organisation.ods_code,
+ "--academic_year",
+ previous_year.to_s
)
end
end
@@ -188,30 +186,26 @@ def when_i_run_the_command_with_team_filtering
@output =
capture_output do
command(
- [
- "--ods_code",
- @organisation.ods_code,
- "--workgroup",
- "ImmunisationNorth"
- ]
+ "--ods_code",
+ @organisation.ods_code,
+ "--workgroup",
+ "ImmunisationNorth"
)
end
end
def when_i_run_the_command_with_invalid_organisation
- @output = capture_error { command(%w[--ods_code INVALID123]) }
+ @output = capture_error { command("--ods_code", "INVALID123") }
end
def when_i_run_the_command_with_invalid_team
@output =
capture_error do
command(
- [
- "--ods_code",
- @organisation.ods_code,
- "--workgroup",
- "InvalidTeamName"
- ]
+ "--ods_code",
+ @organisation.ods_code,
+ "--workgroup",
+ "InvalidTeamName"
)
end
end
diff --git a/spec/features/cli_stats_organisations_spec.rb b/spec/features/cli_stats_organisations_spec.rb
index 4c33d250e2..0f28e9f2b2 100644
--- a/spec/features/cli_stats_organisations_spec.rb
+++ b/spec/features/cli_stats_organisations_spec.rb
@@ -62,7 +62,7 @@
private
- def command(args = [])
+ def command(*args)
Dry::CLI.new(MavisCLI).call(arguments: ["stats", "organisations", *args])
end
@@ -189,15 +189,13 @@ def given_organisation_has_no_data
end
def when_i_run_the_command
- @output = capture_output { command(["--ods_code", @organisation.ods_code]) }
+ @output = capture_output { command("--ods_code", @organisation.ods_code) }
end
def when_i_run_the_command_with_programme_filter(programme)
@output =
capture_output do
- command(
- ["--ods_code", @organisation.ods_code, "--programme", programme]
- )
+ command("--ods_code", @organisation.ods_code, "--programme", programme)
end
end
@@ -206,12 +204,10 @@ def when_i_run_the_command_with_academic_year_filter
@output =
capture_output do
command(
- [
- "--ods_code",
- @organisation.ods_code,
- "--academic_year",
- previous_year.to_s
- ]
+ "--ods_code",
+ @organisation.ods_code,
+ "--academic_year",
+ previous_year.to_s
)
end
end
@@ -219,28 +215,29 @@ def when_i_run_the_command_with_academic_year_filter
def when_i_run_the_command_with_team_filter(team_name)
@output =
capture_output do
- command(
- ["--ods_code", @organisation.ods_code, "--team_name", team_name]
- )
+ command("--ods_code", @organisation.ods_code, "--team_name", team_name)
end
end
def when_i_run_the_command_with_json
@output =
capture_output do
- command(["--ods_code", @organisation.ods_code, "--format", "json"])
+ command("--ods_code", @organisation.ods_code, "--format", "json")
end
end
def when_i_run_the_command_with_invalid_organisation
- @output = capture_error { command(%w[--ods_code INVALID_ODS]) }
+ @output = capture_error { command("--ods_code", "INVALID_ODS") }
end
def when_i_run_the_command_with_invalid_team
@output =
capture_error do
command(
- ["--ods_code", @organisation.ods_code, "--team_name", "INVALID_TEAM"]
+ "--ods_code",
+ @organisation.ods_code,
+ "--team_name",
+ "INVALID_TEAM"
)
end
end
diff --git a/spec/features/cli_stats_vaccinations_spec.rb b/spec/features/cli_stats_vaccinations_spec.rb
index 8ba06545f8..cf6f9b801b 100644
--- a/spec/features/cli_stats_vaccinations_spec.rb
+++ b/spec/features/cli_stats_vaccinations_spec.rb
@@ -63,7 +63,7 @@
private
- def command(args = [])
+ def command(*args)
Dry::CLI.new(MavisCLI).call(arguments: ["stats", "vaccinations", *args])
end
@@ -157,31 +157,31 @@ def when_i_run_the_command
end
def when_i_run_the_command_with_csv
- @output = capture_output { command(%w[--format csv]) }
+ @output = capture_output { command("--format", "csv") }
end
def when_i_run_the_command_with_json
- @output = capture_output { command(%w[--format json]) }
+ @output = capture_output { command("--format", "json") }
end
def when_i_run_the_command_with_ods_code(ods_code)
- @output = capture_output { command(["--ods_code", ods_code]) }
+ @output = capture_output { command("--ods_code", ods_code) }
end
def when_i_run_the_command_with_team_filter(workgroup)
- @output = capture_output { command(["--workgroup", workgroup]) }
+ @output = capture_output { command("--workgroup", workgroup) }
end
def when_i_run_the_command_with_programme_filter(programme)
- @output = capture_output { command(["--programme", programme]) }
+ @output = capture_output { command("--programme", programme) }
end
def when_i_run_the_command_with_invalid_organisation
- @output = capture_error { command(%w[--ods_code INVALID_ODS]) }
+ @output = capture_error { command("--ods_code", "INVALID_ODS") }
end
def when_i_run_the_command_with_invalid_team
- @output = capture_error { command(%w[--workgroup INVALID_TEAM]) }
+ @output = capture_error { command("--workgroup", "INVALID_TEAM") }
end
def then_i_see_table_format_with_all_programmes
diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb
index f050d228c2..af8f9af8bd 100644
--- a/spec/features/edit_vaccination_record_spec.rb
+++ b/spec/features/edit_vaccination_record_spec.rb
@@ -345,6 +345,9 @@ def when_i_click_on_edit_vaccination_record
def then_i_see_the_edit_vaccination_record_page
expect(page).to have_content("Edit vaccination record")
+ expect(page).not_to have_content(
+ "The vaccine given does not match that determined by the child’s consent or triage outcome"
+ )
end
def when_i_click_back
diff --git a/spec/features/import_child_pds_lookup_extravaganza_spec.rb b/spec/features/import_child_pds_lookup_extravaganza_spec.rb
index 27f6a4b9c3..8eb9d89679 100644
--- a/spec/features/import_child_pds_lookup_extravaganza_spec.rb
+++ b/spec/features/import_child_pds_lookup_extravaganza_spec.rb
@@ -9,9 +9,14 @@
given_i_am_signed_in
and_an_hpv_programme_is_underway
and_an_existing_patient_record_exists
- and_pds_lookup_during_import_is_enabled
when_i_visit_the_import_page
+ and_pds_lookups_dont_return_any_matches
+ and_i_upload_import_file("pds_extravaganza.csv")
+ then_i_should_see_the_import_failed
+
+ when_i_visit_the_import_page
+ and_pds_lookup_during_import_is_enabled
and_i_upload_import_file("pds_extravaganza.csv")
then_i_should_see_the_import_page
and_i_should_see_correct_patient_counts
@@ -269,6 +274,30 @@ def and_an_existing_patient_record_exists
expect(Parent.count).to eq(2)
end
+ def and_pds_lookups_dont_return_any_matches
+ Flipper.enable(:pds_lookup_during_import)
+ Flipper.enable(:import_low_pds_match_rate)
+
+ csv_path =
+ Rails.root.join("spec/fixtures/cohort_import/pds_extravaganza.csv")
+
+ CSV.foreach(csv_path, headers: true, header_converters: :symbol) do |row|
+ family_name = row[:child_last_name]
+ given_name = row[:child_first_name]
+ birthdate = row[:child_date_of_birth]
+ postcode = row[:child_postcode]
+
+ next if [family_name, given_name, birthdate].any?(&:blank?)
+
+ stub_pds_cascading_search(
+ family_name: family_name,
+ given_name: given_name,
+ birthdate: "eq#{birthdate}",
+ address_postcode: postcode
+ )
+ end
+ end
+
def and_pds_lookup_during_import_is_enabled
Flipper.enable(:pds_lookup_during_import)
@@ -397,31 +426,15 @@ def stub_pds_cascading_search(
end
end
- def when_i_visit_the_import_page
- visit "/"
- click_link "Import", match: :first
- end
-
- def when_i_go_back_to_the_import_page
- visit "/imports"
- click_link "1 September 2025 at 12:00pm"
- end
-
- def when_i_click_review_for(name)
- within(
- :xpath,
- "//div[h3[contains(text(), 'records with import issues')]]"
- ) do
- within(:xpath, ".//tr[contains(., '#{name}')]") { click_link "Review" }
- end
- end
-
def and_i_upload_import_file(filename)
+ travel 1.minute
+
click_button "Import records"
choose "Child records"
click_button "Continue"
attach_file("cohort_import[csv]", "spec/fixtures/cohort_import/#{filename}")
click_on "Continue"
+
wait_for_import_to_complete(CohortImport)
end
@@ -431,20 +444,9 @@ def when_i_visit_a_session_page_for_the_hpv_programme
click_on "Waterloo Road"
end
- def and_i_start_adding_children_to_the_session
- click_on "Import class lists"
- end
-
- def and_i_select_the_year_groups
- check "Year 8"
- check "Year 9"
- check "Year 10"
- check "Year 11"
- click_on "Continue"
- end
-
- def then_i_should_see_the_import_page
- expect(page).to have_content("Import class list")
+ def then_i_should_see_the_import_failed
+ expect(page).to have_content("Too many records could not be matched")
+ expect(page).to have_content("11 unmatched records")
end
def when_i_upload_a_valid_file
@@ -463,7 +465,8 @@ def when_i_visit_the_import_page
def when_i_go_back_to_the_import_page
visit "/imports"
- click_link "1 September 2025 at 12:00pm"
+
+ click_on_most_recent_import(CohortImport)
end
def when_i_click_review_for(name)
@@ -486,10 +489,6 @@ def and_i_select_the_year_groups
click_on "Continue"
end
- def then_i_should_see_the_import_page
- expect(page).to have_content("Import class list")
- end
-
def and_an_existing_patient_records_exist_in_school
@existing_patient =
create(
@@ -736,27 +735,6 @@ def then_i_see_patient_with_unknown_relationship_details
expect(page).to have_content("15 August 2010")
end
- def and_oliver_has_unknown_relationship_parent
- oliver = Patient.find_by(given_name: "Oliver", family_name: "Green")
- expect(oliver.parents.count).to eq(1)
-
- parent = oliver.parents.first
- expect(parent.full_name).to eq("Jane Doe")
- expect(parent.email).to be_blank
-
- relationship = oliver.parent_relationships.first
- expect(relationship.type).to eq("unknown")
- expect(relationship.label).to eq("Unknown")
- end
- def when_i_click_on_patient_with_unknown_relationship
- click_link "GREEN, Oliver"
- end
-
- def then_i_see_patient_with_unknown_relationship_details
- expect(page).to have_content("GREEN, Oliver")
- expect(page).to have_content("15 August 2010")
- end
-
def and_oliver_has_unknown_relationship_parent
oliver = Patient.find_by(given_name: "Oliver", family_name: "Green")
expect(oliver.parents.count).to eq(1)
diff --git a/spec/features/import_child_records_spec.rb b/spec/features/import_child_records_spec.rb
index fe7312b4a3..a14a6b1e11 100644
--- a/spec/features/import_child_records_spec.rb
+++ b/spec/features/import_child_records_spec.rb
@@ -338,7 +338,7 @@ def and_i_refresh_the_page
end
def when_i_go_to_the_import_page
- click_link CohortImport.last.created_at.to_fs(:long), match: :first
+ click_on_most_recent_import(CohortImport)
end
def and_i_import_child_records_from_children_tab
diff --git a/spec/features/import_child_records_with_duplicates_spec.rb b/spec/features/import_child_records_with_duplicates_spec.rb
index ea1386a90a..43bc61db44 100644
--- a/spec/features/import_child_records_with_duplicates_spec.rb
+++ b/spec/features/import_child_records_with_duplicates_spec.rb
@@ -95,6 +95,28 @@
end
end
+ scenario "SearchVaccinationRecordsInNHSJob is enqueued during duplicate resolution" do
+ given_i_am_signed_in
+ and_the_required_feature_flags_are_enabled
+ and_an_hpv_programme_is_underway
+ and_matching_patient_records_exist_with_different_nhs_numbers
+
+ when_i_visit_the_import_page
+ and_i_start_adding_children_to_the_cohort
+ and_i_upload_a_file_with_duplicate_records
+ then_i_should_see_the_import_page_with_duplicate_records
+
+ when_i_review_the_first_duplicate_record
+ and_i_choose_to_keep_the_duplicate_record
+ and_i_confirm_my_selection
+ then_search_vaccination_records_in_nhs_job_should_be_enqueued
+
+ when_i_review_the_second_duplicate_record_jimmy
+ and_i_choose_to_keep_the_previously_uploaded_record
+ and_i_confirm_my_selection
+ then_search_vaccination_records_in_nhs_job_should_be_enqueued_for_second_patient
+ end
+
def given_i_am_signed_in
@programme = create(:programme, :hpv)
@team =
@@ -228,6 +250,11 @@ def then_i_should_see_the_import_page_with_duplicate_records
def when_i_choose_to_keep_the_duplicate_record
choose "Use uploaded child record"
end
+ alias_method :and_i_choose_to_keep_the_duplicate_record,
+ :when_i_choose_to_keep_the_duplicate_record
+
+ alias_method :and_i_choose_to_keep_the_duplicate_record,
+ :when_i_choose_to_keep_the_duplicate_record
def when_i_choose_to_keep_both_records
choose "Keep both child records"
@@ -236,6 +263,11 @@ def when_i_choose_to_keep_both_records
def when_i_choose_to_keep_the_previously_uploaded_record
choose "Keep existing child"
end
+ alias_method :and_i_choose_to_keep_the_previously_uploaded_record,
+ :when_i_choose_to_keep_the_previously_uploaded_record
+
+ alias_method :and_i_choose_to_keep_the_previously_uploaded_record,
+ :when_i_choose_to_keep_the_previously_uploaded_record
def when_i_submit_the_form_without_choosing_anything
click_on "Resolve duplicate"
@@ -272,6 +304,10 @@ def when_i_review_the_second_duplicate_record
click_on "Review SMITH, James"
end
+ def when_i_review_the_second_duplicate_record_jimmy
+ click_on "Review SMITH, Jimmy"
+ end
+
def and_the_first_duplicate_record_should_be_persisted
@first_patient.reload
expect(@first_patient.given_name).to eq("Jennifer")
@@ -336,4 +372,73 @@ def then_i_should_see_no_import_issues_with_the_count
expect(page).to have_link("Import issues")
expect(page).to have_selector(".app-count", text: "(0)")
end
+
+ def and_the_required_feature_flags_are_enabled
+ Flipper.enable(:imms_api_integration)
+ Flipper.enable(:imms_api_search_job)
+ end
+
+ def and_matching_patient_records_exist_with_different_nhs_numbers
+ @first_patient =
+ create(
+ :patient,
+ given_name: "Jennifer",
+ family_name: "Clarke",
+ nhs_number: nil, # 9990000018 in valid.csv, will raise a duplicate to review
+ date_of_birth: Date.new(2010, 1, 1),
+ gender_code: :female,
+ address_line_1: "10 Downing Street",
+ address_line_2: "",
+ address_town: "London",
+ address_postcode: "SW11 1AA",
+ school: nil,
+ session: @session
+ )
+
+ @second_patient =
+ create(
+ :patient,
+ given_name: "Jimmy",
+ family_name: "Smith",
+ nhs_number: nil, # 999 000 0026 in valid.csv, will raise a duplicate to review
+ date_of_birth: Date.new(2010, 1, 2),
+ gender_code: :male,
+ address_line_1: "10 Downing Street",
+ address_line_2: "",
+ address_town: "London",
+ address_postcode: "SW11 1AA",
+ school: @school,
+ session: @session
+ )
+
+ @third_patient =
+ create(
+ :patient,
+ given_name: "Mark",
+ family_name: "Doe",
+ nhs_number: "9999075320", # nil in valid.csv, will be implicitly accepted
+ date_of_birth: Date.new(2010, 1, 3),
+ gender_code: :male,
+ address_line_1: "10 Downing Street",
+ address_line_2: "",
+ address_town: "London",
+ address_postcode: "SW1A 1AA",
+ school: @school,
+ session: @session
+ )
+ end
+
+ def then_search_vaccination_records_in_nhs_job_should_be_enqueued
+ # When we keep the duplicate record and NHS number changes, SearchVaccinationRecordsInNHSJob should be enqueued
+ expect(SearchVaccinationRecordsInNHSJob).to have_enqueued_sidekiq_job.with(
+ @first_patient.id
+ )
+ end
+
+ def then_search_vaccination_records_in_nhs_job_should_be_enqueued_for_second_patient
+ # The second patient should have NHS number changes and SearchVaccinationRecordsInNHSJob should be enqueued
+ expect(
+ SearchVaccinationRecordsInNHSJob
+ ).not_to have_enqueued_sidekiq_job.with(@second_patient.id)
+ end
end
diff --git a/spec/features/manage_attendance_spec.rb b/spec/features/manage_attendance_spec.rb
index 40818d746d..7f94525e47 100644
--- a/spec/features/manage_attendance_spec.rb
+++ b/spec/features/manage_attendance_spec.rb
@@ -201,7 +201,7 @@ def when_i_go_to_the_session_patients
end
def and_i_go_to_a_patient
- click_link Patient.where.missing(:session_attendances).first.full_name
+ click_link Patient.where.missing(:attendance_records).first.full_name
end
def then_the_patient_is_not_registered_yet
diff --git a/spec/features/tallying_spec.rb b/spec/features/tallying_spec.rb
new file mode 100644
index 0000000000..22787c0224
--- /dev/null
+++ b/spec/features/tallying_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+describe "Tallying" do
+ scenario "vaccinator can see how many they have administered during a session" do
+ given_a_session_for_hpv_and_flu_is_running_today
+ and_i_have_administered_two_cervarix_vaccines_for_hpv_programme
+ and_administered_one_gardasil_vaccine_for_hpv_programme
+ and_administered_one_fluenz_vaccine_for_flu_programme
+ and_i_created_vaccination_records_yesterday
+ and_vaccinations_are_recorded_by_other_team_members
+ and_the_default_vaccine_batches_have_been_set_for_flu_and_hpv
+
+ when_i_visit_the_session_record_tab
+ and_i_click_on_the_expander_your_vaccinations_today
+ then_i_see_my_vaccination_tallies_for_today_with_default_batches
+ end
+
+ scenario "no vaccinations have been administered yet" do
+ given_a_session_for_hpv_and_flu_is_running_today
+ and_the_default_vaccine_batches_have_been_set_for_flu_and_hpv
+
+ when_i_visit_the_session_record_tab
+ and_i_click_on_the_expander_your_vaccinations_today
+ then_i_see_my_vaccination_tallies_with_all_zero_values_with_default_batches
+ end
+
+ scenario "when an admin is viewing the record tab for a session" do
+ given_a_session_for_hpv_and_flu_is_running_today
+ when_i_visit_the_session_record_tab_as_an_admin
+ then_i_do_not_see_the_vaccination_tallies_table
+ end
+
+ def given_a_session_for_hpv_and_flu_is_running_today
+ @flu_programme = create(:programme, :flu, vaccines: [])
+ @hpv_programme = create(:programme, :hpv, vaccines: [])
+
+ programmes = [@hpv_programme, @flu_programme]
+ team = create(:team, :with_generic_clinic, :with_one_nurse, programmes:)
+ @user = team.users.first
+
+ @session =
+ create(:session, :today, :requires_no_registration, programmes:, team:)
+
+ @cervarix_vaccine = create(:vaccine, :cervarix, programme: @hpv_programme)
+ @cervarix_batch = create(:batch, :not_expired, vaccine: @cervarix_vaccine)
+
+ @gardasil9_vaccine =
+ create(:vaccine, :gardasil_9, programme: @hpv_programme)
+ @gardasil9_batch = create(:batch, :not_expired, vaccine: @gardasil9_vaccine)
+
+ @fluenz_vaccine = create(:vaccine, :fluenz, programme: @flu_programme)
+ @fluenz_batch = create(:batch, :not_expired, vaccine: @fluenz_vaccine)
+
+ @patient =
+ create(
+ :patient_session,
+ :consent_given_triage_not_needed,
+ :in_attendance,
+ session: @session
+ ).patient
+ end
+
+ def when_i_visit_the_session_record_tab
+ sign_in @user, role: :nurse
+ visit session_record_path(@session)
+ end
+
+ def when_i_visit_the_session_record_tab_as_an_admin
+ sign_in @user, role: :medical_secretary
+ visit session_record_path(@session)
+ end
+
+ def and_click_on_change_default_batch_link
+ within ".nhsuk-table" do
+ click_on "Change"
+ end
+ end
+
+ def and_the_default_vaccine_batches_have_been_set_for_flu_and_hpv
+ page.set_rack_session(
+ todays_batch: {
+ @hpv_programme.type.to_s => {
+ @cervarix_vaccine.method => {
+ id: @cervarix_batch.id,
+ date: Date.current.iso8601
+ }
+ },
+ @flu_programme.type.to_s => {
+ @fluenz_vaccine.method => {
+ id: @fluenz_batch.id,
+ date: Date.current.iso8601
+ }
+ }
+ }
+ )
+ end
+
+ def and_i_have_administered_two_cervarix_vaccines_for_hpv_programme
+ create(
+ :vaccination_record,
+ batch: @cervarix_batch,
+ vaccine: @cervarix_vaccine,
+ session: @session,
+ programme: @hpv_programme,
+ performed_by: @user
+ )
+
+ create(
+ :vaccination_record,
+ batch: @gardasil9_batch,
+ vaccine: @gardasil9_vaccine,
+ session: @session,
+ programme: @hpv_programme,
+ performed_by: @user
+ )
+ end
+
+ def and_administered_one_gardasil_vaccine_for_hpv_programme
+ create(
+ :vaccination_record,
+ batch: @gardasil9_batch,
+ vaccine: @gardasil9_vaccine,
+ session: @session,
+ programme: @hpv_programme,
+ performed_by: @user
+ )
+ end
+
+ def and_administered_one_fluenz_vaccine_for_flu_programme
+ create(
+ :vaccination_record,
+ batch: @fluenz_batch,
+ vaccine: @fluenz_vaccine,
+ session: @session,
+ programme: @flu_programme,
+ performed_by: @user
+ )
+ end
+
+ def and_i_created_vaccination_records_yesterday
+ create(
+ :vaccination_record,
+ batch: @cervarix_batch,
+ vaccine: @cervarix_vaccine,
+ session: @session,
+ programme: @hpv_programme,
+ performed_by: @user,
+ performed_at: Time.zone.yesterday
+ )
+ end
+
+ def and_vaccinations_are_recorded_by_other_team_members
+ create(
+ :vaccination_record,
+ batch: @cervarix_batch,
+ vaccine: @cervarix_vaccine,
+ session: @session,
+ programme: @hpv_programme
+ )
+ end
+
+ def then_i_see_my_vaccination_tallies_for_today_with_default_batches
+ rows = page.all(".nhsuk-table__row")
+ expect(rows.count).to eq(4)
+ expect(rows[1]).to have_content("Fluenz 1 #{@fluenz_batch.name} Change")
+ expect(rows[2]).to have_content("Gardasil 9 2 Not set")
+ expect(rows[3]).to have_content("Cervarix 1 #{@cervarix_batch.name} Change")
+ end
+
+ def then_i_see_my_vaccination_tallies_with_all_zero_values_with_default_batches
+ rows = page.all(".nhsuk-table__row")
+ expect(rows.count).to eq(3)
+ expect(rows[1]).to have_content("Fluenz 0 #{@fluenz_batch.name} Change")
+ expect(rows[2]).to have_content("Gardasil 9 0 Not set")
+ end
+
+ def and_i_click_on_the_expander_your_vaccinations_today
+ find("span", text: "Your vaccinations today").click
+ end
+
+ def then_i_do_not_see_the_vaccination_tallies_table
+ expect(page).to have_no_content("Your vaccinations today")
+ end
+end
diff --git a/spec/features/todays_batch_spec.rb b/spec/features/todays_batch_spec.rb
deleted file mode 100644
index e9db7b61fb..0000000000
--- a/spec/features/todays_batch_spec.rb
+++ /dev/null
@@ -1,230 +0,0 @@
-# frozen_string_literal: true
-
-describe "Today’s batch" do
- around { |example| travel_to(Time.zone.local(2024, 2, 1)) { example.run } }
-
- before { given_i_am_signed_in }
-
- scenario "injection only" do
- when_i_vaccinate_a_patient_with_hpv
- and_i_choose_a_default_batch(@hpv_batch)
- then_i_see_the_default_batch_banner_with_batch_1
-
- when_i_click_the_change_batch_link
- then_i_see_the_change_batch_page
-
- when_i_choose_the_second_batch
- then_i_see_the_default_batch_banner_with_batch_2
-
- when_i_vaccinate_a_second_patient_with_hpv
- then_i_see_the_default_batch_on_the_confirmation_page
- and_i_see_the_default_batch_on_the_patient_page
-
- when_i_vaccinate_a_patient_with_flu
- then_i_am_required_to_choose_a_batch
- end
-
- scenario "nasal spray and injection" do
- when_i_vaccinate_a_patient_with_flu
- then_i_dont_see_the_batch(@flu_nasal_batch)
- and_i_choose_a_default_batch(@flu_injection_batch)
-
- when_i_vaccinate_a_second_patient_with_flu
- then_i_am_required_to_choose_a_batch
- and_i_dont_see_the_batch(@flu_injection_batch)
- and_i_choose_a_default_batch(@flu_nasal_batch)
- then_i_see_the_default_flu_batches_banner
- end
-
- def given_i_am_signed_in
- flu_programme = create(:programme, :flu)
- hpv_programme = create(:programme, :hpv)
-
- programmes = [hpv_programme, flu_programme]
-
- team = create(:team, :with_one_nurse, programmes:)
-
- batches =
- programmes.map do |programme|
- programme.vaccines.flat_map do |vaccine|
- create_list(:batch, 2, :not_expired, team:, vaccine:)
- end
- end
-
- @hpv_batch = batches.first.first
- @hpv_batch2 = batches.first.second
- @flu_injection_batch = batches.second.find { it.vaccine.injection? }
- @flu_nasal_batch = batches.second.find { it.vaccine.nasal? }
-
- @session = create(:session, team:, programmes:)
-
- @patient =
- create(
- :patient,
- :consent_given_triage_not_needed,
- :in_attendance,
- session: @session,
- year_group: 9
- )
-
- @patient2 =
- create(
- :patient,
- :consent_given_triage_not_needed,
- :in_attendance,
- session: @session,
- year_group: 8
- )
-
- @patient2.consent_status(
- programme: flu_programme,
- academic_year: Date.current.academic_year
- ).update!(vaccine_methods: %w[nasal])
-
- sign_in team.users.first
- end
-
- def when_i_vaccinate_a_patient_with_hpv
- visit session_record_path(@session)
-
- click_link @patient.full_name
- click_on "HPV"
-
- within all("section")[0] do
- check "I have checked that the above statements are true"
- end
-
- within all("section")[1] do
- choose "Yes"
- choose "Left arm (upper position)"
- click_button "Continue"
- end
- end
-
- def then_i_dont_see_the_batch(batch)
- expect(page).not_to have_content(batch.name)
- end
-
- alias_method :and_i_dont_see_the_batch, :then_i_dont_see_the_batch
-
- def and_i_choose_a_default_batch(batch)
- choose batch.name
-
- # Find the selected radio button element
- selected_radio_button = find(:radio_button, batch.name, checked: true)
-
- # Find the "Default to this batch for this session" checkbox immediately below and check it
- checkbox_below =
- selected_radio_button.find(
- :xpath,
- 'following::input[@type="checkbox"][1]'
- )
- checkbox_below.check
- click_button "Continue"
-
- click_button "Confirm"
-
- # back to session
- click_on "Record vaccinations"
- end
-
- def when_i_vaccinate_a_second_patient_with_hpv
- visit session_record_path(@session)
-
- click_link @patient2.full_name
- click_on "HPV"
-
- within all("section")[0] do
- check "I have checked that the above statements are true"
- end
-
- within all("section")[1] do
- choose "Yes"
- choose "Left arm (upper position)"
- click_button "Continue"
- end
- end
-
- def then_i_see_the_default_batch_banner_with_batch_1
- expect(page).to have_content("Gardasil 9 (HPV): #{@hpv_batch.name}")
- end
-
- def then_i_see_the_default_batch_banner_with_batch_2
- expect(page).to have_content("Gardasil 9 (HPV): #{@hpv_batch2.name}")
- end
-
- def when_i_click_the_change_batch_link
- click_link "Change default batch"
- end
-
- def then_i_see_the_change_batch_page
- expect(page).to have_content("Select a default HPV batch for this session")
- expect(page).to have_selector(:label, @hpv_batch.name)
- expect(page).to have_selector(:label, @hpv_batch2.name)
- end
-
- def when_i_choose_the_second_batch
- choose @hpv_batch2.name
- click_button "Continue"
- end
-
- def then_i_see_the_default_batch_on_the_confirmation_page
- expect(page).to have_content("Check and confirm")
- expect(page).to have_content(@hpv_batch2.name)
-
- click_button "Confirm"
- end
-
- def and_i_see_the_default_batch_on_the_patient_page
- expect(page).to have_content("Vaccinated")
-
- click_on "1 February 2024"
- expect(page).to have_content(@hpv_batch2.name)
- end
-
- def when_i_vaccinate_a_patient_with_flu
- visit session_record_path(@session)
-
- click_link @patient.full_name
- click_on "Flu"
-
- within all("section")[0] do
- check "I have checked that the above statements are true"
- end
-
- within all("section")[1] do
- choose "Yes"
- choose "Left arm (upper position)"
- click_button "Continue"
- end
- end
-
- def when_i_vaccinate_a_second_patient_with_flu
- visit session_record_path(@session)
-
- click_link @patient2.full_name
- click_on "Flu"
-
- within all("section")[0] do
- check "I have checked that the above statements are true"
- end
-
- within all("section")[1] do
- choose "Yes"
- click_button "Continue"
- end
- end
-
- def then_i_am_required_to_choose_a_batch
- expect(page).to have_content("Which batch did you use?")
- end
-
- def then_i_see_the_default_flu_batches_banner
- expect(page).to have_content(
- "Cell-based Trivalent Influenza Vaccine Seqirus (flu injection): #{@flu_injection_batch.name}"
- )
- expect(page).to have_content(
- "Fluenz (flu nasal spray): #{@flu_nasal_batch.name}"
- )
- end
-end
diff --git a/spec/fixtures/files/fhir/from-fhir-record-full.json b/spec/fixtures/files/fhir/fhir_record_full.json
similarity index 100%
rename from spec/fixtures/files/fhir/from-fhir-record-full.json
rename to spec/fixtures/files/fhir/fhir_record_full.json
diff --git a/spec/fixtures/files/fhir/fhir_record_half_dose.json b/spec/fixtures/files/fhir/fhir_record_half_dose.json
new file mode 100644
index 0000000000..cd52e6a03e
--- /dev/null
+++ b/spec/fixtures/files/fhir/fhir_record_half_dose.json
@@ -0,0 +1,340 @@
+{
+ "resourceType": "Immunization",
+ "id": "11112222-3333-4444-5555-666677779999",
+ "contained": [
+ {
+ "resourceType": "Practitioner",
+ "id": "Pract1",
+ "name": [
+ {
+ "family": "Deadman",
+ "given": ["Florence"]
+ },
+ {
+ "use": "official",
+ "text": "hello pract1",
+ "family": "Smith",
+ "given": ["Steph", "Steph1", "Steph2"],
+ "period": {
+ "start": "2025-03-06T13:28:17.2567+01:00",
+ "end": "2025-03-06T13:28:20.2567+01:00"
+ }
+ },
+ {
+ "use": "home",
+ "text": "hello pract2",
+ "family": "Family2",
+ "given": ["Given2", "Given3", "Given4"],
+ "period": {
+ "start": "2025-03-06T13:28:15.2567+01:00",
+ "end": "2025-03-06T13:28:17.2567+01:00"
+ }
+ },
+ {
+ "use": "home",
+ "text": "hello pract3",
+ "family": "Family3",
+ "given": ["given3", "given4", "given5"],
+ "period": {
+ "start": "2025-03-06T13:28:15.2567+01:00",
+ "end": "2025-03-06T13:28:20.2567+01:00"
+ }
+ }
+ ]
+ },
+ {
+ "resourceType": "Patient",
+ "id": "Pat1",
+ "identifier": [
+ {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9452372249"
+ }
+ ],
+ "name": [
+ {
+ "use": "official",
+ "text": "hello pat1",
+ "family": "test14",
+ "given": ["test15", "test16", "test17"],
+ "period": {
+ "start": "2025-03-06T13:28:17.2567+01:00",
+ "end": "2025-03-06T13:28:20.2567+01:00"
+ }
+ },
+ {
+ "use": "home",
+ "text": "hello pat2",
+ "family": "test18",
+ "given": ["test19", "test20", "test21"],
+ "period": {
+ "start": "2025-03-06T13:28:15.2567+01:00",
+ "end": "2025-03-06T13:28:17.2567+01:00"
+ }
+ },
+ {
+ "use": "home",
+ "text": "hello pat3",
+ "family": "test22",
+ "given": ["test23", "test24", "test25"],
+ "period": {
+ "start": "2025-03-06T13:28:15.2567+01:00",
+ "end": "2025-03-06T13:28:20.2567+01:00"
+ }
+ }
+ ],
+ "gender": "unknown",
+ "birthDate": "1960-01-01",
+ "address": [
+ {
+ "use": "home",
+ "type": "postal",
+ "text": "Validate Obf",
+ "line": ["1, obf_2"],
+ "city": "obf_3",
+ "district": "obf_4",
+ "state": "obf_5",
+ "postalCode": "LS01 1AB",
+ "country": "obf_7",
+ "period": {
+ "start": "2000-01-01",
+ "end": "2025-01-01"
+ }
+ },
+ {
+ "use": "home",
+ "type": "postal",
+ "text": "Validate Obf",
+ "line": ["1, obf_2"],
+ "city": "obf_3",
+ "district": "obf_4",
+ "state": "obf_5",
+ "postalCode": "LS02 1AB",
+ "country": "obf_7",
+ "period": {
+ "start": "2000-01-01",
+ "end": "2025-01-01"
+ }
+ },
+ {
+ "use": "home",
+ "type": "postal",
+ "text": "Validate Obf",
+ "line": ["1, obf_2"],
+ "city": "obf_3",
+ "district": "obf_4",
+ "state": "obf_5",
+ "postalCode": "LS03 1AB",
+ "country": "obf_7",
+ "period": {
+ "start": "2000-01-01",
+ "end": "2025-03-05"
+ }
+ },
+ {
+ "use": "home",
+ "type": "postal",
+ "text": "Validate Obf",
+ "line": ["1, obf_2"],
+ "city": "obf_3",
+ "district": "obf_4",
+ "state": "obf_5",
+ "postalCode": "LS04 1AB",
+ "country": "obf_7",
+ "period": {
+ "start": "2000-01-01",
+ "end": "2025-03-05"
+ }
+ }
+ ]
+ }
+ ],
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-VaccinationProcedure",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-CodingSCTDescDisplay",
+ "valueString": "Test Value string 123456 Flu vaccination"
+ },
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/coding-sctdescid",
+ "valueId": "5306706018"
+ }
+ ],
+ "system": "http://snomed.info/test",
+ "code": "955651000000100",
+ "display": "Seasonal influenza vaccination 111 given by other healthcare provider (situation)"
+ },
+ {
+ "system": "https://acme.lab/resultcodes",
+ "code": "NEG",
+ "display": "Negative"
+ },
+ {
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-CodingSCTDescDisplay",
+ "valueString": "Test Value string 56451 Flu vaccination"
+ },
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/coding-sctdescid",
+ "valueId": "5306706018"
+ }
+ ],
+ "system": "http://snomed.info/sct",
+ "code": "822851000000102",
+ "display": "Seasonal influenza vaccination 111 (procedure)"
+ },
+ {
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-CodingSCTDescDisplay",
+ "valueString": "Test Value string 8956 Flu vaccination"
+ },
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/coding-sctdescid",
+ "valueId": "5306706018"
+ }
+ ],
+ "system": "http://snomed.info/sct",
+ "code": "884861000000100",
+ "display": "Seasonal influenza vaccination 222 (procedure)"
+ }
+ ],
+ "text": "Negative for Chlamydia Trachomatis rRNA Flu"
+ }
+ }
+ ],
+ "identifier": [
+ {
+ "system": "https://supplierABC/identifiers/vacc",
+ "value": "aaaabbbb-0000-1111-3333-ffff77773333"
+ }
+ ],
+ "status": "completed",
+ "vaccineCode": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "43208811000001106",
+ "display": "Fluenz (trivalent) vaccine nasal suspension 0.2ml unit dose (AstraZeneca UK Ltd) (product)"
+ }
+ ]
+ },
+ "patient": {
+ "reference": "#Pat1"
+ },
+ "occurrenceDateTime": "2025-04-06T23:59:50.2+01:00",
+ "recorded": "2025-03-12T13:28:17.12+00:00",
+ "primarySource": true,
+ "manufacturer": {
+ "display": "AstraZeneca Ltd"
+ },
+ "location": {
+ "identifier": {
+ "system": "https://fhir.hl7.org.uk/Id/urn-school-number",
+ "value": "100006"
+ }
+ },
+ "lotNumber": "4120Z001",
+ "expirationDate": "2026-07-02",
+ "site": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "279549004",
+ "display": "Nasal cavity structure"
+ }
+ ]
+ },
+ "route": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "46713006",
+ "display": "Nasal"
+ }
+ ]
+ },
+ "doseQuantity": {
+ "value": 0.1,
+ "unit": "ml",
+ "system": "http://snomed.info/sct",
+ "code": "258773002"
+ },
+ "performer": [
+ {
+ "actor": {
+ "reference": "#Pract1"
+ }
+ },
+ {
+ "actor": {
+ "type": "Organization",
+ "display": "Acme Healthcare",
+ "identifier": {
+ "value": "B0C4P",
+ "system": "https://fhir.nhs.uk/Id/ods-organization-code",
+ "use": "usual",
+ "type": {
+ "coding": [
+ {
+ "system": "http://terminology.hl7.org/CodeSystem/v2-0203",
+ "version": "Test version performer",
+ "code": "123456",
+ "display": "Test display performer",
+ "userSelected": true
+ }
+ ],
+ "text": "test string performer"
+ },
+ "period": {
+ "start": "2000-01-01",
+ "end": "2025-01-01"
+ }
+ }
+ }
+ }
+ ],
+ "reasonCode": [
+ {
+ "coding": [
+ {
+ "code": "123684005",
+ "system": "http://snomed.info/test",
+ "display": "Disease outbreak (event)"
+ },
+ {
+ "code": "453684005",
+ "system": "http://snomed.info/sct",
+ "display": "Disease outbreak (event)"
+ },
+ {
+ "code": "443684005",
+ "system": "http://snomed.info/sct",
+ "display": "Disease outbreak (event)"
+ }
+ ]
+ }
+ ],
+ "protocolApplied": [
+ {
+ "targetDisease": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "6142004",
+ "display": "Influenza caused by Influenza virus (disorder)"
+ }
+ ]
+ }
+ ],
+ "doseNumberPositiveInt": 1
+ }
+ ]
+}
diff --git a/spec/fixtures/files/fhir/from-fhir-record-minimum.json b/spec/fixtures/files/fhir/fhir_record_minimum.json
similarity index 100%
rename from spec/fixtures/files/fhir/from-fhir-record-minimum.json
rename to spec/fixtures/files/fhir/fhir_record_minimum.json
diff --git a/spec/fixtures/files/fhir/from-fhir-record-unknown-location.json b/spec/fixtures/files/fhir/fhir_record_unknown_location.json
similarity index 100%
rename from spec/fixtures/files/fhir/from-fhir-record-unknown-location.json
rename to spec/fixtures/files/fhir/fhir_record_unknown_location.json
diff --git a/spec/fixtures/files/fhir/from-fhir-record-unknown-vaccine.json b/spec/fixtures/files/fhir/fhir_record_unknown_vaccine.json
similarity index 100%
rename from spec/fixtures/files/fhir/from-fhir-record-unknown-vaccine.json
rename to spec/fixtures/files/fhir/fhir_record_unknown_vaccine.json
diff --git a/spec/fixtures/files/fhir/immunisation-create.json b/spec/fixtures/files/fhir/immunisation_create.json
similarity index 100%
rename from spec/fixtures/files/fhir/immunisation-create.json
rename to spec/fixtures/files/fhir/immunisation_create.json
diff --git a/spec/fixtures/files/fhir/immunisation-update.json b/spec/fixtures/files/fhir/immunisation_update.json
similarity index 100%
rename from spec/fixtures/files/fhir/immunisation-update.json
rename to spec/fixtures/files/fhir/immunisation_update.json
diff --git a/spec/fixtures/files/fhir/search_response_0_results.json b/spec/fixtures/files/fhir/search_response_0_results.json
index c8f9c20343..8363a53e13 100644
--- a/spec/fixtures/files/fhir/search_response_0_results.json
+++ b/spec/fixtures/files/fhir/search_response_0_results.json
@@ -4,7 +4,7 @@
"link": [
{
"relation": "self",
- "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
+ "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
}
],
"entry": [],
diff --git a/spec/fixtures/files/fhir/search_response_1_result.json b/spec/fixtures/files/fhir/search_response_1_result.json
index 179505ef16..ec44154027 100644
--- a/spec/fixtures/files/fhir/search_response_1_result.json
+++ b/spec/fixtures/files/fhir/search_response_1_result.json
@@ -4,7 +4,7 @@
"link": [
{
"relation": "self",
- "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
+ "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
}
],
"entry": [
@@ -30,7 +30,7 @@
"identifier": [
{
"use": "official",
- "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "system": "https://supplierABC/identifiers/vacc",
"value": "71f538d8-1171-4204-aee4-17ff0b0ba0b0"
}
],
@@ -52,7 +52,7 @@
"value": "9449308357"
}
},
- "occurrenceDateTime": "2025-08-22T14:16:03+01:00",
+ "occurrenceDateTime": "2025-08-23T14:16:03+01:00",
"recorded": "2025-08-22T14:16:05.246000+01:00",
"primarySource": true,
"location": {
diff --git a/spec/fixtures/files/fhir/search_response_1_result_mavis.json b/spec/fixtures/files/fhir/search_response_1_result_mavis.json
new file mode 100644
index 0000000000..6601ca81c0
--- /dev/null
+++ b/spec/fixtures/files/fhir/search_response_1_result_mavis.json
@@ -0,0 +1,153 @@
+{
+ "resourceType": "Bundle",
+ "type": "searchset",
+ "link": [
+ {
+ "relation": "self",
+ "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
+ }
+ ],
+ "entry": [
+ {
+ "fullUrl": "https://api.service.nhs.uk/immunisation-fhir-api/Immunization/4e7f3c91-a14d-4139-bbcf-859e998d2028",
+ "resource": {
+ "resourceType": "Immunization",
+ "id": "4e7f3c91-a14d-4139-bbcf-859e998d2028",
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-VaccinationProcedure",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "884861000000100",
+ "display": "Seasonal influenza vaccination (procedure)"
+ }
+ ]
+ }
+ }
+ ],
+ "identifier": [
+ {
+ "use": "official",
+ "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "value": "71f538d8-1171-4204-aee4-17ff0b0ba0b0"
+ }
+ ],
+ "status": "completed",
+ "vaccineCode": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "43208811000001106",
+ "display": "Fluenz (trivalent) vaccine nasal suspension 0.2ml unit dose (AstraZeneca UK Ltd) (product)"
+ }
+ ]
+ },
+ "patient": {
+ "reference": "urn:uuid:e06dbb8d-ef9b-454f-9c5f-fde8460a0b6a",
+ "type": "Patient",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9449308357"
+ }
+ },
+ "occurrenceDateTime": "2025-08-22T14:16:03+01:00",
+ "recorded": "2025-08-22T14:16:05.246000+01:00",
+ "primarySource": true,
+ "location": {
+ "identifier": {
+ "system": "https://fhir.hl7.org.uk/Id/urn-school-number",
+ "value": "100001"
+ }
+ },
+ "manufacturer": {
+ "display": "AstraZeneca"
+ },
+ "lotNumber": "BU5086",
+ "expirationDate": "2025-09-30",
+ "site": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "279549004",
+ "display": "Nasal cavity structure"
+ }
+ ]
+ },
+ "route": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "46713006",
+ "display": "Nasal"
+ }
+ ]
+ },
+ "doseQuantity": {
+ "value": 0.2,
+ "unit": "ml",
+ "system": "http://snomed.info/sct",
+ "code": "258773002"
+ },
+ "performer": [
+ {
+ "actor": {
+ "type": "Organization",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/ods-organization-code",
+ "value": "R1L"
+ }
+ }
+ }
+ ],
+ "reasonCode": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "723620004"
+ }
+ ]
+ }
+ ],
+ "protocolApplied": [
+ {
+ "targetDisease": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "6142004",
+ "display": "Influenza"
+ }
+ ]
+ }
+ ],
+ "doseNumberPositiveInt": 1
+ }
+ ]
+ },
+ "search": {
+ "mode": "match"
+ }
+ },
+ {
+ "fullUrl": "urn:uuid:e06dbb8d-ef9b-454f-9c5f-fde8460a0b6a",
+ "resource": {
+ "resourceType": "Patient",
+ "id": "9449308357",
+ "identifier": [
+ {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9449308357"
+ }
+ ]
+ },
+ "search": {
+ "mode": "include"
+ }
+ }
+ ],
+ "total": 1
+}
diff --git a/spec/fixtures/files/fhir/search_response_2_results.json b/spec/fixtures/files/fhir/search_response_2_results.json
index d7e865a39d..1d077e8a8f 100644
--- a/spec/fixtures/files/fhir/search_response_2_results.json
+++ b/spec/fixtures/files/fhir/search_response_2_results.json
@@ -4,7 +4,7 @@
"link": [
{
"relation": "self",
- "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
+ "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
}
],
"entry": [
@@ -30,7 +30,7 @@
"identifier": [
{
"use": "official",
- "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "system": "https://supplierABC/identifiers/vacc",
"value": "71f538d8-1171-4204-aee4-17ff0b0ba0b0"
}
],
@@ -154,7 +154,7 @@
"identifier": [
{
"use": "official",
- "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "system": "https://supplierABC/identifiers/vacc",
"value": "18441e7b-b652-4d8c-980c-b60009f95942"
}
],
diff --git a/spec/fixtures/files/fhir/search_response_full_bundle.json b/spec/fixtures/files/fhir/search_response_full_bundle.json
new file mode 100644
index 0000000000..d7e865a39d
--- /dev/null
+++ b/spec/fixtures/files/fhir/search_response_full_bundle.json
@@ -0,0 +1,277 @@
+{
+ "resourceType": "Bundle",
+ "type": "searchset",
+ "link": [
+ {
+ "relation": "self",
+ "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026immunization.target=FLU\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357"
+ }
+ ],
+ "entry": [
+ {
+ "fullUrl": "https://api.service.nhs.uk/immunisation-fhir-api/Immunization/4e7f3c91-a14d-4139-bbcf-859e998d2028",
+ "resource": {
+ "resourceType": "Immunization",
+ "id": "4e7f3c91-a14d-4139-bbcf-859e998d2028",
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-VaccinationProcedure",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "884861000000100",
+ "display": "Seasonal influenza vaccination (procedure)"
+ }
+ ]
+ }
+ }
+ ],
+ "identifier": [
+ {
+ "use": "official",
+ "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "value": "71f538d8-1171-4204-aee4-17ff0b0ba0b0"
+ }
+ ],
+ "status": "completed",
+ "vaccineCode": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "43208811000001106",
+ "display": "Fluenz (trivalent) vaccine nasal suspension 0.2ml unit dose (AstraZeneca UK Ltd) (product)"
+ }
+ ]
+ },
+ "patient": {
+ "reference": "urn:uuid:e06dbb8d-ef9b-454f-9c5f-fde8460a0b6a",
+ "type": "Patient",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9449308357"
+ }
+ },
+ "occurrenceDateTime": "2025-08-22T14:16:03+01:00",
+ "recorded": "2025-08-22T14:16:05.246000+01:00",
+ "primarySource": true,
+ "location": {
+ "identifier": {
+ "system": "https://fhir.hl7.org.uk/Id/urn-school-number",
+ "value": "100001"
+ }
+ },
+ "manufacturer": {
+ "display": "AstraZeneca"
+ },
+ "lotNumber": "BU5086",
+ "expirationDate": "2025-09-30",
+ "site": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "279549004",
+ "display": "Nasal cavity structure"
+ }
+ ]
+ },
+ "route": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "46713006",
+ "display": "Nasal"
+ }
+ ]
+ },
+ "doseQuantity": {
+ "value": 0.2,
+ "unit": "ml",
+ "system": "http://snomed.info/sct",
+ "code": "258773002"
+ },
+ "performer": [
+ {
+ "actor": {
+ "type": "Organization",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/ods-organization-code",
+ "value": "R1L"
+ }
+ }
+ }
+ ],
+ "reasonCode": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "723620004"
+ }
+ ]
+ }
+ ],
+ "protocolApplied": [
+ {
+ "targetDisease": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "6142004",
+ "display": "Influenza"
+ }
+ ]
+ }
+ ],
+ "doseNumberPositiveInt": 1
+ }
+ ]
+ },
+ "search": {
+ "mode": "match"
+ }
+ },
+ {
+ "fullUrl": "https://api.service.nhs.uk/immunisation-fhir-api/Immunization/871f91fa-385e-4f42-8e0b-98e6c9a592dd",
+ "resource": {
+ "resourceType": "Immunization",
+ "id": "871f91fa-385e-4f42-8e0b-98e6c9a592dd",
+ "extension": [
+ {
+ "url": "https://fhir.hl7.org.uk/StructureDefinition/Extension-UKCore-VaccinationProcedure",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "884861000000100",
+ "display": "Seasonal influenza vaccination (procedure)"
+ }
+ ]
+ }
+ }
+ ],
+ "identifier": [
+ {
+ "use": "official",
+ "system": "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records",
+ "value": "18441e7b-b652-4d8c-980c-b60009f95942"
+ }
+ ],
+ "status": "completed",
+ "vaccineCode": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "43208811000001106",
+ "display": "Fluenz (trivalent) vaccine nasal suspension 0.2ml unit dose (AstraZeneca UK Ltd) (product)"
+ }
+ ]
+ },
+ "patient": {
+ "reference": "urn:uuid:e06dbb8d-ef9b-454f-9c5f-fde8460a0b6a",
+ "type": "Patient",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9449308357"
+ }
+ },
+ "occurrenceDateTime": "2025-08-26T12:48:01+01:00",
+ "recorded": "2025-08-26T12:48:58.741000+01:00",
+ "primarySource": true,
+ "location": {
+ "identifier": {
+ "system": "https://fhir.hl7.org.uk/Id/urn-school-number",
+ "value": "100005"
+ }
+ },
+ "manufacturer": {
+ "display": "AstraZeneca"
+ },
+ "lotNumber": "IK1741",
+ "expirationDate": "2025-09-25",
+ "site": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "279549004",
+ "display": "Nasal cavity structure"
+ }
+ ]
+ },
+ "route": {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "46713006",
+ "display": "Nasal"
+ }
+ ]
+ },
+ "doseQuantity": {
+ "value": 0.2,
+ "unit": "ml",
+ "system": "http://snomed.info/sct",
+ "code": "258773002"
+ },
+ "performer": [
+ {
+ "actor": {
+ "type": "Organization",
+ "identifier": {
+ "system": "https://fhir.nhs.uk/Id/ods-organization-code",
+ "value": "R1L"
+ }
+ }
+ }
+ ],
+ "reasonCode": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "723620004"
+ }
+ ]
+ }
+ ],
+ "protocolApplied": [
+ {
+ "targetDisease": [
+ {
+ "coding": [
+ {
+ "system": "http://snomed.info/sct",
+ "code": "6142004",
+ "display": "Influenza"
+ }
+ ]
+ }
+ ],
+ "doseNumberPositiveInt": 1
+ }
+ ]
+ },
+ "search": {
+ "mode": "match"
+ }
+ },
+ {
+ "fullUrl": "urn:uuid:e06dbb8d-ef9b-454f-9c5f-fde8460a0b6a",
+ "resource": {
+ "resourceType": "Patient",
+ "id": "9449308357",
+ "identifier": [
+ {
+ "system": "https://fhir.nhs.uk/Id/nhs-number",
+ "value": "9449308357"
+ }
+ ]
+ },
+ "search": {
+ "mode": "include"
+ }
+ }
+ ],
+ "total": 2
+}
diff --git a/spec/jobs/commit_patient_changesets_job_spec.rb b/spec/jobs/commit_patient_changesets_job_spec.rb
new file mode 100644
index 0000000000..240f402d75
--- /dev/null
+++ b/spec/jobs/commit_patient_changesets_job_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+describe CommitPatientChangesetsJob do
+ let(:team) { create(:team) }
+ let(:import) { create(:cohort_import, team:) }
+
+ describe "#import_patients_and_parents" do
+ subject(:import_patients_and_parents) do
+ job = described_class.new
+ job.send(:import_patients_and_parents, changesets, import)
+ end
+
+ let!(:first_patient) { create(:patient) }
+ let!(:second_patient) { create(:patient) }
+ let!(:third_patient) { create(:patient, nhs_number: nil) }
+ let(:patients) { [first_patient, second_patient, third_patient] }
+
+ let(:changesets) do
+ patients.map do |patient|
+ instance_double(
+ PatientChangeset,
+ patient:,
+ parents: [],
+ parent_relationships: []
+ )
+ end
+ end
+
+ before do
+ allow(Patient).to receive(:import)
+ allow(PatientChangeset).to receive(:import)
+ allow(Parent).to receive(:import)
+ allow(ParentRelationship).to receive(:import)
+
+ changesets.each do |changeset|
+ allow(changeset).to receive(:assign_patient_id)
+ end
+ end
+
+ context "when patients have NHS number changes" do
+ before do
+ allow(first_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(true)
+ allow(second_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(true)
+ allow(third_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ end
+
+ it "enqueues SearchVaccinationRecordsInNHSJob for patients with NHS number changes" do
+ import_patients_and_parents
+
+ expect(SearchVaccinationRecordsInNHSJob).to have_enqueued_sidekiq_job(
+ first_patient.id
+ )
+ expect(SearchVaccinationRecordsInNHSJob).to have_enqueued_sidekiq_job(
+ second_patient.id
+ )
+ expect(
+ SearchVaccinationRecordsInNHSJob
+ ).not_to have_enqueued_sidekiq_job(third_patient.id)
+ end
+ end
+
+ context "when no patients have NHS number changes" do
+ before do
+ allow(first_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ allow(second_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ allow(third_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ end
+
+ it "does not enqueue SearchVaccinationRecordsInNHSJob" do
+ expect { import_patients_and_parents }.not_to enqueue_sidekiq_job(
+ SearchVaccinationRecordsInNHSJob
+ )
+ end
+ end
+ end
+end
diff --git a/spec/jobs/enqueue_vaccinations_search_in_nhs_job_spec.rb b/spec/jobs/enqueue_vaccinations_search_in_nhs_job_spec.rb
new file mode 100644
index 0000000000..2c461c00af
--- /dev/null
+++ b/spec/jobs/enqueue_vaccinations_search_in_nhs_job_spec.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+describe EnqueueVaccinationsSearchInNHSJob do
+ include ActiveJob::TestHelper
+
+ let(:team) { create(:team) }
+ let(:flu) { create(:programme, :flu) }
+ let(:location) { create(:school, team:, programmes: [flu]) }
+ let(:school) { location }
+ let!(:patient) { create(:patient, team:, school:) }
+
+ describe "#perform", :within_academic_year do
+ subject { SearchVaccinationRecordsInNHSJob }
+
+ before { allow(SearchVaccinationRecordsInNHSJob).to receive(:perform_bulk) }
+
+ let(:send_invitations_at) {}
+ let!(:session) do
+ create(
+ :session,
+ programmes: [flu],
+ academic_year: AcademicYear.pending,
+ dates:,
+ send_invitations_at:,
+ team:,
+ location:,
+ patients: [patient]
+ )
+ end
+
+ context "with a specific session" do
+ before { described_class.perform_now([session]) }
+
+ let(:dates) { [] }
+
+ it { should have_received(:perform_bulk).once.with([[patient.id]]) }
+ end
+
+ context "session with dates in the future" do
+ before { described_class.perform_now }
+
+ let(:dates) { [7.days.from_now] }
+ let(:send_invitations_at) { 14.days.ago }
+
+ it { should have_received(:perform_bulk).once.with([[patient.id]]) }
+
+ context "community clinic session" do
+ let(:location) { create(:community_clinic, team:, programmes: [flu]) }
+ let(:school) { create(:school, team:, programmes: [flu]) }
+
+ it { should have_received(:perform_bulk).exactly(:once) }
+ end
+
+ context "generic clinic session" do
+ let(:location) { create(:generic_clinic, team:, programmes: [flu]) }
+ let(:school) { create(:school, team:, programmes: [flu]) }
+
+ it { should have_received(:perform_bulk).exactly(:once) }
+ end
+ end
+
+ context "session with dates in the past",
+ within_academic_year: {
+ from_start: 7.days
+ } do
+ before { described_class.perform_now }
+
+ let(:dates) { [7.days.ago] }
+
+ it { should_not have_received(:perform_bulk) }
+ end
+
+ context "session with dates in the past and the future",
+ within_academic_year: {
+ from_start: 7.days
+ } do
+ before { described_class.perform_now }
+
+ let(:send_invitations_at) { 28.days.ago }
+ let(:dates) { [7.days.ago, 7.days.from_now] }
+
+ it { should have_received(:perform_bulk).exactly(:once) }
+ end
+
+ context "session with send_invitations_at in the future" do
+ before { described_class.perform_now }
+
+ let(:send_invitations_at) { 2.days.from_now }
+ let(:dates) { [17.days.from_now] }
+
+ it { should have_received(:perform_bulk).exactly(:once) }
+ end
+
+ context "session with send_invitations_at too far in the future" do
+ let(:send_invitations_at) { 3.days.from_now }
+ let(:dates) { [17.days.from_now] }
+
+ it { should_not have_received(:perform_bulk) }
+ end
+ end
+end
diff --git a/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb b/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb
new file mode 100644
index 0000000000..3ac8807947
--- /dev/null
+++ b/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+describe SearchVaccinationRecordsInNHSJob do
+ subject(:perform) { described_class.new.perform(patient.id) }
+
+ let(:team) { create(:team) }
+ let(:school) { create(:school, team:) }
+ let(:patient) { create(:patient, team:, school:, nhs_number:) }
+ let(:nhs_number) { "9449308357" }
+ let!(:programme) { create(:programme, :flu) }
+
+ before do
+ Flipper.enable(:imms_api_integration)
+ Flipper.enable(:imms_api_search_job)
+ end
+
+ after do
+ Flipper.disable(:imms_api_integration)
+ Flipper.disable(:imms_api_search_job)
+ end
+
+ describe "#extract_vaccination_records" do
+ let(:bundle) do
+ FHIR.from_contents(
+ file_fixture("fhir/search_response_2_results.json").read
+ )
+ end
+
+ it "returns only Immunization resources from the bundle" do
+ records = described_class.new.extract_vaccination_records(bundle)
+ expect(records).to all(have_attributes(resourceType: "Immunization"))
+ expect(records.size).to eq 2
+ end
+ end
+
+ describe "#perform" do
+ shared_examples "calls StatusUpdater" do
+ it "calls StatusUpdater with the patient" do
+ expect(StatusUpdater).to receive(:call).with(patient:)
+ perform
+ end
+ end
+
+ let(:expected_query) do
+ {
+ "patient.identifier" =>
+ "https://fhir.nhs.uk/Id/nhs-number|#{patient.nhs_number}",
+ "-immunization.target" => "FLU"
+ }
+ end
+ let(:status) { 200 }
+ let(:body) { file_fixture("fhir/search_response_2_results.json").read }
+ let(:headers) { { "content-type" => "application/fhir+json" } }
+
+ let(:existing_bundle) do
+ FHIR.from_contents(
+ file_fixture("fhir/search_response_0_results.json").read
+ )
+ end
+ let!(:existing_records) do
+ fhir_records =
+ described_class.new.extract_vaccination_records(existing_bundle)
+ mapped_records =
+ fhir_records.map do |fhir_record|
+ mapped =
+ FHIRMapper::VaccinationRecord.from_fhir_record(
+ fhir_record,
+ patient:
+ )
+ mapped.save!
+
+ mapped
+ end
+
+ mapped_records
+ end
+
+ before do
+ stub_request(
+ :get,
+ "https://sandbox.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization"
+ ).with(query: expected_query).to_return(status:, body:, headers:)
+ end
+
+ context "with 2 new incoming records" do
+ it "creates new vaccination records for incoming Immunizations" do
+ expect { perform }.to change { patient.vaccination_records.count }.by(2)
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+
+ context "with 1 existing record and 1 new incoming record" do
+ let(:existing_bundle) do
+ FHIR.from_contents(
+ file_fixture("fhir/search_response_1_result.json").read
+ )
+ end
+
+ it "updates existing records and creates new records not present" do
+ expect { perform }.to change { patient.vaccination_records.count }.by(1)
+ expect(patient.vaccination_records.map(&:id)).to include(
+ existing_records.map(&:id).first
+ )
+ expect(existing_records.first.reload.performed_at).to eq(
+ Time.parse("2025-08-22T14:16:03+01:00")
+ )
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+
+ context "with 2 existing records and only 1 incoming (edited) record" do
+ let(:existing_bundle) do
+ FHIR.from_contents(
+ file_fixture("fhir/search_response_2_results.json").read
+ )
+ end
+ let(:body) { file_fixture("fhir/search_response_1_result.json").read }
+
+ it "deletes the record that is no longer present, and edits the existing record" do
+ expect { perform }.to change { patient.vaccination_records.count }.by(
+ -1
+ )
+ expect(patient.vaccination_records.count).to eq(1)
+ expect(existing_records.map(&:id)).to include(
+ patient.vaccination_records.map(&:id).first
+ )
+ expect(patient.vaccination_records.first&.performed_at).to eq(
+ Time.parse("2025-08-23T14:16:03+01:00")
+ )
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+
+ context "with a mavis record in the search results" do
+ let(:body) do
+ file_fixture("fhir/search_response_1_result_mavis.json").read
+ end
+
+ it "does not create a new record" do
+ expect { perform }.not_to(change { patient.vaccination_records.count })
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+
+ context "with the feature flag disabled" do
+ before { Flipper.disable(:imms_api_search_job) }
+
+ it "does not change any records locally" do
+ expect { perform }.not_to(change { patient.vaccination_records.count })
+ end
+ end
+
+ context "with a non-api record already on the patient" do
+ let!(:vaccination_record) do
+ create(:vaccination_record, patient:, programme:)
+ end
+
+ it "does not change the record which was recorded in service" do
+ expect { perform }.not_to(change(vaccination_record, :reload))
+
+ expect(patient.vaccination_records.count).to be 3
+ expect(patient.vaccination_records.map(&:source)).to contain_exactly(
+ "historical_upload",
+ "nhs_immunisations_api",
+ "nhs_immunisations_api"
+ )
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+
+ context "with no NHS number" do
+ let(:nhs_number) { nil }
+
+ let(:existing_bundle) do
+ FHIR.from_contents(
+ file_fixture("fhir/search_response_2_results.json").read
+ )
+ end
+
+ it "deletes all the API records and does not create any new ones" do
+ expect { perform }.to change { patient.vaccination_records.count }.by(
+ -2
+ )
+ expect(patient.vaccination_records.count).to eq(0)
+ end
+
+ include_examples "calls StatusUpdater"
+ end
+ end
+end
diff --git a/spec/lib/fhir_mapper/patient_spec.rb b/spec/lib/fhir_mapper/patient_spec.rb
index ca084c45f0..3c5d0c9f65 100644
--- a/spec/lib/fhir_mapper/patient_spec.rb
+++ b/spec/lib/fhir_mapper/patient_spec.rb
@@ -31,6 +31,12 @@
subject { patient_fhir.address[0] }
its(:postalCode) { should eq patient.address_postcode }
+
+ context "when the address postcode is not set" do
+ let(:patient) { create(:patient, address_postcode: nil) }
+
+ its(:postalCode) { should eq "ZZ99 3WZ" }
+ end
end
describe "gender" do
diff --git a/spec/lib/fhir_mapper/vaccination_record_spec.rb b/spec/lib/fhir_mapper/vaccination_record_spec.rb
index 14fb4e6046..2792cd908a 100644
--- a/spec/lib/fhir_mapper/vaccination_record_spec.rb
+++ b/spec/lib/fhir_mapper/vaccination_record_spec.rb
@@ -311,6 +311,7 @@
its(:source) { should eq "nhs_immunisations_api" }
its(:nhs_immunisations_api_synced_at) { should eq Time.current }
+ its(:programme) { should eq programme }
it "batch.team is nil" do
expect(record.batch&.team).to be_nil
@@ -326,9 +327,7 @@
context "with a full fhir record" do
let(:fhir_immunization) do
- FHIR.from_contents(
- file_fixture("/fhir/from-fhir-record-full.json").read
- )
+ FHIR.from_contents(file_fixture("/fhir/fhir_record_full.json").read)
end
let(:school) { create(:school, urn: "100006") }
@@ -352,10 +351,38 @@
its(:performed_ods_code) { should eq "B0C4P" }
end
+ context "with a record with not full dose" do
+ let(:fhir_immunization) do
+ FHIR.from_contents(
+ file_fixture("/fhir/fhir_record_half_dose.json").read
+ )
+ end
+ let(:school) { create(:school, urn: "100006") }
+
+ include_examples "a mapped vaccination record (common fields)"
+
+ its(:performed_by_given_name) { should eq "Steph" }
+ its(:performed_by_family_name) { should eq "Smith" }
+ its(:batch) { should have_attributes(name: "4120Z001") }
+
+ its(:vaccine) do
+ should have_attributes(snomed_product_code: "43208811000001106")
+ end
+
+ its(:performed_at) { should eq Time.parse("2025-04-06T23:59:50.2+01:00") }
+ its(:delivery_method) { should eq "nasal_spray" }
+ its(:delivery_site) { should eq "nose" }
+ its(:full_dose) { should be false }
+ its(:outcome) { should eq "administered" }
+ its(:location) { should have_attributes(urn: "100006") }
+ its(:location_name) { should be_nil }
+ its(:performed_ods_code) { should eq "B0C4P" }
+ end
+
context "with a record that has an unknown vaccine" do
let(:fhir_immunization) do
FHIR.from_contents(
- file_fixture("fhir/from-fhir-record-unknown-vaccine.json").read
+ file_fixture("fhir/fhir_record_unknown_vaccine.json").read
)
end
@@ -392,7 +419,7 @@
context "with a record that has an unknown location" do
let(:fhir_immunization) do
FHIR.from_contents(
- file_fixture("fhir/from-fhir-record-unknown-location.json").read
+ file_fixture("fhir/fhir_record_unknown_location.json").read
)
end
diff --git a/spec/lib/nhs/immunisations_api_spec.rb b/spec/lib/nhs/immunisations_api_spec.rb
index 895ffe08b3..0b848eeb79 100644
--- a/spec/lib/nhs/immunisations_api_spec.rb
+++ b/spec/lib/nhs/immunisations_api_spec.rb
@@ -80,6 +80,14 @@
" in Immunisations API: unexpected response status"
)
)
+ elsif action == "reading_by_id"
+ expect { perform_request }.to raise_error(
+ Regexp.new(
+ "Error reading vaccination record from Immunisations API by NHS" \
+ " Immunisations API ID ffff1111-eeee-2222-dddd-3333eeee4444: unexpected" \
+ " response status"
+ )
+ )
else
expect { perform_request }.to raise_error(
Regexp.new(
@@ -105,6 +113,13 @@
" in Immunisations API: Invalid patient ID"
)
)
+ elsif action == "reading_by_id"
+ expect { perform_request }.to raise_error(
+ Regexp.new(
+ "Error reading vaccination record from Immunisations API by" \
+ " NHS Immunisations API ID ffff1111-eeee-2222-dddd-3333eeee4444: Invalid patient ID"
+ )
+ )
else
expect { perform_request }.to raise_error(
Regexp.new(
@@ -220,7 +235,7 @@
end
it "sends the correct JSON payload" do
- expected_body = file_fixture("fhir/immunisation-create.json").read.chomp
+ expected_body = file_fixture("fhir/immunisation_create.json").read.chomp
request_stub.with do |request|
expect(request.headers).to include(
@@ -301,6 +316,120 @@
include_examples "an imms_api_integration feature flag check"
end
+ describe "read immunisation_by_nhs_immunisations_api_id" do
+ subject(:perform_request) do
+ described_class.read_immunisation_by_nhs_immunisations_api_id(
+ "ffff1111-eeee-2222-dddd-3333eeee4444"
+ )
+ end
+
+ let(:status) { 200 }
+ let(:body) { file_fixture("fhir/fhir_record_full.json").read }
+ let(:headers) { { "content-type" => "application/fhir+json" } }
+
+ let!(:request_stub) do
+ stub_request(
+ :get,
+ "https://sandbox.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization/ffff1111-eeee-2222-dddd-3333eeee4444"
+ ).to_return(status:, body:, headers:)
+ end
+
+ include_examples "an imms_api_integration feature flag check"
+
+ it "sends the correct request" do
+ request_stub.with do |request|
+ expect(request.headers).to include(
+ { "Accept" => "application/fhir+json" }
+ )
+ end
+
+ perform_request
+
+ expect(request_stub).to have_been_made
+ end
+
+ it "returns the FHIR record" do
+ expect(perform_request).to be_a FHIR::Immunization
+ end
+
+ context "an error is returned by the api" do
+ let(:code) { nil }
+ let(:diagnostics) { nil }
+
+ let(:body) do
+ {
+ resourceType: "OperationOutcome",
+ id: "bc2c3c82-4392-4314-9d6b-a7345f82d923",
+ meta: {
+ profile: [
+ "https://simplifier.net/guide/UKCoreDevelopment2/ProfileUKCore-OperationOutcome"
+ ]
+ },
+ issue: [
+ {
+ severity: "error",
+ code: "invalid",
+ details: {
+ coding: [
+ {
+ system: "https://fhir.nhs.uk/Codesystem/http-error-codes",
+ code:
+ }
+ ]
+ },
+ diagnostics:
+ }
+ ]
+ }.to_json
+ end
+
+ include_examples "unexpected response status", 201, "reading_by_id"
+ include_examples "client error (4XX) handling", "reading_by_id"
+ include_examples "generic error handling"
+ end
+ end
+
+ describe "read immunisation" do
+ subject(:perform_request) do
+ described_class.read_immunisation(vaccination_record)
+ end
+
+ let(:status) { 200 }
+ let(:body) { file_fixture("fhir/fhir_record_full.json").read }
+ let(:headers) { { "content-type" => "application/fhir+json" } }
+
+ let!(:request_stub) do
+ stub_request(
+ :get,
+ "https://sandbox.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization/ffff1111-eeee-2222-dddd-3333eeee4444"
+ ).to_return(status:, body:, headers:)
+ end
+
+ before do
+ vaccination_record.update(
+ nhs_immunisations_api_id: "ffff1111-eeee-2222-dddd-3333eeee4444"
+ )
+ end
+
+ include_examples "an imms_api_integration feature flag check"
+
+ it "sends the correct request" do
+ request_stub.with do |request|
+ expect(request.headers).to include(
+ { "Accept" => "application/fhir+json" }
+ )
+ end
+
+ perform_request
+
+ expect(request_stub).to have_been_made
+ end
+
+ it "returns the FHIR record" do
+ expect(perform_request).to be_a FHIR::Immunization
+ end
+ end
+
describe "update immunisations" do
subject(:perform_request) do
described_class.update_immunisation(vaccination_record)
@@ -324,7 +453,7 @@
end
it "sends the correct JSON payload" do
- expected_body = file_fixture("fhir/immunisation-update.json").read.chomp
+ expected_body = file_fixture("fhir/immunisation_update.json").read.chomp
request_stub.with do |request|
expect(request.headers).to include(
@@ -690,7 +819,7 @@
end
let(:status) { 200 }
- let(:body) { file_fixture("fhir/search_response_2_results.json").read }
+ let(:body) { file_fixture("fhir/search_response_full_bundle.json").read }
let(:headers) { { "content-type" => "application/fhir+json" } }
let(:diagnostics) { nil }
diff --git a/spec/lib/patient_archiver_spec.rb b/spec/lib/patient_archiver_spec.rb
new file mode 100644
index 0000000000..046ad667d0
--- /dev/null
+++ b/spec/lib/patient_archiver_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+describe PatientArchiver do
+ subject(:call) do
+ described_class.call(patient:, team:, type:, other_details:)
+ end
+
+ let(:patient) { create(:patient) }
+ let(:team) { create(:team) }
+
+ let(:type) { "imported_in_error" }
+ let(:other_details) { nil }
+
+ it "creates an archive reason" do
+ expect { call }.to change(patient.archive_reasons, :count).by(1)
+
+ archive_reason = patient.archive_reasons.last
+ expect(archive_reason).to be_imported_in_error
+ expect(archive_reason.team_id).to eq(team.id)
+ end
+
+ context "when in upcoming sessions" do
+ let(:session) { create(:session, :tomorrow, team:) }
+
+ before { create(:patient_session, patient:, session:) }
+
+ it "removes the patient from the sessions" do
+ expect(patient.sessions).to include(session)
+ call
+ expect(patient.reload.sessions).not_to include(session)
+ end
+ end
+
+ context "with a school move for the same team" do
+ let!(:school_move) do
+ create(:school_move, :to_home_educated, patient:, team:)
+ end
+
+ it "deletes the school move" do
+ expect { call }.to change(SchoolMove, :count).by(-1)
+ expect { school_move.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context "with a school move for a school in the same team" do
+ let!(:school_move) do
+ create(:school_move, :to_school, patient:, school: create(:school, team:))
+ end
+
+ it "deletes the school move" do
+ expect { call }.to change(SchoolMove, :count).by(-1)
+ expect { school_move.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context "with a school move for an unrelated team" do
+ before { create(:school_move, :to_unknown_school, patient:) }
+
+ it "doesn't delete the school move" do
+ expect { call }.not_to change(SchoolMove, :count)
+ end
+ end
+
+ context "with a school move for an unrelated school" do
+ before { create(:school_move, :to_school, patient:) }
+
+ it "doesn't delete the school move" do
+ expect { call }.not_to change(SchoolMove, :count)
+ end
+ end
+
+ context "with an other type" do
+ let(:type) { "other" }
+ let(:other_details) { "Details" }
+
+ it "creates an archive reason" do
+ expect { call }.to change(patient.archive_reasons, :count).by(1)
+
+ archive_reason = patient.archive_reasons.last
+ expect(archive_reason).to be_other
+ expect(archive_reason.team_id).to eq(team.id)
+ expect(archive_reason.other_details).to eq("Details")
+ end
+ end
+end
diff --git a/spec/lib/patient_merger_spec.rb b/spec/lib/patient_merger_spec.rb
index 6a4b712998..35ca7e5401 100644
--- a/spec/lib/patient_merger_spec.rb
+++ b/spec/lib/patient_merger_spec.rb
@@ -33,6 +33,9 @@
let(:access_log_entry) do
create(:access_log_entry, patient: patient_to_destroy)
end
+ let(:attendance_record) do
+ create(:attendance_record, :present, patient: patient_to_destroy)
+ end
let(:consent) { create(:consent, patient: patient_to_destroy, programme:) }
let(:consent_notification) do
create(
@@ -58,6 +61,13 @@
let(:patient_session) do
create(:patient_session, session:, patient: patient_to_destroy)
end
+ let(:patient_specific_direction) do
+ create(
+ :patient_specific_direction,
+ programme:,
+ patient: patient_to_destroy
+ )
+ end
let(:pre_screening) { create(:pre_screening, patient: patient_to_destroy) }
let(:school_move) do
create(:school_move, :to_school, patient: patient_to_destroy)
@@ -68,9 +78,6 @@
let(:duplicate_school_move) do
create(:school_move, patient: patient_to_keep, school: school_move.school)
end
- let(:session_attendance) do
- create(:session_attendance, :present, patient: patient_to_destroy)
- end
let(:session_notification) do
create(
:session_notification,
@@ -110,6 +117,12 @@
)
end
+ it "moves attendance records" do
+ expect { call }.to change { attendance_record.reload.patient }.to(
+ patient_to_keep
+ )
+ end
+
it "moves consents" do
expect { call }.to change { consent.reload.patient }.to(patient_to_keep)
end
@@ -154,6 +167,12 @@
)
end
+ it "moves patient specific directions" do
+ expect { call }.to change {
+ patient_specific_direction.reload.patient
+ }.to(patient_to_keep)
+ end
+
it "moves pre-screenings" do
expect { call }.to change { pre_screening.reload.patient }.to(
patient_to_keep
@@ -177,12 +196,6 @@
expect { school_move.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
- it "moves session attendances" do
- expect { call }.to change { session_attendance.reload.patient }.to(
- patient_to_keep
- )
- end
-
it "moves session notifications" do
expect { call }.to change { session_notification.reload.patient }.to(
patient_to_keep
diff --git a/spec/lib/reports/systm_one_exporter_spec.rb b/spec/lib/reports/systm_one_exporter_spec.rb
index 10bb40d575..0de12ed577 100644
--- a/spec/lib/reports/systm_one_exporter_spec.rb
+++ b/spec/lib/reports/systm_one_exporter_spec.rb
@@ -206,22 +206,6 @@
)
end
- context "HPV" do
- context "Gardasil 9 dose 2" do
- let(:vaccine) { Vaccine.find_by!(brand: "Gardasil 9") }
- let(:dose_sequence) { 2 }
-
- it { should eq("Y19a5") }
- end
-
- context "Gardasil 9 dose 3" do
- let(:vaccine) { Vaccine.find_by!(brand: "Gardasil 9") }
- let(:dose_sequence) { 3 }
-
- it { should eq("Y19a6") }
- end
- end
-
context "flu" do
let(:programme) { create(:programme, :flu_all_vaccines) }
let(:dose_sequence) { 1 }
@@ -255,18 +239,57 @@
end
end
+ context "HPV" do
+ context "Gardasil 9 dose 2" do
+ let(:vaccine) { Vaccine.find_by!(brand: "Gardasil 9") }
+ let(:dose_sequence) { 2 }
+
+ it { should eq("Y19a5") }
+ end
+
+ context "Gardasil 9 dose 3" do
+ let(:vaccine) { Vaccine.find_by!(brand: "Gardasil 9") }
+ let(:dose_sequence) { 3 }
+
+ it { should eq("Y19a6") }
+ end
+ end
+
+ context "MenACWY" do
+ let(:programme) { create(:programme, :menacwy_all_vaccines) }
+ let(:dose_sequence) { nil }
+
+ context "MenQuadfi" do
+ let(:vaccine) { Vaccine.find_by!(brand: "MenQuadfi") }
+
+ it { should eq("YbXKi") }
+ end
+
+ context "Menveo" do
+ let(:vaccine) { Vaccine.find_by!(brand: "Menveo") }
+
+ it { should eq("Menveo") }
+ end
+
+ context "Nimenrix" do
+ let(:vaccine) { Vaccine.find_by!(brand: "Nimenrix") }
+
+ it { should eq("Nimenrix") }
+ end
+ end
+
context "unknown vaccine and no dose sequence" do
- let(:vaccine) { create(:vaccine, :menquadfi) }
+ let(:vaccine) { create(:vaccine, :menveo) }
let(:dose_sequence) { nil }
- it { should eq("MenQuadfi") }
+ it { should eq("Menveo") }
end
context "unknown vaccine and a dose sequence" do
- let(:vaccine) { create(:vaccine, :menquadfi) }
+ let(:vaccine) { create(:vaccine, :menveo) }
let(:dose_sequence) { 1 }
- it { should eq("MenQuadfi Part 1") }
+ it { should eq("Menveo Part 1") }
end
end
diff --git a/spec/lib/status_generator/registration_spec.rb b/spec/lib/status_generator/registration_spec.rb
index ffc8274d56..98a6f348f4 100644
--- a/spec/lib/status_generator/registration_spec.rb
+++ b/spec/lib/status_generator/registration_spec.rb
@@ -5,10 +5,8 @@
described_class.new(
patient:,
session:,
- session_attendance:
- patient_session.session_attendances.find_by(
- session_date: session.session_dates.last
- ),
+ attendance_record:
+ patient_session.attendance_records.find_by(date: session.dates.last),
vaccination_records: patient.vaccination_records
)
end
@@ -34,10 +32,11 @@
context "with a session attendance for a different day to today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:present,
patient:,
- session_date: session.session_dates.first
+ session:,
+ date: session.dates.first
)
end
@@ -47,10 +46,11 @@
context "with a present session attendance for today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:present,
patient:,
- session_date: session.session_dates.second
+ session:,
+ date: session.dates.second
)
end
@@ -60,10 +60,11 @@
context "with an absent session attendance for today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:absent,
patient:,
- session_date: session.session_dates.second
+ session:,
+ date: session.dates.second
)
end
diff --git a/spec/lib/status_generator/session_spec.rb b/spec/lib/status_generator/session_spec.rb
index 05c25b4e27..123dd50e9d 100644
--- a/spec/lib/status_generator/session_spec.rb
+++ b/spec/lib/status_generator/session_spec.rb
@@ -5,7 +5,7 @@
described_class.new(
session_id: patient_session.session_id,
academic_year: patient_session.academic_year,
- session_attendance: patient_session.session_attendances.last,
+ attendance_record: patient_session.attendance_records.last,
programme:,
patient:,
consents: patient.consents,
@@ -79,7 +79,7 @@
end
context "when not attending the session" do
- before { create(:session_attendance, :absent, patient:, session:) }
+ before { create(:attendance_record, :absent, patient:, session:) }
it { should be(:absent_from_session) }
end
@@ -275,7 +275,7 @@
context "with absent from session attendance" do
before do
- create(:session_attendance, :absent, patient:, session:, created_at:)
+ create(:attendance_record, :absent, patient:, session:, created_at:)
end
it { should eq(created_at) }
@@ -297,7 +297,7 @@
)
create(
- :session_attendance,
+ :attendance_record,
:absent,
patient:,
session:,
@@ -320,7 +320,7 @@
)
create(
- :session_attendance,
+ :attendance_record,
:absent,
patient:,
session:,
diff --git a/spec/models/attendance_record_spec.rb b/spec/models/attendance_record_spec.rb
new file mode 100644
index 0000000000..a8ba46f7a5
--- /dev/null
+++ b/spec/models/attendance_record_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: attendance_records
+#
+# id :bigint not null, primary key
+# attending :boolean not null
+# date :date not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# location_id :bigint not null
+# patient_id :bigint not null
+#
+# Indexes
+#
+# idx_on_patient_id_location_id_date_e5912f40c4 (patient_id,location_id,date) UNIQUE
+# index_attendance_records_on_location_id (location_id)
+# index_attendance_records_on_patient_id (patient_id)
+#
+# Foreign Keys
+#
+# fk_rails_... (location_id => locations.id)
+# fk_rails_... (patient_id => patients.id)
+#
+describe AttendanceRecord do
+ subject(:attendance_record) { build(:attendance_record) }
+
+ describe "associations" do
+ it { should belong_to(:patient) }
+ it { should belong_to(:location) }
+ end
+end
diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb
index e27e17dabf..e08bb06341 100644
--- a/spec/models/class_import_spec.rb
+++ b/spec/models/class_import_spec.rb
@@ -538,4 +538,96 @@
end
end
end
+
+ describe "#pds_match_rate" do
+ subject(:pds_match_rate) { class_import.pds_match_rate }
+
+ context "when there are no changesets" do
+ it { should eq(0) }
+ end
+
+ context "with some changesets" do
+ before do
+ create_list(
+ :patient_changeset,
+ 4,
+ :with_pds_match,
+ import: class_import
+ )
+ create_list(:patient_changeset, 6, import: class_import)
+ end
+
+ it "returns percentage" do
+ expect(pds_match_rate).to eq(40.0)
+ end
+ end
+
+ context "with only some attempted searches" do
+ before do
+ create_list(
+ :patient_changeset,
+ 4,
+ :with_pds_match,
+ import: class_import
+ )
+ create_list(
+ :patient_changeset,
+ 6,
+ :without_pds_search_attempted,
+ import: class_import
+ )
+ end
+
+ it "returns 100" do
+ expect(pds_match_rate).to eq(100)
+ end
+ end
+ end
+
+ describe "#validate_pds_match_rate!" do
+ subject(:validate_pds_match_rate!) { class_import.validate_pds_match_rate! }
+
+ context "when match rate is equal to threshold" do
+ before do
+ create_list(
+ :patient_changeset,
+ 7,
+ :with_pds_match,
+ import: class_import
+ )
+ create_list(:patient_changeset, 3, import: class_import)
+ end
+
+ it "does not mark as low_pds_match_rate" do
+ validate_pds_match_rate!
+ expect(class_import.reload.status).not_to eq("low_pds_match_rate")
+ end
+ end
+
+ context "when match rate is below threshold and enough changesets" do
+ before do
+ create_list(
+ :patient_changeset,
+ 6,
+ :with_pds_match,
+ import: class_import
+ )
+ create_list(:patient_changeset, 4, import: class_import)
+ end
+
+ it "marks the import as low_pds_match_rate" do
+ validate_pds_match_rate!
+ expect(class_import.reload.status).to eq("low_pds_match_rate")
+ end
+ end
+
+ context "when there are fewer than 10 changesets" do
+ before { create_list(:patient_changeset, 5, import: class_import) }
+
+ it "skips validation" do
+ validate_pds_match_rate!
+ expect(class_import.reload.status).not_to eq("low_pds_match_rate")
+ end
+ end
+ end
end
diff --git a/spec/models/draft_vaccination_record_spec.rb b/spec/models/draft_vaccination_record_spec.rb
index 0f48f7ca9e..a23267b9e0 100644
--- a/spec/models/draft_vaccination_record_spec.rb
+++ b/spec/models/draft_vaccination_record_spec.rb
@@ -358,7 +358,7 @@
context "when vaccination is not administered" do
let(:attributes) { valid_not_administered_attributes }
- it { should be true }
+ it { should be(true) }
end
context "when delivery method is nasal_spray" do
@@ -367,41 +367,39 @@
end
context "when consent is given for nasal" do
- let(:patient) do
- create(
- :patient,
- :consent_given_nasal_only_triage_not_needed,
- session:
- )
- end
+ before { create(:consent, :given_nasal, patient:, programme:) }
- it { should be true }
+ it { should be(true) }
end
context "when consent is given for injection" do
- let(:patient) do
- create(
- :patient,
- :consent_given_injection_only_triage_needed,
- session:
- )
- end
+ before { create(:consent, :given_injection, patient:, programme:) }
- it { should be false }
+ it { should be(false) }
end
context "when triage is safe for nasal" do
- let(:patient) do
- create(:patient, :triage_safe_to_vaccinate_nasal, session:)
+ before do
+ create(:consent, :given_nasal, patient:, programme:)
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient:,
+ programme:,
+ vaccine_method: "nasal"
+ )
end
- it { should be true }
+ it { should be(true) }
end
context "when triage is safe for injection" do
- let(:patient) { create(:patient, :triage_safe_to_vaccinate, session:) }
+ before do
+ create(:consent, :given_injection, patient:, programme:)
+ create(:triage, :ready_to_vaccinate, patient:, programme:)
+ end
- it { should be false }
+ it { should be(false) }
end
end
@@ -411,37 +409,39 @@
end
context "when consent is given for injection" do
- let(:patient) do
- create(
- :patient,
- :consent_given_injection_only_triage_not_needed,
- session:
- )
- end
+ before { create(:consent, :given_injection, patient:, programme:) }
- it { should be true }
+ it { should be(true) }
end
context "when consent is given for nasal" do
- let(:patient) do
- create(:patient, :consent_given_nasal_only_triage_needed, session:)
- end
+ before { create(:consent, :given_nasal, patient:, programme:) }
- it { should be false }
+ it { should be(false) }
end
context "when triage is safe for injection" do
- let(:patient) { create(:patient, :triage_safe_to_vaccinate, session:) }
+ before do
+ create(:consent, :given_injection, patient:, programme:)
+ create(:triage, :ready_to_vaccinate, patient:, programme:)
+ end
- it { should be true }
+ it { should be(true) }
end
context "when triage is safe for nasal" do
- let(:patient) do
- create(:patient, :triage_safe_to_vaccinate_nasal, session:)
+ before do
+ create(:consent, :given_nasal, patient:, programme:)
+ create(
+ :triage,
+ :ready_to_vaccinate,
+ patient:,
+ programme:,
+ vaccine_method: "nasal"
+ )
end
- it { should be false }
+ it { should be(false) }
end
end
end
diff --git a/spec/models/patient/registration_status_spec.rb b/spec/models/patient/registration_status_spec.rb
index dde9e6865e..69271d2751 100644
--- a/spec/models/patient/registration_status_spec.rb
+++ b/spec/models/patient/registration_status_spec.rb
@@ -47,12 +47,12 @@
it { should belong_to(:session) }
end
- describe "#session_attendance" do
+ describe "#attendance_record" do
subject do
described_class
- .includes(:session_attendances)
+ .includes(:attendance_records)
.find(patient_registration_status.id)
- .session_attendance
+ .attendance_record
end
let(:patient_registration_status) do
@@ -70,25 +70,15 @@
end
context "with an attendance today and yesterday" do
- let(:today_session_attendance) do
- create(
- :session_attendance,
- :present,
- patient:,
- session_date: session.session_dates.find_by(value: Date.current)
- )
+ let(:today_attendance_record) do
+ create(:attendance_record, :present, :today, patient:, session:)
end
before do
- create(
- :session_attendance,
- :absent,
- patient:,
- session_date: session.session_dates.find_by(value: Date.yesterday)
- )
+ create(:attendance_record, :absent, :yesterday, patient:, session:)
end
- it { should eq(today_session_attendance) }
+ it { should eq(today_attendance_record) }
end
end
@@ -102,10 +92,11 @@
context "with a session attendance for a different day to today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:present,
patient:,
- session_date: session.session_dates.first
+ session:,
+ date: session.dates.first
)
end
@@ -115,10 +106,11 @@
context "with a present session attendance for today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:present,
patient:,
- session_date: session.session_dates.second
+ session:,
+ date: session.dates.second
)
end
@@ -128,10 +120,11 @@
context "with an absent session attendance for today" do
before do
create(
- :session_attendance,
+ :attendance_record,
:absent,
patient:,
- session_date: session.session_dates.second
+ session:,
+ date: session.dates.second
)
end
diff --git a/spec/models/patient_import_spec.rb b/spec/models/patient_import_spec.rb
new file mode 100644
index 0000000000..ffc2cb9caa
--- /dev/null
+++ b/spec/models/patient_import_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+describe PatientImport do
+ let(:team) { create(:team) }
+ let(:cohort_import) { create(:cohort_import, team:) }
+
+ describe "#bulk_import" do
+ let!(:first_patient) { create(:patient) }
+ let!(:second_patient) { create(:patient) }
+ let!(:third_patient) { create(:patient, nhs_number: nil) }
+
+ before do
+ cohort_import.instance_variable_set(
+ :@patients_batch,
+ Set.new([first_patient, second_patient, third_patient])
+ )
+ cohort_import.instance_variable_set(:@parents_batch, Set.new)
+ cohort_import.instance_variable_set(:@relationships_batch, Set.new)
+ cohort_import.instance_variable_set(:@school_moves_to_confirm, Set.new)
+ cohort_import.instance_variable_set(:@school_moves_to_save, Set.new)
+ end
+
+ context "when patients have NHS number changes" do
+ before do
+ allow(first_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(true)
+ allow(second_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(true)
+ allow(third_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ end
+
+ it "enqueues SearchVaccinationRecordsInNHSJob for patients with NHS number changes" do
+ cohort_import.send(:bulk_import, rows: :all)
+
+ expect(SearchVaccinationRecordsInNHSJob).to have_enqueued_sidekiq_job(
+ first_patient.id
+ )
+ expect(SearchVaccinationRecordsInNHSJob).to have_enqueued_sidekiq_job(
+ second_patient.id
+ )
+ expect(
+ SearchVaccinationRecordsInNHSJob
+ ).not_to have_enqueued_sidekiq_job(third_patient.id)
+ end
+ end
+
+ context "when no patients have NHS number changes" do
+ before do
+ allow(first_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ allow(second_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ allow(third_patient).to receive(
+ :nhs_number_previously_changed?
+ ).and_return(false)
+ end
+
+ it "does not enqueue SearchVaccinationRecordsInNHSJob" do
+ expect {
+ cohort_import.send(:bulk_import, rows: :all)
+ }.not_to enqueue_sidekiq_job(SearchVaccinationRecordsInNHSJob)
+ end
+ end
+ end
+end
diff --git a/spec/models/patient_session_spec.rb b/spec/models/patient_session_spec.rb
index 46e5c1d36f..1602acb0e7 100644
--- a/spec/models/patient_session_spec.rb
+++ b/spec/models/patient_session_spec.rb
@@ -168,7 +168,7 @@
it { should be true }
it "is safe with only absent attendances" do
- create(:session_attendance, :absent, patient:, session:)
+ create(:attendance_record, :absent, patient:, session:)
expect(safe_to_destroy?).to be true
end
end
@@ -185,12 +185,12 @@
end
it "is unsafe with present attendances" do
- create(:session_attendance, :present, patient:, session:)
+ create(:attendance_record, :present, patient:, session:)
expect(safe_to_destroy?).to be false
end
it "is unsafe with mixed conditions" do
- create(:session_attendance, :absent, patient:, session:)
+ create(:attendance_record, :absent, patient:, session:)
create(:vaccination_record, programme:, patient:, session:)
expect(safe_to_destroy?).to be false
end
diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb
index 1d7928befb..ef94acf2a0 100644
--- a/spec/models/patient_spec.rb
+++ b/spec/models/patient_spec.rb
@@ -966,6 +966,34 @@
end
end
+ describe "#should_search_vaccinations_from_nhs_immunisations_api?" do
+ subject(:should_search_vaccinations_from_nhs_immunisations_api?) do
+ patient.send(:should_search_vaccinations_from_nhs_immunisations_api?)
+ end
+
+ let(:patient) { create(:patient, nhs_number: "9449310475") }
+
+ context "when nhs_number changes" do
+ it "syncs vaccination records to NHS Immunisations API" do
+ patient.update!(nhs_number: "9449304130")
+
+ expect(
+ should_search_vaccinations_from_nhs_immunisations_api?
+ ).to be_truthy
+ end
+ end
+
+ context "when other attributes change" do
+ it "does not sync vaccination records to NHS Immunisations API" do
+ patient.update!(given_name: "NewName")
+
+ expect(
+ should_search_vaccinations_from_nhs_immunisations_api?
+ ).to be_falsy
+ end
+ end
+ end
+
describe "#stage_changes" do
let(:patient) { create(:patient, given_name: "John", family_name: "Doe") }
diff --git a/spec/models/programme_spec.rb b/spec/models/programme_spec.rb
index df237edb80..1a63e5ae23 100644
--- a/spec/models/programme_spec.rb
+++ b/spec/models/programme_spec.rb
@@ -31,6 +31,54 @@
it { should_not include(menacwy_programme) }
it { should_not include(td_ipv_programme) }
end
+
+ describe "#can_sync_to_immunisations_api" do
+ subject(:scope) { described_class.can_sync_to_immunisations_api }
+
+ let(:expectations) do
+ { flu: true, hpv: true, menacwy: false, td_ipv: false }
+ end
+
+ let!(:programmes) do
+ expectations.keys.index_with { |k| create(:programme, k) }
+ end
+
+ it "includes exactly the programmes expected to sync" do
+ expected =
+ expectations.select { |_k, v| v }.keys.map { |k| programmes.fetch(k) }
+ expect(scope).to match_array(expected)
+ end
+
+ it "matches the predicate for each record" do
+ predicate_true =
+ programmes.values.select(&:can_sync_to_immunisations_api?)
+ expect(scope.to_a).to match_array(predicate_true)
+ end
+ end
+
+ describe "#can_search_in_immunisations_api" do
+ subject(:scope) { described_class.can_search_in_immunisations_api }
+
+ let(:expectations) do
+ { flu: true, hpv: false, menacwy: false, td_ipv: false }
+ end
+
+ let!(:programmes) do
+ expectations.keys.index_with { |k| create(:programme, k) }
+ end
+
+ it "includes exactly the programmes expected to sync" do
+ expected =
+ expectations.select { |_k, v| v }.keys.map { |k| programmes.fetch(k) }
+ expect(scope).to match_array(expected)
+ end
+
+ it "matches the predicate for each record" do
+ predicate_true =
+ programmes.values.select(&:can_search_in_immunisations_api?)
+ expect(scope.to_a).to match_array(predicate_true)
+ end
+ end
end
describe "validations" do
diff --git a/spec/models/session_attendance_spec.rb b/spec/models/session_attendance_spec.rb
deleted file mode 100644
index fc037f2ffe..0000000000
--- a/spec/models/session_attendance_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-# frozen_string_literal: true
-
-# == Schema Information
-#
-# Table name: session_attendances
-#
-# id :bigint not null, primary key
-# attending :boolean not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# patient_id :bigint not null
-# session_date_id :bigint not null
-#
-# Indexes
-#
-# index_session_attendances_on_patient_id (patient_id)
-# index_session_attendances_on_patient_id_and_session_date_id (patient_id,session_date_id) UNIQUE
-# index_session_attendances_on_session_date_id (session_date_id)
-#
-# Foreign Keys
-#
-# fk_rails_... (patient_id => patients.id)
-# fk_rails_... (session_date_id => session_dates.id)
-#
-describe SessionAttendance do
- subject(:session_attendance) { build(:session_attendance) }
-
- describe "associations" do
- it { should belong_to(:patient) }
- it { should belong_to(:session_date) }
-
- it { should have_one(:session).through(:session_date) }
- end
-end
diff --git a/spec/models/session_date_spec.rb b/spec/models/session_date_spec.rb
index 48ef5f4e0f..96abb08118 100644
--- a/spec/models/session_date_spec.rb
+++ b/spec/models/session_date_spec.rb
@@ -73,7 +73,7 @@
end
context "with a session attendance" do
- before { create(:session_attendance, :present, session:) }
+ before { create(:attendance_record, :present, session:) }
it { should be(true) }
end
diff --git a/spec/models/vaccination_report_spec.rb b/spec/models/vaccination_report_spec.rb
deleted file mode 100644
index 5864509995..0000000000
--- a/spec/models/vaccination_report_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-describe VaccinationReport do
- describe "file_formats" do
- subject { described_class.file_formats(programme) }
-
- context "when programme is hpv" do
- let(:programme) { create(:programme, :hpv) }
-
- it { should eq(%w[careplus mavis systm_one]) }
- end
-
- context "when programme is menacwy" do
- let(:programme) { create(:programme, :menacwy) }
-
- it { should eq(%w[careplus mavis]) }
- end
-
- context "when programme is flu" do
- let(:programme) { create(:programme, :flu) }
-
- it { should eq(%w[careplus mavis systm_one]) }
- end
- end
-end
diff --git a/spec/policies/session_attendance_policy_spec.rb b/spec/policies/attendance_record_policy_spec.rb
similarity index 81%
rename from spec/policies/session_attendance_policy_spec.rb
rename to spec/policies/attendance_record_policy_spec.rb
index c2940806db..bf9eb24d48 100644
--- a/spec/policies/session_attendance_policy_spec.rb
+++ b/spec/policies/attendance_record_policy_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-describe SessionAttendancePolicy do
- subject(:policy) { described_class.new(user, session_attendance) }
+describe AttendanceRecordPolicy do
+ subject(:policy) { described_class.new(user, attendance_record) }
let(:user) { create(:nurse) }
@@ -12,17 +12,13 @@
shared_examples "allow if not yet vaccinated or seen by nurse" do
context "with a new session attendance" do
- let(:session_attendance) do
- build(:session_attendance, patient:, session:)
- end
+ let(:attendance_record) { build(:attendance_record, patient:, session:) }
it { should be(true) }
end
context "with session attendance and one vaccination record from a different session" do
- let(:session_attendance) do
- build(:session_attendance, patient:, session:)
- end
+ let(:attendance_record) { build(:attendance_record, patient:, session:) }
before do
create(
@@ -39,9 +35,7 @@
end
context "with session attendance and both vaccination records" do
- let(:session_attendance) do
- build(:session_attendance, patient:, session:)
- end
+ let(:attendance_record) { build(:attendance_record, patient:, session:) }
before do
programmes.each do |programme|
@@ -61,9 +55,7 @@
end
context "with session attendance and both vaccination records from a different date" do
- let(:session_attendance) do
- build(:session_attendance, patient:, session:)
- end
+ let(:attendance_record) { build(:attendance_record, patient:, session:) }
around { |example| travel_to(Date.new(2025, 8, 31)) { example.run } }
diff --git a/spec/policies/vaccination_record_policy_spec.rb b/spec/policies/vaccination_record_policy_spec.rb
index ef1e15a055..0c53a5ac2b 100644
--- a/spec/policies/vaccination_record_policy_spec.rb
+++ b/spec/policies/vaccination_record_policy_spec.rb
@@ -3,12 +3,12 @@
describe VaccinationRecordPolicy do
subject(:policy) { described_class.new(user, vaccination_record) }
+ let(:programme) { create(:programme) }
+ let(:team) { create(:team, programmes: [programme]) }
+
describe "update?" do
subject(:update?) { policy.update? }
- let(:programme) { create(:programme) }
- let(:team) { create(:team, programmes: [programme]) }
-
let(:vaccination_record) { create(:vaccination_record, programme:) }
context "with a medical secretary" do
@@ -60,29 +60,58 @@
describe "destroy?" do
subject(:destroy?) { policy.destroy? }
- let(:vaccination_record) { create(:vaccination_record) }
+ context "when vaccination record is from the nhs immunisations api" do
+ let(:vaccination_record) do
+ create(:vaccination_record, programme:, source: "nhs_immunisations_api")
+ end
- context "with a medical secretary" do
- let(:user) { build(:medical_secretary) }
+ context "with a medical secretary with superuser access" do
+ let(:user) { build(:medical_secretary, :superuser) }
- it { should be(false) }
+ it { should be(false) }
+ end
- context "and superuser access" do
- let(:user) { build(:medical_secretary, :superuser) }
+ context "with a nurse with superuser access" do
+ let(:user) { build(:nurse, :superuser) }
- it { should be(true) }
+ it { should be(false) }
end
end
- context "with a nurse" do
- let(:user) { build(:nurse) }
+ context "when vaccination record is managed in mavis" do
+ let(:session) { create(:session, team:, programmes: [programme]) }
+ let(:vaccination_record) do
+ create(
+ :vaccination_record,
+ team:,
+ programme:,
+ source: "service",
+ session:
+ )
+ end
- it { should be(false) }
+ context "with a medical secretary" do
+ let(:user) { build(:medical_secretary) }
- context "and superuser access" do
- let(:user) { build(:nurse, :superuser) }
+ it { should be(false) }
- it { should be(true) }
+ context "and superuser access" do
+ let(:user) { build(:medical_secretary, :superuser) }
+
+ it { should be(true) }
+ end
+ end
+
+ context "with a nurse" do
+ let(:user) { build(:nurse) }
+
+ it { should be(false) }
+
+ context "and superuser access" do
+ let(:user) { build(:nurse, :superuser) }
+
+ it { should be(true) }
+ end
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index ba538b979a..506caf94d9 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -110,6 +110,7 @@
require "capybara/cuprite"
require "capybara-screenshot/rspec"
require "sidekiq/testing"
+require "rack_session_access/capybara"
Faker::Config.locale = "en-GB"
@@ -153,7 +154,9 @@
# directory. Alternatively, in the individual `*_spec.rb` files, manually
# require only the support files necessary.
#
-Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |f| require f }
+Dir[Rails.root.join("spec/support/**/*.rb")].sort.each do |f|
+ require f unless f.end_with?("_spec.rb")
+end
# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
diff --git a/spec/support/imports_helper.rb b/spec/support/imports_helper.rb
index dc2fd5969d..1127b65f54 100644
--- a/spec/support/imports_helper.rb
+++ b/spec/support/imports_helper.rb
@@ -14,6 +14,10 @@ def wait_for_import_to_complete(import_class)
perform_enqueued_jobs(only: CommitPatientChangesetsJob)
+ click_on_most_recent_import(import_class)
+ end
+
+ def click_on_most_recent_import(import_class)
click_on import_class.order(:created_at).last.created_at.to_fs(:long),
match: :first
end
diff --git a/spec/support/spec/within_academic_year_spec.rb b/spec/support/spec/within_academic_year_spec.rb
new file mode 100644
index 0000000000..c397671e85
--- /dev/null
+++ b/spec/support/spec/within_academic_year_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+describe "WithinAcademicYear sets the current date" do
+ subject(:current_date) { Date.current }
+
+ let(:current_academic_year) { AcademicYear.current }
+ let(:next_academic_year) { current_academic_year + 1 }
+ # We define the next one to try to make clear even though we are testing a
+ # date in the "next" year, our intention isn't to place a date in the next
+ # academic year. i.e we'll be defining a date before the end of the academic
+ # year.
+ let(:preparatory_period_year) { current_academic_year + 1 }
+
+ prepend_before { travel_to(test_date) }
+
+ describe "with within_academic_year not set" do
+ context "within the academic year" do
+ let(:test_date) { Date.new(current_academic_year, 8, 14) }
+
+ it { should eq test_date }
+ end
+
+ context "during the preparatory period" do
+ let(:test_date) { Date.new(preparatory_period_year, 8, 14) }
+
+ it { should eq test_date }
+ end
+ end
+
+ describe "when within_academic_year is false", within_academic_year: false do
+ context "within the academic year" do
+ let(:test_date) { Date.new(current_academic_year, 8, 14) }
+
+ it { should eq test_date }
+ end
+
+ context "during the preparatory period" do
+ let(:test_date) { Date.new(preparatory_period_year, 8, 14) }
+
+ it { should eq test_date }
+ end
+ end
+
+ describe "when within_academic_year is true", :within_academic_year do
+ context "within the academic year" do
+ let(:test_date) { Date.new(current_academic_year, 9, 14) }
+
+ it { should eq Date.new(current_academic_year, 9, 14) }
+ end
+
+ context "during preparatory period" do
+ let(:test_date) { Date.new(preparatory_period_year, 8, 14) }
+
+ it { should eq Date.new(next_academic_year, 9, 1) }
+ end
+ end
+
+ describe "using from_start to ensure back-dated dates are handled correctly",
+ within_academic_year: {
+ from_start: 21.days
+ } do
+ context "when too close to the beginning of the academic year" do
+ let(:test_date) { Date.new(current_academic_year, 9, 14) }
+
+ it { should eq Date.new(current_academic_year, 9, 22) }
+ end
+
+ context "within the academic year" do
+ let(:test_date) { Date.new(current_academic_year, 10, 1) }
+
+ it { should eq Date.new(current_academic_year, 10, 1) }
+ end
+
+ context "during the preparatory period" do
+ let(:test_date) { Date.new(preparatory_period_year, 8, 14) }
+
+ it { should eq Date.new(next_academic_year, 9, 22) }
+ end
+ end
+end
diff --git a/spec/support/within_academic_year.rb b/spec/support/within_academic_year.rb
new file mode 100644
index 0000000000..752eed582f
--- /dev/null
+++ b/spec/support/within_academic_year.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.configure do |config|
+ config.before do |example|
+ next unless example.metadata[:within_academic_year]
+
+ within_academic_year = example.metadata[:within_academic_year]
+
+ from_start =
+ (within_academic_year.is_a?(Hash) ? within_academic_year[:from_start] : 0)
+ test_date = Date.current - from_start
+
+ if test_date.academic_year != AcademicYear.pending
+ travel_to(Date.new(AcademicYear.pending, 9, 1) + from_start)
+ end
+ end
+end
diff --git a/spec/validators/notify_safe_email_validator_spec.rb b/spec/validators/notify_safe_email_validator_spec.rb
index bef3587277..352e2e62bf 100644
--- a/spec/validators/notify_safe_email_validator_spec.rb
+++ b/spec/validators/notify_safe_email_validator_spec.rb
@@ -62,6 +62,7 @@
";beginning-semicolon@domain.co.uk",
"middle-semicolon@domain.co;uk",
"trailing-semicolon@domain.com;",
+ "trailing-dot@domain.com.",
'"email+leading-quotes@domain.com',
'email+middle"-quotes@domain.com',
'"quoted-local-part"@domain.com',
diff --git a/terraform/app/env/production.tfvars b/terraform/app/env/production.tfvars
index 527172ef0a..b571f99b77 100644
--- a/terraform/app/env/production.tfvars
+++ b/terraform/app/env/production.tfvars
@@ -25,7 +25,7 @@ ecs_log_retention_days = 30
backup_retention_period = 7
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
access_logs_bucket = "nhse-mavis-access-logs-production"
-max_aurora_capacity_units = 32
+max_aurora_capacity_units = 64
container_insights = "enhanced"
enable_backup_to_vault = true
diff --git a/terraform/app/env/qa.tfvars b/terraform/app/env/qa.tfvars
index a0134184a5..d3e4ee6c6b 100644
--- a/terraform/app/env/qa.tfvars
+++ b/terraform/app/env/qa.tfvars
@@ -21,7 +21,7 @@ http_hosts = {
MAVIS__GIVE_OR_REFUSE_CONSENT_HOST = "qa.mavistesting.com"
}
appspec_bucket = "nhse-mavis-appspec-bucket-qa"
-max_aurora_capacity_units = 32
+max_aurora_capacity_units = 64
container_insights = "enhanced"
enable_backup_to_vault = true