From 5f40c1f230f8525eef16a59b0c1cc2c84fa42917 Mon Sep 17 00:00:00 2001 From: fumimowdan <tecknuovo@zemis.co.uk> Date: Wed, 27 Sep 2023 14:51:42 +0100 Subject: [PATCH] Create Urn model This will serve to render a urn and hold the current state of available urns ready to be used. 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 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. Update code to used `Urn.next` * startup.sh * submitform service * factories Fix specs for urn --- app/models/urn.rb | 135 +++++------------- app/services/generate_urns.rb | 62 ++++++++ bin/app-startup.sh | 2 + config/initializers/urn.rb | 7 - db/migrate/20230927092305_create_urns.rb | 12 ++ db/schema.rb | 9 ++ lib/tasks/urn.rake | 10 ++ spec/factories/urns.rb | 7 + .../admin_console/applications_list_spec.rb | 6 + spec/fixtures/urns.yml | 90 ++++++++++++ spec/models/urn_spec.rb | 65 ++++++--- spec/rails_helper.rb | 3 +- spec/services/generate_urns_spec.rb | 23 +++ 13 files changed, 299 insertions(+), 132 deletions(-) create mode 100644 app/services/generate_urns.rb delete mode 100644 config/initializers/urn.rb create mode 100644 db/migrate/20230927092305_create_urns.rb create mode 100644 lib/tasks/urn.rake create mode 100644 spec/factories/urns.rb create mode 100644 spec/fixtures/urns.yml create mode 100644 spec/services/generate_urns_spec.rb diff --git a/app/models/urn.rb b/app/models/urn.rb index 9a2a31ad..70538fba 100644 --- a/app/models/urn.rb +++ b/app/models/urn.rb @@ -1,115 +1,46 @@ -# frozen_string_literal: true - -# Urn represents a pseudo random Uniform Resource Name (URN) generator. -# Invoking the method `next` returns a unique URN with a fixed prefix -# and a random alphanumeric suffix. +# == Schema Information # -# Urn.configure do |c| -# c.max_suffix = 11 -# c.seeds = { teacher: ENV['TEACHER_URN_SEED'] } -# c.urns = ->(route) { Application.where(application_route: route).pluck(:urn) } -# end +# Table name: urns # -# Example: +# id :bigint not null, primary key +# code :string +# prefix :string +# suffix :integer +# created_at :datetime not null +# updated_at :datetime not null # # Urn.next('teacher') # => "IRPTE12345" # Urn.next('teacher') # => "IRPTE12345" # Urn.next('salaried_trainee') # => "IRPST12345" # -class Urn - class NoUrnAvailableError < StandardError; end - - class Config - def initialize - @default_prefix = "IRP" - @default_max_suffix = 99_999 - @default_codes = { - teacher: "TE", - salaried_trainee: "ST", - }.with_indifferent_access - @default_urns = ->(_) { [] } - end - - attr_writer :prefix, :codes, :max_suffix, :seeds, :urns, :padding_size - - def prefix - @prefix || @default_prefix - end - - def codes - (@codes || @default_codes).with_indifferent_access - end - def max_suffix - @max_suffix || @default_max_suffix - end - - def padding_size - @padding_size || max_suffix.to_s.size - end - - def seeds - (@seeds || {}).with_indifferent_access - end +class Urn < ApplicationRecord + class NoUrnAvailableError < StandardError; end - def urns - @urns || @default_urns - end + PREFIX = "IRP".freeze + MAX_SUFFIX = 99_999 + PADDING_SIZE = MAX_SUFFIX.to_s.size + VALID_CODES = { + "teacher" => "TE", + "salaried_trainee" => "ST", + }.freeze + + 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 - class << self - def configure - yield(config) - end - - def config - return @config if @config.present? - - @config = Config.new - end - - def next(route) - routes[route].next - rescue KeyError - raise(ArgumentError, "Invalid route: #{route}, must be one of #{config.codes.keys}") - end - - private - - def routes - @routes ||= Concurrent::Hash.new do |hash, route| - hash[route] = urn_enumerator( - config.codes.fetch(route), - config.seeds.fetch(route, Random.new_seed), - config.urns.call(route), - ) - end - end - - def urns(code, seed) - Array - .new(config.max_suffix) { formatter(code, _1) } - .drop(1) - .shuffle!(random: Random.new(seed)) - end - - def formatter(code, suffix) - [config.prefix, code, sprintf("%0#{config.padding_size}d", suffix)].join - end - - def available_urns(code, seed, used_urns) - urns(code, seed) - used_urns - end - - def urn_enumerator(code, seed, used_urns) - list = Concurrent::Array.new(available_urns(code, seed, used_urns)) - error_msg = "you have exhausted urn for code #{code} you need to increase the size of the suffix" - - Enumerator.new do |yielder| - list.each { yielder << _1 } - - raise(NoUrnAvailableError, error_msg) - end - end + def to_s + [prefix, code, sprintf("%0#{PADDING_SIZE}d", suffix)].join end 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/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/config/initializers/urn.rb b/config/initializers/urn.rb deleted file mode 100644 index f9658c35..00000000 --- a/config/initializers/urn.rb +++ /dev/null @@ -1,7 +0,0 @@ -require Rails.root.join("app/models/urn") - -Urn.configure do |c| - c.prefix = "IRP" - c.max_suffix = 99_999 - c.urns = ->(route) { Application.where(application_route: route).pluck(:urn) } -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 f538b96b..34a7b89d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -195,6 +195,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/urns.rb b/spec/factories/urns.rb new file mode 100644 index 00000000..c88e1b7c --- /dev/null +++ b/spec/factories/urns.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :urn do + prefix { "IRP" } + code { %w[TE ST].sample } + sequence(:suffix) { _1 } + end +end diff --git a/spec/features/admin_console/applications_list_spec.rb b/spec/features/admin_console/applications_list_spec.rb index 85a51dee..4001fdd0 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, 25, code: "TE") + create_list(:urn, 25, 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 366389b0..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.next(applicant_type) } + describe "next" do + subject(:next_urn) { described_class.next(route) } - describe ".next" 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 route: invalid_type, must be one of ["teacher", "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 + + 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