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