From b4b3b294c0bec4363ad76da739ef0bac7699ad64 Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Wed, 27 Sep 2023 14:51:42 +0100 Subject: [PATCH 1/5] Create Urn model This will serve to render a urn and hold the current state of available urns ready to be used. --- app/models/urn.rb | 63 ++++++++++++----------- db/migrate/20230927092305_create_urns.rb | 12 +++++ db/schema.rb | 11 +++- spec/factories/urns.rb | 7 +++ spec/models/urn_spec.rb | 64 +++++++++++++++--------- 5 files changed, 103 insertions(+), 54 deletions(-) create mode 100644 db/migrate/20230927092305_create_urns.rb create mode 100644 spec/factories/urns.rb 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/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/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/models/urn_spec.rb b/spec/models/urn_spec.rb index 4df75ded..02466d4c 100644 --- a/spec/models/urn_spec.rb +++ b/spec/models/urn_spec.rb @@ -1,39 +1,57 @@ -# 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 ".generate" do - context 'when applicant type is "teacher"' do - let(:applicant_type) { "teacher" } + describe "next" do + subject(:next_urn) { described_class.next(route) } + + context "for application route teacher" do + let(:route) { "teacher" } + + before { create(:urn, code: "TE") } + + it { expect(next_urn).to match(/IRPTE\d{5}/) } + end - it "generates a URN with the correct prefix and suffix" do - expect(urn).to match(/^IRPTE[0-9]{5}$/) - end + context "for application route salaried_trainee" do + let(:route) { "salaried_trainee" } - 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] + before { create(:urn, code: "ST") } - expect(urn[5..9].chars).to all(be_in(charset)) - end + 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") - 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 From 9559c43bd7acdaf86cb3d3d4d4ed799e3e78c6f2 Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Wed, 27 Sep 2023 14:52:43 +0100 Subject: [PATCH 2/5] Add GenerateUrns service This service will create all the available urns per application route. They will be randomized, unique and the next available urn to be taken will retreived with the Urn.next method. Once all the urn are exhausted this method will raise an error and the format of the URN will need updating ie increase the size of the suffix set. Urn::MAX_SUFFIX --- app/services/generate_urns.rb | 62 +++++++++++++++++++++++++++++ spec/services/generate_urns_spec.rb | 13 ++++++ 2 files changed, 75 insertions(+) create mode 100644 app/services/generate_urns.rb create mode 100644 spec/services/generate_urns_spec.rb 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/spec/services/generate_urns_spec.rb b/spec/services/generate_urns_spec.rb new file mode 100644 index 00000000..ebde4c31 --- /dev/null +++ b/spec/services/generate_urns_spec.rb @@ -0,0 +1,13 @@ +require "rails_helper" + +RSpec.describe GenerateUrns do + subject(:service) { described_class } + + describe ".call" do + it { fail } + end + + describe ".generate" do + it { fail } + end +end From 5ed227e747062cd3645f4f706b4b8f9fc7815627 Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Wed, 27 Sep 2023 14:56:23 +0100 Subject: [PATCH 3/5] Add rake task urn:generate This task is going to be used to ensure that when start the application service the URNs are ready to be picked. --- lib/tasks/urn.rake | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 lib/tasks/urn.rake 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 From afbda394039fe56fdfda89003ba877f8eec1badb Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Wed, 27 Sep 2023 14:57:45 +0100 Subject: [PATCH 4/5] Update code to used `Urn.next` * startup.sh * submitform service * factories --- app/services/submit_form.rb | 2 +- bin/app-startup.sh | 2 ++ spec/factories/applications.rb | 2 +- spec/services/submit_form_spec.rb | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) 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/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/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" } From 2a714a22ad8ed0e398f3ebd88d162aca4fd15071 Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Mon, 2 Oct 2023 17:02:41 +0100 Subject: [PATCH 5/5] Fix specs for urn --- .../admin_console/applications_list_spec.rb | 6 ++ spec/fixtures/urns.yml | 90 +++++++++++++++++++ spec/models/urn_spec.rb | 5 +- spec/rails_helper.rb | 3 +- spec/services/generate_urns_spec.rb | 22 +++-- 5 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 spec/fixtures/urns.yml 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 02466d4c..eb150367 100644 --- a/spec/models/urn_spec.rb +++ b/spec/models/urn_spec.rb @@ -12,7 +12,6 @@ require "rails_helper" RSpec.describe Urn do - describe "next" do subject(:next_urn) { described_class.next(route) } @@ -41,6 +40,10 @@ context "when there is no more urn available to assign" do let(:route) { "salaried_trainee" } + 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 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 index ebde4c31..cbe58507 100644 --- a/spec/services/generate_urns_spec.rb +++ b/spec/services/generate_urns_spec.rb @@ -1,13 +1,23 @@ require "rails_helper" RSpec.describe GenerateUrns do - subject(:service) { described_class } + describe ".generate" do + subject(:generate) { described_class.new(code:).generate } - describe ".call" do - it { fail } - end + before do + allow(Urn).to receive(:insert_all) + stub_const "Urn::MAX_SUFFIX", 3 + generate + end - describe ".generate" do - it { fail } + 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