diff --git a/app/controllers/badge_batches_controller.rb b/app/controllers/badge_batches_controller.rb
new file mode 100644
index 00000000..c390eae6
--- /dev/null
+++ b/app/controllers/badge_batches_controller.rb
@@ -0,0 +1,83 @@
+# typed: false
+# frozen_string_literal: true
+
+class BadgeBatchesController < ApplicationController
+ before_action :require_admin
+ before_action :set_badge_batch, only: %i[show edit update]
+
+ def index
+ @badge_batches = BadgeBatch.includes(:badges).order(created_at: :desc)
+ end
+
+ def show
+ @badges = @badge_batch.badges.order(:id)
+ end
+
+ def new
+ @badge_batch = BadgeBatch.new
+ end
+
+ def create
+ count = badge_batch_params[:count].to_i
+ note = badge_batch_params[:note]
+
+ badge_batch = BadgeBatch.create!(note: note, count: count)
+
+ badge_ids = Badge.generate_random_ids(count)
+
+ timestamp = Time.current
+ badge_records = badge_ids.map do |id|
+ {id: id, badge_batch_id: badge_batch.id, created_at: timestamp,
+ updated_at: timestamp}
+ end
+ Badge.insert_all(badge_records)
+
+ flash[:success] = t("badges.messages.batch_created", count: count)
+ redirect_to badge_batch_path(badge_batch)
+ end
+
+ def edit
+ end
+
+ def update
+ if @badge_batch.update(note_param)
+ flash[:success] = t("badges.messages.batch_updated")
+ redirect_to badge_batch_path(@badge_batch)
+ else
+ render :edit
+ end
+ end
+
+ def search
+ query = params[:query]&.strip&.upcase
+
+ if query.blank?
+ redirect_to badge_batches_path
+ return
+ end
+
+ badge = Badge.find_by(id: query)
+
+ if badge
+ flash[:success] = t("badges.messages.search_success")
+ redirect_to badge_path(badge)
+ else
+ flash[:alert] = t("badges.messages.search_not_found", query: query)
+ redirect_to badge_batches_path
+ end
+ end
+
+ private
+
+ def set_badge_batch
+ @badge_batch = BadgeBatch.find(params[:id])
+ end
+
+ def badge_batch_params
+ params.require(:badge_batch).permit(:count, :note)
+ end
+
+ def note_param
+ params.require(:badge_batch).permit(:note)
+ end
+end
diff --git a/app/controllers/badges_controller.rb b/app/controllers/badges_controller.rb
new file mode 100644
index 00000000..51c2cedb
--- /dev/null
+++ b/app/controllers/badges_controller.rb
@@ -0,0 +1,34 @@
+# typed: false
+# frozen_string_literal: true
+
+class BadgesController < ApplicationController
+ before_action :require_admin
+ before_action :set_badge, only: %i[show edit update]
+
+ def show
+ @badge_batch = @badge.badge_batch
+ @units = Unit.where(id: @badge.id)
+ end
+
+ def edit
+ end
+
+ def update
+ if @badge.update(note_params)
+ flash[:success] = t("badges.messages.badge_updated")
+ redirect_to badge_batch_path(@badge.badge_batch)
+ else
+ render :edit
+ end
+ end
+
+ private
+
+ def set_badge
+ @badge = Badge.find(params[:id])
+ end
+
+ def note_params
+ params.require(:badge).permit(:note)
+ end
+end
diff --git a/app/javascript/dirty_forms.js b/app/javascript/dirty_forms.js
index b1100bb7..c193f4e4 100644
--- a/app/javascript/dirty_forms.js
+++ b/app/javascript/dirty_forms.js
@@ -58,7 +58,8 @@ class DirtyForms {
formAction.includes("/login") ||
formAction.includes("/register") ||
formAction.includes("/safety_standards") ||
- formAction.includes("/search")
+ formAction.includes("/search") ||
+ formAction.includes("/badge_batches")
) {
return;
}
diff --git a/app/models/badge.rb b/app/models/badge.rb
new file mode 100644
index 00000000..df8cf657
--- /dev/null
+++ b/app/models/badge.rb
@@ -0,0 +1,10 @@
+# typed: true
+# frozen_string_literal: true
+
+class Badge < ApplicationRecord
+ extend T::Sig
+
+ include CustomIdGenerator
+
+ belongs_to :badge_batch
+end
diff --git a/app/models/badge_batch.rb b/app/models/badge_batch.rb
new file mode 100644
index 00000000..4663a229
--- /dev/null
+++ b/app/models/badge_batch.rb
@@ -0,0 +1,8 @@
+# typed: true
+# frozen_string_literal: true
+
+class BadgeBatch < ApplicationRecord
+ extend T::Sig
+
+ has_many :badges, dependent: :destroy
+end
diff --git a/app/models/concerns/custom_id_generator.rb b/app/models/concerns/custom_id_generator.rb
index 936b1f56..c4ff0635 100644
--- a/app/models/concerns/custom_id_generator.rb
+++ b/app/models/concerns/custom_id_generator.rb
@@ -19,10 +19,8 @@ module CustomIdGenerator
class_methods do
extend T::Sig
- sig do
- params(scope_conditions: T::Hash[T.untyped, T.untyped]).returns(String)
- end
- def generate_random_id(scope_conditions = {})
+ sig { returns(String) }
+ def generate_random_id
loop do
raw_id = SecureRandom.alphanumeric(32).upcase
filtered_chars = raw_id.chars.reject do |char|
@@ -30,7 +28,41 @@ def generate_random_id(scope_conditions = {})
end
id = filtered_chars.first(ID_LENGTH).join
next if id.length < ID_LENGTH
- break id unless exists?({id: id}.merge(scope_conditions))
+ break id unless exists?(id: id)
+ end
+ end
+
+ sig { params(count: Integer).returns(T::Array[String]) }
+ def generate_random_ids(count)
+ return [] if count <= 0
+
+ needed = count
+ generated_ids = []
+
+ while needed > 0
+ # Generate a batch of candidate IDs
+ candidates = needed.times.map { generate_single_id_string }
+
+ # Check which ones already exist (single DB query)
+ existing = where(id: candidates).pluck(:id)
+ new_ids = candidates - existing
+
+ generated_ids.concat(new_ids)
+ needed -= new_ids.length
+ end
+
+ generated_ids.first(count)
+ end
+
+ sig { returns(String) }
+ def generate_single_id_string
+ loop do
+ raw_id = SecureRandom.alphanumeric(32).upcase
+ filtered_chars = raw_id.chars.reject do |char|
+ AMBIGUOUS_CHARS.include?(char)
+ end
+ id = filtered_chars.first(ID_LENGTH).join
+ return id if id.length == ID_LENGTH
end
end
end
@@ -39,7 +71,6 @@ def generate_random_id(scope_conditions = {})
sig { void }
def generate_custom_id
- scope_conditions = respond_to?(:uniqueness_scope) ? uniqueness_scope : {}
- self.id = self.class.generate_random_id(scope_conditions)
+ self.id = self.class.generate_random_id
end
end
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index 997d70e2..9e41fb88 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -7,6 +7,7 @@
<%= link_to t("navigation.users"), users_path %>
<%= link_to t("inspector_companies.titles.index"), inspector_companies_path %>
<%= link_to t("navigation.pages"), pages_path %>
+ <%= link_to t("navigation.badges"), badge_batches_path %>
<%= link_to t("navigation.jobs"), "/mission_control" %>
<%= link_to t("navigation.releases"), admin_releases_path %>
<%= link_to t("navigation.files"), admin_files_path %>
diff --git a/app/views/badge_batches/edit.html.erb b/app/views/badge_batches/edit.html.erb
new file mode 100644
index 00000000..0e509291
--- /dev/null
+++ b/app/views/badge_batches/edit.html.erb
@@ -0,0 +1,8 @@
+<%= render 'chobble_forms/form_context',
+ model: @badge_batch,
+ i18n_base: 'forms.badge_batch_edit',
+ url: badge_batch_path(@badge_batch) do |form| %>
+ <%= render 'chobble_forms/fieldset', legend_key: 'batch_details' do %>
+ <%= render 'chobble_forms/text_area', field: :note %>
+ <% end %>
+<% end %>
diff --git a/app/views/badge_batches/index.html.erb b/app/views/badge_batches/index.html.erb
new file mode 100644
index 00000000..92fa73aa
--- /dev/null
+++ b/app/views/badge_batches/index.html.erb
@@ -0,0 +1,63 @@
+<%= render 'shared/page_header', title: t("badges.titles.index") %>
+
+
+
+<%= form_with url: search_badge_batches_path, method: :get, data: { turbo: false }, id: "search-box" do |form| %>
+ <%= form.text_field :query,
+ placeholder: t('badges.search.placeholder'),
+ value: params[:query],
+ onkeyup: "if(event.key === 'Enter') this.form.submit();" %>
+<% end %>
+
+<% if @badge_batches.any? %>
+
+
+
+
+ <% @badge_batches.each do |batch| %>
+ -
+ <%= link_to badge_batch_path(batch), class: "table-list-link" do %>
+ <%= render 'shared/table_column',
+ field: :id,
+ i18n_key: "badges.fields.id",
+ item: batch %>
+ <%= render 'shared/table_column',
+ field: :count,
+ i18n_key: "badges.fields.count",
+ item: batch %>
+ <%= render 'shared/table_column',
+ field: :created_at,
+ i18n_key: "badges.fields.created_at" do %>
+ <%= l(batch.created_at, format: :long) %>
+ <% end %>
+ <%= render 'shared/table_column',
+ field: :note,
+ i18n_key: "badges.fields.note",
+ item: batch %>
+ <% end %>
+
+ <% end %>
+
+
+<% else %>
+ <%= t("badges.messages.no_batches") %>
+<% end %>
diff --git a/app/views/badge_batches/new.html.erb b/app/views/badge_batches/new.html.erb
new file mode 100644
index 00000000..33f4e15f
--- /dev/null
+++ b/app/views/badge_batches/new.html.erb
@@ -0,0 +1,9 @@
+<%= render 'chobble_forms/form_context',
+ model: @badge_batch,
+ i18n_base: 'forms.badge_batch',
+ url: badge_batches_path do |form| %>
+ <%= render 'chobble_forms/fieldset', legend_key: 'batch_details' do %>
+ <%= render 'chobble_forms/number', field: :count, required: true %>
+ <%= render 'chobble_forms/text_area', field: :note %>
+ <% end %>
+<% end %>
diff --git a/app/views/badge_batches/show.html.erb b/app/views/badge_batches/show.html.erb
new file mode 100644
index 00000000..4dec4adf
--- /dev/null
+++ b/app/views/badge_batches/show.html.erb
@@ -0,0 +1,60 @@
+<%= render 'shared/page_header',
+ title: t("badges.titles.show", id: @badge_batch.id) %>
+
+
+
+
+
+ <%= t("badges.fields.created_at") %>:
+ <%= l(@badge_batch.created_at, format: :long) %>
+
+
+
+ <%= t("badges.fields.count") %>:
+ <%= @badge_batch.count %>
+
+
+
+ <%= t("badges.fields.note") %>:
+ <%= @badge_batch.note %>
+
+
+
+<% if @badges.any? %>
+
+
+
+
+ <% @badges.each do |badge| %>
+ -
+ <%= link_to edit_badge_path(badge), class: "table-list-link" do %>
+ <%= render 'shared/table_column',
+ field: :badge_id,
+ i18n_key: "badges.fields.badge_id" do %>
+ <%= badge.id %>
+ <% end %>
+ <%= render 'shared/table_column',
+ field: :note,
+ i18n_key: "badges.fields.note",
+ item: badge %>
+ <% end %>
+
+ <% end %>
+
+
+<% else %>
+ <%= t("badges.messages.no_badges") %>
+<% end %>
diff --git a/app/views/badges/edit.html.erb b/app/views/badges/edit.html.erb
new file mode 100644
index 00000000..fce34dcd
--- /dev/null
+++ b/app/views/badges/edit.html.erb
@@ -0,0 +1,13 @@
+
+ <%= t("badges.titles.edit", id: @badge.id) %>
+
+
+<%= render 'chobble_forms/form_context',
+ model: @badge,
+ i18n_base: 'forms.badge',
+ url: badge_path(@badge),
+ method: :patch do |form| %>
+ <%= render 'chobble_forms/fieldset', legend_key: 'badge_details' do %>
+ <%= render 'chobble_forms/text_area', field: :note %>
+ <% end %>
+<% end %>
diff --git a/app/views/badges/show.html.erb b/app/views/badges/show.html.erb
new file mode 100644
index 00000000..fc0fba80
--- /dev/null
+++ b/app/views/badges/show.html.erb
@@ -0,0 +1,37 @@
+<%= render 'shared/page_header', title: t('badges.titles.show_badge', id: @badge.id) %>
+
+
+ -
+ <%= t("badges.fields.badge_id") %>:
+ <%= @badge.id %>
+
+
+ -
+ <%= t("badges.fields.batch") %>:
+ <%= link_to t("badges.titles.show", id: @badge_batch.id), badge_batch_path(@badge_batch) %>
+
+
+ -
+ <%= t("badges.fields.batch_created_at") %>:
+ <%= l(@badge_batch.created_at, format: :long) %>
+
+
+ <% if @badge_batch.note.present? %>
+ -
+ <%= t("badges.fields.batch_note") %>:
+ <%= @badge_batch.note %>
+
+ <% end %>
+
+ <% if @badge.note.present? %>
+ -
+ <%= t("badges.fields.badge_note") %>:
+ <%= @badge.note %>
+
+ <% end %>
+
+
+<% if @units.any? %>
+ <%= t("badges.titles.associated_units") %>
+ <%= render 'units/unit_table', unit: @units %>
+<% end %>
diff --git a/config/locales/badges.en.yml b/config/locales/badges.en.yml
new file mode 100644
index 00000000..db6900be
--- /dev/null
+++ b/config/locales/badges.en.yml
@@ -0,0 +1,58 @@
+en:
+ badges:
+ titles:
+ associated_units: "Units with this Badge"
+ edit: "Edit Badge %{id}"
+ edit_batch: "Edit Badge Batch #%{id}"
+ index: "Badge Batches"
+ new: "Create New Badge Batch"
+ show: "Badge Batch #%{id}"
+ show_badge: "Badge %{id}"
+ fields:
+ badge_id: "Badge ID"
+ badge_note: "Badge Note"
+ batch: "Batch"
+ batch_created_at: "Batch Created"
+ batch_note: "Batch Note"
+ count: "Number of Badges"
+ created_at: "Date Created"
+ id: "ID"
+ note: "Notes"
+ buttons:
+ back: "Back to Batches"
+ edit: "Edit Badge"
+ edit_batch: "Edit Batch"
+ new_batch: "Create New Batch"
+ search:
+ placeholder: "Search for badge ID..."
+ messages:
+ badge_updated: "Badge note updated successfully"
+ batch_created: "Created batch with %{count} badges"
+ batch_updated: "Badge batch updated successfully"
+ no_badges: "No badges in this batch"
+ no_batches: "No badge batches created yet"
+ search_not_found: "Badge '%{query}' not found"
+ search_success: "Badge found"
+ forms:
+ badge:
+ header: "Edit Badge Note"
+ sections:
+ badge_details: "Badge Details"
+ fields:
+ note: "Note"
+ submit: "Update Badge"
+ badge_batch:
+ header: "Create Badge Batch"
+ sections:
+ batch_details: "Batch Details"
+ fields:
+ count: "Number of Badges to Generate"
+ note: "Note"
+ submit: "Create Batch"
+ badge_batch_edit:
+ header: "Edit Badge Batch"
+ sections:
+ batch_details: "Batch Details"
+ fields:
+ note: "Note"
+ submit: "Update Batch"
diff --git a/config/locales/shared.en.yml b/config/locales/shared.en.yml
index 73397e47..cbf459f1 100644
--- a/config/locales/shared.en.yml
+++ b/config/locales/shared.en.yml
@@ -18,6 +18,7 @@ en:
jobs: Jobs
admin: Admin
backups: Backups
+ badges: Badges
releases: Releases
files: Files
diff --git a/config/routes.rb b/config/routes.rb
index 89da6433..839734c7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,5 +1,4 @@
-# frozen_string_literal: true
-
+# typed: false
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
@@ -115,10 +114,7 @@
# rails_disk_service GET /rails/active_storage/disk/:encoded_key/*filename(.:format) active_storage/disk#show
# update_rails_disk_service PUT /rails/active_storage/disk/:encoded_token(.:format) active_storage/disk#update
# rails_direct_uploads POST /rails/active_storage/direct_uploads(.:format) active_storage/direct_uploads#create
-
-# typed: false
-
-# typed: strict
+# frozen_string_literal: true
Rails.application.routes.draw do
# Mount Mission Control Jobs (authentication handled by initializer)
@@ -209,6 +205,14 @@
get "admin/releases", to: "admin#releases", as: :admin_releases
get "admin/files", to: "admin#files", as: :admin_files
+ # Badges (admin-only)
+ resources :badge_batches, only: %i[index show new create edit update] do
+ collection do
+ get :search, path: "search", as: :search
+ end
+ end
+ resources :badges, only: %i[show edit update]
+
# Backups
resources :backups, only: [:index] do
collection do
diff --git a/db/migrate/20251012131010_create_badge_batches.rb b/db/migrate/20251012131010_create_badge_batches.rb
new file mode 100644
index 00000000..8aba55a7
--- /dev/null
+++ b/db/migrate/20251012131010_create_badge_batches.rb
@@ -0,0 +1,12 @@
+# typed: true
+# frozen_string_literal: true
+
+class CreateBadgeBatches < ActiveRecord::Migration[8.0]
+ def change
+ create_table :badge_batches do |t|
+ t.text :note
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20251012131023_create_badges.rb b/db/migrate/20251012131023_create_badges.rb
new file mode 100644
index 00000000..319d4ca7
--- /dev/null
+++ b/db/migrate/20251012131023_create_badges.rb
@@ -0,0 +1,16 @@
+# typed: true
+# frozen_string_literal: true
+
+class CreateBadges < ActiveRecord::Migration[8.0]
+ def change
+ create_table :badges, id: false do |t|
+ t.string :id, limit: 8, null: false, primary_key: true
+ t.references :badge_batch, null: false, foreign_key: true
+ t.text :note
+
+ t.timestamps
+ end
+
+ add_index :badges, :id, unique: true
+ end
+end
diff --git a/db/migrate/20251012134703_add_count_to_badge_batches.rb b/db/migrate/20251012134703_add_count_to_badge_batches.rb
new file mode 100644
index 00000000..7d1e59bf
--- /dev/null
+++ b/db/migrate/20251012134703_add_count_to_badge_batches.rb
@@ -0,0 +1,5 @@
+class AddCountToBadgeBatches < ActiveRecord::Migration[8.0]
+ def change
+ add_column :badge_batches, :count, :integer
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 565e9c80..1ccdd3b9 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_08_11_162534) do
+ActiveRecord::Schema[8.0].define(version: 2025_10_12_134703) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -60,6 +60,22 @@
t.index ["inspection_id"], name: "anchorage_assessments_new_pkey", unique: true
end
+ create_table "badge_batches", force: :cascade do |t|
+ t.text "note"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.integer "count"
+ end
+
+ create_table "badges", id: {type: :string, limit: 8}, force: :cascade do |t|
+ t.integer "badge_batch_id", null: false
+ t.text "note"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["badge_batch_id"], name: "index_badges_on_badge_batch_id"
+ t.index ["id"], name: "index_badges_on_id", unique: true
+ end
+
create_table "credentials", force: :cascade do |t|
t.string "user_id", limit: 12, null: false
t.string "external_id", null: false
diff --git a/spec/factories/badge_batches.rb b/spec/factories/badge_batches.rb
new file mode 100644
index 00000000..e95d98d1
--- /dev/null
+++ b/spec/factories/badge_batches.rb
@@ -0,0 +1,17 @@
+# typed: false
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :badge_batch do
+ note { "Test batch" }
+ count { nil }
+
+ trait :with_badges do
+ count { 5 }
+
+ after(:create) do |batch|
+ create_list(:badge, 5, badge_batch: batch)
+ end
+ end
+ end
+end
diff --git a/spec/factories/badges.rb b/spec/factories/badges.rb
new file mode 100644
index 00000000..7e45b5fc
--- /dev/null
+++ b/spec/factories/badges.rb
@@ -0,0 +1,9 @@
+# typed: false
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :badge do
+ association :badge_batch
+ note { nil }
+ end
+end
diff --git a/spec/features/badges/badge_management_spec.rb b/spec/features/badges/badge_management_spec.rb
new file mode 100644
index 00000000..2e97241d
--- /dev/null
+++ b/spec/features/badges/badge_management_spec.rb
@@ -0,0 +1,207 @@
+# typed: false
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.feature "Badge Management", type: :feature do
+ let(:admin_user) { create(:user, :admin, :without_company) }
+ let(:regular_user) { create(:user, :active_user) }
+
+ background do
+ sign_in(admin_user)
+ end
+
+ scenario "admin can access badges from admin dashboard" do
+ visit admin_path
+
+ expect(page).to have_link(I18n.t("navigation.badges"))
+ click_link I18n.t("navigation.badges")
+
+ expect(current_path).to eq(badge_batches_path)
+ expect(page).to have_content(I18n.t("badges.titles.index"))
+ end
+
+ scenario "admin can create a new badge batch" do
+ visit badge_batches_path
+
+ click_link I18n.t("badges.buttons.new_batch")
+ expect(page).to have_content(I18n.t("forms.badge_batch.header"))
+
+ fill_in I18n.t("forms.badge_batch.fields.count"), with: 10
+ fill_in I18n.t("forms.badge_batch.fields.note"), with: "Test batch"
+
+ click_button I18n.t("forms.badge_batch.submit")
+
+ batch_created_msg = I18n.t("badges.messages.batch_created", count: 10)
+ expect(page).to have_content(batch_created_msg)
+ expect(page).to have_content("Test batch")
+ end
+
+ scenario "admin can view badge batch details" do
+ batch = create(:badge_batch, :with_badges, note: "Sample batch")
+
+ visit badge_batches_path
+ within("li", text: batch.id.to_s) do
+ click_link
+ end
+
+ expect(page).to have_content(I18n.t("badges.titles.show", id: batch.id))
+ expect(page).to have_content("Sample batch")
+ expect(page).to have_content(batch.count.to_s)
+ end
+
+ scenario "admin can view individual badges in a batch" do
+ batch = create(:badge_batch)
+ badge1 = create(:badge, badge_batch: batch)
+ badge2 = create(:badge, badge_batch: batch)
+
+ visit badge_batch_path(batch)
+
+ expect(page).to have_content(badge1.id)
+ expect(page).to have_content(badge2.id)
+ end
+
+ scenario "admin can edit badge note" do
+ batch = create(:badge_batch)
+ badge = create(:badge, badge_batch: batch, note: "Original note")
+
+ visit badge_batch_path(batch)
+ within("li", text: badge.id) do
+ click_link
+ end
+
+ expect(page).to have_content(I18n.t("badges.titles.edit", id: badge.id))
+
+ fill_in I18n.t("forms.badge.fields.note"), with: "Updated note"
+ click_button I18n.t("forms.badge.submit")
+
+ expect(page).to have_content(I18n.t("badges.messages.badge_updated"))
+ expect(page).to have_content("Updated note")
+ end
+
+ scenario "badge batch shows correct badge count" do
+ batch = create(:badge_batch, count: 3)
+ create_list(:badge, 3, badge_batch: batch)
+
+ visit badge_batches_path
+
+ within("li", text: batch.id.to_s) do
+ expect(page).to have_content("3")
+ end
+ end
+
+ scenario "admin can edit badge batch note" do
+ batch = create(:badge_batch, note: "Original batch note")
+
+ visit badge_batch_path(batch)
+ click_link I18n.t("badges.buttons.edit_batch")
+
+ expect(page).to have_content(I18n.t("forms.badge_batch_edit.header"))
+
+ fill_in I18n.t("forms.badge_batch_edit.fields.note"), with: "Updated note"
+ click_button I18n.t("forms.badge_batch_edit.submit")
+
+ expect(page).to have_content(I18n.t("badges.messages.batch_updated"))
+ expect(page).to have_content("Updated note")
+ end
+
+ scenario "regular user cannot access badges" do
+ logout
+ sign_in(regular_user)
+
+ visit badge_batches_path
+
+ admin_required_msg = I18n.t("forms.session_new.status.admin_required")
+ expect(page).to have_content(admin_required_msg)
+ expect(current_path).to eq(root_path)
+ end
+
+ scenario "clicking batch row navigates to batch details" do
+ batch = create(:badge_batch, :with_badges, note: "Row click test")
+
+ visit badge_batches_path
+
+ within("li", text: batch.id.to_s) do
+ click_link
+ end
+
+ expect(current_path).to eq(badge_batch_path(batch))
+ expect(page).to have_content("Row click test")
+ end
+
+ scenario "clicking badge row navigates to badge edit" do
+ batch = create(:badge_batch)
+ badge = create(:badge, badge_batch: batch, note: "Badge row test")
+
+ visit badge_batch_path(batch)
+
+ within("li", text: badge.id) do
+ click_link
+ end
+
+ expect(current_path).to eq(edit_badge_path(badge))
+ expect(page).to have_content(I18n.t("badges.titles.edit", id: badge.id))
+ end
+
+ scenario "back button navigates from batch to index" do
+ batch = create(:badge_batch)
+
+ visit badge_batch_path(batch)
+ click_link I18n.t("badges.buttons.back")
+
+ expect(current_path).to eq(badge_batches_path)
+ end
+
+ scenario "displaying batch count in index" do
+ batch = create(:badge_batch, count: 25)
+
+ visit badge_batches_path
+
+ within("li", text: batch.id.to_s) do
+ expect(page).to have_content("25")
+ end
+ end
+
+ scenario "viewing all badges in a batch" do
+ batch = create(:badge_batch, count: 3)
+ badges = create_list(:badge, 3, badge_batch: batch)
+
+ visit badge_batch_path(batch)
+
+ badges.each do |badge|
+ expect(page).to have_content(badge.id)
+ end
+ end
+
+ scenario "searching for existing badge shows badge details" do
+ batch = create(:badge_batch)
+ badge = create(:badge, badge_batch: batch)
+
+ visit search_badge_batches_path(query: badge.id)
+
+ expect(page).to have_content(I18n.t("badges.messages.search_success"))
+ badge_title = I18n.t("badges.titles.show_badge", id: badge.id)
+ expect(page).to have_content(badge_title)
+ expect(current_path).to eq(badge_path(badge))
+ end
+
+ scenario "searching for nonexistent badge shows error" do
+ visit search_badge_batches_path(query: "NOTFOUND")
+
+ error_msg = I18n.t("badges.messages.search_not_found", query: "NOTFOUND")
+ expect(page).to have_content(error_msg)
+ expect(current_path).to eq(badge_batches_path)
+ end
+
+ scenario "badge show page displays batch information" do
+ batch = create(:badge_batch, note: "Test batch note")
+ badge = create(:badge, badge_batch: batch, note: "Test badge note")
+
+ visit badge_path(badge)
+
+ expect(page).to have_content(badge.id)
+ expect(page).to have_link(I18n.t("badges.titles.show", id: batch.id))
+ expect(page).to have_content("Test batch note")
+ expect(page).to have_content("Test badge note")
+ end
+end
diff --git a/spec/models/badge_batch_spec.rb b/spec/models/badge_batch_spec.rb
new file mode 100644
index 00000000..cbd3fd9f
--- /dev/null
+++ b/spec/models/badge_batch_spec.rb
@@ -0,0 +1,45 @@
+# typed: false
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe BadgeBatch, type: :model do
+ describe "associations" do
+ it "has many badges" do
+ batch = create(:badge_batch)
+ expect(batch).to respond_to(:badges)
+ end
+
+ it "destroys associated badges when batch is destroyed" do
+ batch = create(:badge_batch)
+ create(:badge, badge_batch: batch)
+ create(:badge, badge_batch: batch)
+
+ expect { batch.destroy }.to change(Badge, :count).by(-2)
+ end
+ end
+
+ describe "count field" do
+ it "stores the count of badges in the batch" do
+ batch = create(:badge_batch, count: 5)
+ expect(batch.count).to eq(5)
+ end
+
+ it "allows nil count" do
+ batch = create(:badge_batch, count: nil)
+ expect(batch).to be_valid
+ end
+ end
+
+ describe "note field" do
+ it "allows nil note" do
+ batch = create(:badge_batch, note: nil)
+ expect(batch).to be_valid
+ end
+
+ it "allows custom note" do
+ batch = create(:badge_batch, note: "Test batch note")
+ expect(batch.note).to eq("Test batch note")
+ end
+ end
+end
diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb
new file mode 100644
index 00000000..a0e8db44
--- /dev/null
+++ b/spec/models/badge_spec.rb
@@ -0,0 +1,55 @@
+# typed: false
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Badge, type: :model do
+ describe "associations" do
+ it "belongs to badge_batch" do
+ badge = create(:badge)
+ expect(badge.badge_batch).to be_a(BadgeBatch)
+ end
+ end
+
+ describe "ID generation" do
+ it "generates a custom string ID on creation" do
+ batch = create(:badge_batch)
+ badge = Badge.create!(badge_batch: batch)
+
+ expect(badge.id).to be_present
+ expect(badge.id).to be_a(String)
+ expect(badge.id.length).to eq(8)
+ end
+
+ it "generates unique IDs" do
+ batch = create(:badge_batch)
+ badge1 = Badge.create!(badge_batch: batch)
+ badge2 = Badge.create!(badge_batch: batch)
+
+ expect(badge1.id).not_to eq(badge2.id)
+ end
+
+ it "excludes ambiguous characters from IDs" do
+ batch = create(:badge_batch)
+ 100.times do
+ badge = Badge.create!(badge_batch: batch)
+ ambiguous_chars = %w[0 O 1 I L]
+ ambiguous_chars.each do |char|
+ expect(badge.id).not_to include(char)
+ end
+ end
+ end
+ end
+
+ describe "note field" do
+ it "allows nil note" do
+ badge = create(:badge, note: nil)
+ expect(badge).to be_valid
+ end
+
+ it "allows custom note" do
+ badge = create(:badge, note: "Test note")
+ expect(badge.note).to eq("Test note")
+ end
+ end
+end
diff --git a/spec/models/concerns/custom_id_generator_spec.rb b/spec/models/concerns/custom_id_generator_spec.rb
index 7410dd75..9b26cbc7 100644
--- a/spec/models/concerns/custom_id_generator_spec.rb
+++ b/spec/models/concerns/custom_id_generator_spec.rb
@@ -48,15 +48,55 @@
expect(test_class).to have_received(:exists?).twice
expect(id).to match(/\A[A-Z0-9]{8}\z/)
end
+ end
+
+ describe ".generate_random_ids" do
+ it "generates the requested number of IDs" do
+ ids = test_class.generate_random_ids(10)
+ expect(ids.length).to eq(10)
+ end
+
+ it "generates unique IDs within the batch" do
+ ids = test_class.generate_random_ids(50)
+ expect(ids.uniq.length).to eq(50)
+ end
+
+ it "generates IDs of correct length" do
+ ids = test_class.generate_random_ids(10)
+
+ ids.each do |id|
+ expect(id.length).to eq(8)
+ end
+ end
+
+ it "excludes ambiguous characters from all IDs" do
+ ids = test_class.generate_random_ids(100)
+ ambiguous_chars = %w[0 O 1 I L]
+
+ ids.each do |id|
+ ambiguous_chars.each do |char|
+ expect(id).not_to include(char)
+ end
+ end
+ end
+
+ it "returns empty array for zero count" do
+ ids = test_class.generate_random_ids(0)
+ expect(ids).to eq([])
+ end
- it "accepts scope conditions for uniqueness checking" do
- scope_conditions = {user_id: 1}
- allow(test_class).to receive(:exists?).and_return(false)
+ it "returns empty array for negative count" do
+ ids = test_class.generate_random_ids(-5)
+ expect(ids).to eq([])
+ end
+
+ it "checks database in batches to avoid existing IDs" do
+ allow(test_class).to receive(:where).and_call_original
- test_class.generate_random_id(scope_conditions)
+ test_class.generate_random_ids(10)
- expect(test_class).to have_received(:exists?)
- .with(hash_including(scope_conditions))
+ # Should check the database for existing IDs
+ expect(test_class).to have_received(:where)
end
end
@@ -87,26 +127,13 @@
expect(instance.id.blank?).to be_falsy
end
- it "calls uniqueness_scope if model responds to it" do
- # Add uniqueness_scope method to test class
- test_class.define_method(:uniqueness_scope) { {user_id: 1} }
-
- allow(test_class).to receive(:generate_random_id)
- .and_return("TESTID123456")
-
- instance.send(:generate_custom_id)
-
- expect(test_class).to have_received(:generate_random_id)
- .with({user_id: 1})
- end
-
- it "calls generate_random_id with empty scope without uniqueness_scope" do
+ it "calls generate_random_id when creating" do
allow(test_class).to receive(:generate_random_id)
- .and_return("TESTID123456")
+ .and_return("TESTID12")
instance.send(:generate_custom_id)
- expect(test_class).to have_received(:generate_random_id).with({})
+ expect(test_class).to have_received(:generate_random_id).with(no_args)
end
end
end
diff --git a/spec/requests/badges/badge_batches_spec.rb b/spec/requests/badges/badge_batches_spec.rb
new file mode 100644
index 00000000..6f759b0e
--- /dev/null
+++ b/spec/requests/badges/badge_batches_spec.rb
@@ -0,0 +1,78 @@
+# typed: false
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "BadgeBatches", type: :request do
+ let(:admin_user) { create(:user, :admin, :without_company) }
+ let(:regular_user) { create(:user, :active_user) }
+
+ describe "Authentication" do
+ it "redirects to login when not logged in" do
+ get badge_batches_path
+ expect(response).to redirect_to(login_path)
+ end
+ end
+
+ describe "Authorization" do
+ before { login_as(regular_user) }
+
+ it "denies access to regular users" do
+ get badge_batches_path
+ expect(response).to redirect_to(root_path)
+ admin_required_msg = I18n.t("forms.session_new.status.admin_required")
+ expect(flash[:alert]).to include(admin_required_msg)
+ end
+ end
+
+ describe "Badge batch creation" do
+ before { login_as(admin_user) }
+
+ it "creates batch with specified count" do
+ params = {badge_batch: {count: 10, note: "Test"}}
+ expect {
+ post badge_batches_path, params: params
+ }.to change(BadgeBatch, :count).by(1)
+ .and change(Badge, :count).by(10)
+ end
+
+ it "stores count on the batch" do
+ params = {badge_batch: {count: 10, note: "Test"}}
+ post badge_batches_path, params: params
+ expect(BadgeBatch.last.count).to eq(10)
+ end
+ end
+
+ describe "Badge search" do
+ before { login_as(admin_user) }
+
+ it "redirects to badge show when badge exists" do
+ batch = create(:badge_batch)
+ badge = create(:badge, badge_batch: batch)
+
+ get search_badge_batches_path, params: {query: badge.id}
+ expect(response).to redirect_to(badge_path(badge))
+ expect(flash[:success]).to be_present
+ end
+
+ it "redirects to index with error when badge not found" do
+ get search_badge_batches_path, params: {query: "NOTFOUND"}
+ expect(response).to redirect_to(badge_batches_path)
+ expect(flash[:alert]).to be_present
+ end
+
+ it "redirects to index when query is blank" do
+ get search_badge_batches_path, params: {query: ""}
+ expect(response).to redirect_to(badge_batches_path)
+ end
+ end
+
+ describe "Edge cases" do
+ before { login_as(admin_user) }
+
+ it "returns 404 for missing badge batch" do
+ get badge_batch_path(99999)
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/requests/badges/badges_spec.rb b/spec/requests/badges/badges_spec.rb
new file mode 100644
index 00000000..b65f9380
--- /dev/null
+++ b/spec/requests/badges/badges_spec.rb
@@ -0,0 +1,64 @@
+# typed: false
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Badges", type: :request do
+ let(:admin_user) { create(:user, :admin, :without_company) }
+ let(:regular_user) { create(:user, :active_user) }
+ let(:batch) { create(:badge_batch) }
+ let(:badge) { create(:badge, badge_batch: batch) }
+
+ describe "Authentication" do
+ it "redirects to login when not logged in" do
+ get edit_badge_path(badge)
+ expect(response).to redirect_to(login_path)
+ end
+ end
+
+ describe "Authorization" do
+ before { login_as(regular_user) }
+
+ it "denies access to regular users" do
+ get edit_badge_path(badge)
+ expect(response).to redirect_to(root_path)
+ admin_required_msg = I18n.t("forms.session_new.status.admin_required")
+ expect(flash[:alert]).to include(admin_required_msg)
+ end
+ end
+
+ describe "Badge show" do
+ before { login_as(admin_user) }
+
+ it "returns http success" do
+ get badge_path(badge)
+ expect(response).to have_http_status(:success)
+ end
+
+ it "shows badge and batch details" do
+ get badge_path(badge)
+ expect(response.body).to include(badge.id)
+ expect(response.body).to include(batch.id.to_s)
+ end
+ end
+
+ describe "Badge updates" do
+ before { login_as(admin_user) }
+
+ it "updates badge note and redirects to batch" do
+ patch badge_path(badge), params: {badge: {note: "Updated note"}}
+ badge.reload
+ expect(badge.note).to eq("Updated note")
+ expect(response).to redirect_to(badge_batch_path(batch))
+ end
+ end
+
+ describe "Edge cases" do
+ before { login_as(admin_user) }
+
+ it "returns 404 for missing badge" do
+ get edit_badge_path("INVALID1")
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+end