diff --git a/app/models/urn.rb b/app/models/urn.rb index 56330c1c..e88c85e1 100644 --- a/app/models/urn.rb +++ b/app/models/urn.rb @@ -1,38 +1,41 @@ -# frozen_string_literal: true - -# Urn represents a Uniform Resource Name (URN) generator. -# It generates a URN with a fixed prefix and a random alphanumeric suffix. -# +# == Schema Information # -# Example: +# Table name: urns # -# Urn.generate('teacher') # => "IRPTE12345" -# Urn.generate('teacher') # => "IRPTE12345" -# Urn.generate('salaried_trainee') # => "IRPST12345" +# id :bigint not null, primary key +# code :string +# prefix :string +# suffix :integer +# created_at :datetime not null +# updated_at :datetime not null # -class Urn - attr_reader :value - attr_writer :suffix - - def self.generate(applicant_type) - code = applicant_type_code(applicant_type) - PREFIX + code + Array.new(LENGTH) { CHARSET.sample }.join - end +class Urn < ApplicationRecord + class NoUrnAvailableError < StandardError; end - CHARSET = %w[0 1 2 3 4 5 6 7 8 9].freeze - PREFIX = "IRP" - LENGTH = 5 - private_constant :CHARSET, :PREFIX, :LENGTH + PREFIX = "IRP".freeze + MAX_SUFFIX = 99_999 + PADDING_SIZE = MAX_SUFFIX.to_s.size + VALID_CODES = { + "teacher" => "TE", + "salaried_trainee" => "ST", + }.freeze - def self.applicant_type_code(applicant_type) - case applicant_type - when "teacher" - "TE" - when "salaried_trainee" - "ST" - else - raise(ArgumentError, "Invalid applicant type: #{applicant_type}") + def self.next(route) + code = VALID_CODES.fetch(route) + Urn.transaction do + urn = find_by!(code:) + urn.destroy! + urn.to_s end + rescue KeyError => e + Sentry.capture_exception(e) + raise(ArgumentError, "Unknown route #{route}") + rescue ActiveRecord::RecordNotFound => e + Sentry.capture_exception(e) + raise(NoUrnAvailableError, "There no more unique URN available for #{route}") + end + + def to_s + [prefix, code, sprintf("%0#{PADDING_SIZE}d", suffix)].join end - private_methods :applicant_type_code end diff --git a/app/services/generate_urns.rb b/app/services/generate_urns.rb new file mode 100644 index 00000000..a0a33274 --- /dev/null +++ b/app/services/generate_urns.rb @@ -0,0 +1,62 @@ +# Service responsible for the generation of all urns +# It will save the set of available urns based the current URN format +# and store it in the database URNs table. +# +# The Urn model will then be able to fetch the next available unique and +# random urn for application submition +# +# Example: +# +# Urn.next("teacher") # => "IRPTE12345" +# Urn.next("teacher") # => "IRPTE12345" +# Urn.next("salaried_trainee") # => "IRPST12345" +# +class GenerateUrns + def self.call + return if Urn.count.positive? # Do not override the current urn state + + Urn.transaction do + Urn::VALID_CODES.each_value do |code| + new(code:).generate + end + end + end + + def initialize(code:) + @code = code + end + + attr_reader :code + + def generate + data = unused_urns.map do |suffix| + { prefix: Urn::PREFIX, code: code, suffix: suffix } + end + Urn.insert_all(data) # rubocop:disable Rails/SkipsModelValidations + end + +private + + def unused_urns + generate_suffixes - existing_suffixes + end + + def generate_suffixes + Array + .new(Urn::MAX_SUFFIX) { _1 } + .drop(1) + .shuffle! + end + + def existing_suffixes + route = Urn::VALID_CODES.key(code) + Application + .where(application_route: route) + .pluck(:urn) + .map { extract_suffix(_1) } + end + + def extract_suffix(urn) + urn.match(/\d+/)[0].to_i + end +end diff --git a/app/services/submit_form.rb b/app/services/submit_form.rb index 939f2756..23270446 100644 --- a/app/services/submit_form.rb +++ b/app/services/submit_form.rb @@ -88,7 +88,7 @@ def create_application date_of_entry: form.date_of_entry, start_date: form.start_date, subject: SubjectStep.new(form).answer.formatted_value, - urn: Urn.generate(form.application_route), + urn: Urn.next(form.application_route), visa_type: form.visa_type, ) end diff --git a/bin/app-startup.sh b/bin/app-startup.sh index 0df4c416..9da89774 100755 --- a/bin/app-startup.sh +++ b/bin/app-startup.sh @@ -7,6 +7,8 @@ set -e # run migrations bundle exec rails db:migrate +# Front load urn generation +bundle exec rake urn:generate # add seed data in review environment if [[ "$RAILS_ENV" = "review" || "$RAILS_ENV" = "development" ]]; then diff --git a/db/migrate/20230927092305_create_urns.rb b/db/migrate/20230927092305_create_urns.rb new file mode 100644 index 00000000..e13281c1 --- /dev/null +++ b/db/migrate/20230927092305_create_urns.rb @@ -0,0 +1,12 @@ +class CreateUrns < ActiveRecord::Migration[7.0] + def change + create_table :urns do |t| + t.string :prefix + t.string :code + t.integer :suffix + + t.timestamps + end + add_index :urns, :code + end +end diff --git a/db/schema.rb b/db/schema.rb index 2a356c60..a9313f30 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[7.0].define(version: 2023_09_15_100841) do +ActiveRecord::Schema[7.0].define(version: 2023_09_27_092305) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" @@ -173,6 +173,15 @@ t.datetime "updated_at", null: false end + create_table "urns", force: :cascade do |t| + t.string "prefix" + t.string "code" + t.integer "suffix" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_urns_on_code" + end + create_table "users", force: :cascade do |t| t.citext "email" t.datetime "created_at", null: false diff --git a/lib/tasks/urn.rake b/lib/tasks/urn.rake new file mode 100644 index 00000000..64c42ccd --- /dev/null +++ b/lib/tasks/urn.rake @@ -0,0 +1,10 @@ +namespace :urn do + desc "generate and randomize unique urns" + task generate: :environment do + puts "running rake task urn:generate ..." + a = Urn.count + GenerateUrns.call + b = Urn.count + puts "#{b - a} URN created" + end +end diff --git a/spec/factories/applications.rb b/spec/factories/applications.rb index 49d86172..712bf90b 100644 --- a/spec/factories/applications.rb +++ b/spec/factories/applications.rb @@ -31,7 +31,7 @@ visa_type { VisaStep::VALID_ANSWERS_OPTIONS.reject { _1 == "Other" }.sample } date_of_entry { Time.zone.today } start_date { 1.month.from_now.to_date } - urn { Urn.generate(application_route) } + urn { Urn.next(application_route) } factory :teacher_application do application_route { "teacher" } diff --git a/spec/factories/urns.rb b/spec/factories/urns.rb new file mode 100644 index 00000000..8112eadb --- /dev/null +++ b/spec/factories/urns.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :urn do + prefix { "IRP" } + code { %w[TE ST].sample } + suffix { Array.new(10) { _1 }.sample } + end +end diff --git a/spec/features/admin_console/applications_list_spec.rb b/spec/features/admin_console/applications_list_spec.rb index 85a51dee..8cb032cd 100644 --- a/spec/features/admin_console/applications_list_spec.rb +++ b/spec/features/admin_console/applications_list_spec.rb @@ -59,6 +59,8 @@ def given_there_are_few_applications # Create 2 specific applications for search tests + create_list(:urn, 5, code: "TE") + create_list(:urn, 5, code: "ST") unique_applicant = create(:applicant, given_name: "Unique Given Name", middle_name: "Unique Middle Name", family_name: "Unique Family Name", email_address: "unique@example.com") create(:application, applicant: unique_applicant, urn: "Unique Urn 1") @@ -70,12 +72,16 @@ def given_there_are_few_applications end def given_there_is_an_application_that_breached_sla + create_list(:urn, 5, code: "TE") + create_list(:urn, 5, code: "ST") applicant = create(:applicant) application = create(:application, applicant:) application.application_progress.update(initial_checks_completed_at: 4.days.ago) end def given_there_are_applications_with_different_dates + create_list(:urn, 5, code: "TE") + create_list(:urn, 5, code: "ST") create(:application, application_progress: build(:application_progress, :initial_checks_completed, status: :initial_checks)) create(:application, application_progress: build(:application_progress, :home_office_checks_completed, status: :home_office_checks)) end diff --git a/spec/fixtures/urns.yml b/spec/fixtures/urns.yml new file mode 100644 index 00000000..4cedf718 --- /dev/null +++ b/spec/fixtures/urns.yml @@ -0,0 +1,90 @@ +te_one: + suffix: 5668 + prefix: IRP + code: TE + +te_two: + suffix: 21368 + prefix: IRP + code: TE + +te_three: + suffix: 5 + prefix: IRP + code: TE + +te_four: + suffix: 76998 + prefix: IRP + code: TE + +te_five: + suffix: 6559 + prefix: IRP + code: TE + +te_six: + suffix: 6 + prefix: IRP + code: TE + +te_seven: + suffix: 2298 + prefix: IRP + code: TE + +te_eight: + suffix: 1159 + prefix: IRP + code: TE + +te_nine: + suffix: 79298 + prefix: IRP + code: TE + +te_ten: + suffix: 19549 + prefix: IRP + code: TE + +st_one: + suffix: 5668 + prefix: IRP + code: ST + +st_two: + suffix: 29968 + prefix: IRP + code: ST + +st_three: + suffix: 5 + prefix: IRP + code: ST + +st_four: + suffix: 76998 + prefix: IRP + code: ST + +st_five: + suffix: 6559 + prefix: IRP + code: ST + +st_six: + suffix: 6 + prefix: IRP + code: ST + +st_seven: + suffix: 28 + prefix: IRP + code: ST + +st_eight: + suffix: 159 + prefix: IRP + code: ST + diff --git a/spec/models/urn_spec.rb b/spec/models/urn_spec.rb index 4df75ded..eb150367 100644 --- a/spec/models/urn_spec.rb +++ b/spec/models/urn_spec.rb @@ -1,39 +1,60 @@ -# frozen_string_literal: true - +# == Schema Information +# +# Table name: urns +# +# id :bigint not null, primary key +# code :string +# prefix :string +# suffix :integer +# created_at :datetime not null +# updated_at :datetime not null +# require "rails_helper" RSpec.describe Urn do - subject(:urn) { described_class.generate(applicant_type) } + describe "next" do + subject(:next_urn) { described_class.next(route) } - describe ".generate" do - context 'when applicant type is "teacher"' do - let(:applicant_type) { "teacher" } + context "for application route teacher" do + let(:route) { "teacher" } - it "generates a URN with the correct prefix and suffix" do - expect(urn).to match(/^IRPTE[0-9]{5}$/) - end + before { create(:urn, code: "TE") } + + it { expect(next_urn).to match(/IRPTE\d{5}/) } + end - it "generates a Urn with a suffix of only characters in the CHARSET" do - charset = %w[0 1 2 3 4 5 6 7 8 9] + context "for application route salaried_trainee" do + let(:route) { "salaried_trainee" } - expect(urn[5..9].chars).to all(be_in(charset)) - end + before { create(:urn, code: "ST") } + + it { expect(next_urn).to match(/IRPST\d{5}/) } end - context 'when applicant type is "salaried_trainee"' do - let(:applicant_type) { "salaried_trainee" } + context "when bad application route" do + let(:route) { "badroute" } - it "generates a URN with the correct prefix and suffix" do - expect(urn).to match(/^IRPST[0-9]{5}$/) - end + it { expect { next_urn }.to raise_error(ArgumentError) } end - context "when an invalid applicant type is provided" do - let(:applicant_type) { "invalid_type" } + context "when there is no more urn available to assign" do + let(:route) { "salaried_trainee" } - it "raises an ArgumentError" do - expect { urn }.to raise_error(ArgumentError, "Invalid applicant type: invalid_type") + before do + allow(described_class).to receive(:find_by!).and_raise(ActiveRecord::RecordNotFound) end + + it { expect { next_urn }.to raise_error(Urn::NoUrnAvailableError) } end end + + describe ".to_s" do + subject(:urn) { described_class.new(prefix:, code:, suffix:) } + + let(:prefix) { "AST" } + let(:code) { "FF" } + let(:suffix) { 65 } + + it { expect(urn.to_s).to eq("ASTFF00065") } + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 6ba490c8..edfe2b50 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -42,7 +42,8 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join("/spec/fixtures") + config.fixture_path = Rails.root.join("spec/fixtures") + config.global_fixtures = :urns # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false diff --git a/spec/services/generate_urns_spec.rb b/spec/services/generate_urns_spec.rb new file mode 100644 index 00000000..cbe58507 --- /dev/null +++ b/spec/services/generate_urns_spec.rb @@ -0,0 +1,23 @@ +require "rails_helper" + +RSpec.describe GenerateUrns do + describe ".generate" do + subject(:generate) { described_class.new(code:).generate } + + before do + allow(Urn).to receive(:insert_all) + stub_const "Urn::MAX_SUFFIX", 3 + generate + end + + let(:code) { "TE" } + let(:expected_data) do + [ + { prefix: "IRP", code: code, suffix: 1 }, + { prefix: "IRP", code: code, suffix: 2 }, + ] + end + + it { expect(Urn).to have_received(:insert_all).with(match_array(expected_data)) } + end +end diff --git a/spec/services/submit_form_spec.rb b/spec/services/submit_form_spec.rb index 3c110afe..022582e0 100644 --- a/spec/services/submit_form_spec.rb +++ b/spec/services/submit_form_spec.rb @@ -133,7 +133,7 @@ context "applicant email" do before do - allow(Urn).to receive(:generate).and_return(urn) + allow(Urn).to receive(:next).and_return(urn) end let(:urn) { "SOMEURN" }