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? %> +
    +
    + <%= render 'shared/table_column', + field: :id, + i18n_key: "badges.fields.id", + header: true %> + <%= render 'shared/table_column', + field: :count, + i18n_key: "badges.fields.count", + header: true %> + <%= render 'shared/table_column', + field: :created_at, + i18n_key: "badges.fields.created_at", + header: true %> + <%= render 'shared/table_column', + field: :note, + i18n_key: "badges.fields.note", + header: true %> +
    + + +
    +<% 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? %> +
    +
    + <%= render 'shared/table_column', + field: :badge_id, + i18n_key: "badges.fields.badge_id", + header: true %> + <%= render 'shared/table_column', + field: :note, + i18n_key: "badges.fields.note", + header: true %> +
    + + +
    +<% 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) %> + + + +<% 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