diff --git a/.github/copilot/guidelines.yml b/.github/copilot/guidelines.yml new file mode 100644 index 00000000000..204784c2e1b --- /dev/null +++ b/.github/copilot/guidelines.yml @@ -0,0 +1,12 @@ +# GitHub Copilot Guidelines + +# Pull Request Review Guidelines +review: + language: "ja" + description: "Pull Requestのレビューは日本語で行うこと" + +# Code Review Standards +standards: + - "コードレビューでは日本語でコメントを記載する" + - "技術的な議論も日本語で行う" + - "レビューコメントは建設的で丁寧な表現を心がける" diff --git a/Gemfile b/Gemfile index 132b0238b83..ad22676de4b 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,7 @@ gem 'good_job', '~> 3.14', github: 'komagata/good_job' gem 'google-cloud-storage', '~> 1.25', require: false gem 'holiday_jp' gem 'icalendar', '~> 2.8' +gem 'interactor', '~> 3.0' gem 'jp_prefecture', '~> 1.1' gem 'jquery-rails' gem 'kaminari' @@ -62,6 +63,7 @@ gem 'rack-cors', require: 'rack/cors' gem 'rack-user_agent' gem 'rails_autolink' gem 'rails-i18n', '~> 6.0.0' +gem 'rails-patterns', '~> 0.2' gem 'ransack', '3.1.0' gem 'react-rails' gem 'recaptcha', '~> 5.12' diff --git a/Gemfile.lock b/Gemfile.lock index 32765315570..2f9fdb4e311 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,6 +107,10 @@ GEM any_login (1.7.0) rails (>= 6.1) ast (2.4.3) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) babel-source (5.8.35) babel-transpiler (0.7.0) babel-source (>= 4.0, < 6) @@ -137,6 +141,8 @@ GEM cocooned (2.3.0) rails (>= 6.1, <= 8.0) coderay (1.1.3) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) concurrent-ruby (1.3.4) connection_pool (2.5.3) countries (7.1.1) @@ -152,6 +158,8 @@ GEM railties (>= 6.1) date (3.4.1) declarative (0.0.20) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) diffy (3.4.3) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) @@ -243,9 +251,11 @@ GEM logger ostruct ice_cube (0.17.0) + ice_nine (0.11.2) image_processing (1.14.0) mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) + interactor (3.1.2) jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) @@ -446,6 +456,11 @@ GEM rails-i18n (6.0.0) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 7) + rails-patterns (0.11.0) + actionpack (>= 4.2.6) + activerecord (>= 4.2.6) + ruby2_keywords + virtus rails_autolink (1.1.8) actionview (> 3.1) activesupport (> 3.1) @@ -524,6 +539,7 @@ GEM ruby-vips (2.2.3) ffi (~> 1.12) logger + ruby2_keywords (0.0.5) rubyzip (2.4.1) selenium-webdriver (4.17.0) base64 (~> 0.2) @@ -573,6 +589,7 @@ GEM stripe (15.2.0) temple (0.10.3) thor (1.3.2) + thread_safe (0.3.6) tilt (2.6.0) timeout (0.4.3) traceroute (0.8.1) @@ -594,6 +611,10 @@ GEM method_source (~> 1.0) view_source_map (0.3.0) rails (>= 5) + virtus (2.0.0) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -660,6 +681,7 @@ DEPENDENCIES holiday_jp icalendar (~> 2.8) image_processing (~> 1.12) + interactor (~> 3.0) jbuilder (~> 2.7) jp_prefecture (~> 1.1) jquery-rails @@ -696,6 +718,7 @@ DEPENDENCIES rack-user_agent rails (~> 6.1.7.10) rails-i18n (~> 6.0.0) + rails-patterns (~> 0.2) rails_autolink ransack (= 3.1.0) react-rails diff --git a/app/controllers/admin/users/practice_progress_batches_controller.rb b/app/controllers/admin/users/practice_progress_batches_controller.rb new file mode 100644 index 00000000000..f9c33f5fc8a --- /dev/null +++ b/app/controllers/admin/users/practice_progress_batches_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Admin::Users::PracticeProgressBatchesController < AdminController + before_action :set_user + + def create + migrator = PracticeProgressMigrator.new(@user) + + result = migrator.migrate_all + if result + redirect_to admin_user_practice_progress_path(@user), notice: '全ての進捗をコピーしました。' + else + redirect_to admin_user_practice_progress_path(@user), alert: '進捗のコピーに失敗しました。' + end + end + + private + + def set_user + @user = User.find(params[:user_id]) + end +end diff --git a/app/controllers/admin/users/practice_progress_controller.rb b/app/controllers/admin/users/practice_progress_controller.rb new file mode 100644 index 00000000000..fb037614768 --- /dev/null +++ b/app/controllers/admin/users/practice_progress_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Admin::Users::PracticeProgressController < AdminController + before_action :set_user + + def show + @presenter = PracticeProgressPresenter.new(@user) + @current_course = @user.course + @rails_course = Course.rails_course + end + + def create + practice_id = practice_progress_params[:practice_id] + + unless practice_id.present? && Practice.exists?(practice_id) + redirect_to admin_user_practice_progress_path(@user), alert: 'プラクティスが見つかりません' + return + end + + migrator = PracticeProgressMigrator.new(@user) + + if migrator.migrate(practice_id) + redirect_to admin_user_practice_progress_path(@user), notice: '進捗をコピーしました。' + else + redirect_to admin_user_practice_progress_path(@user), alert: 'コピー先のプラクティスが見つかりません。' + end + end + + private + + def set_user + @user = User.find(params[:user_id]) + end + + def practice_progress_params + params.permit(:practice_id) + end +end diff --git a/app/interactors/copy_check.rb b/app/interactors/copy_check.rb new file mode 100644 index 00000000000..9120fcc2d63 --- /dev/null +++ b/app/interactors/copy_check.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class CopyCheck + include Interactor + + def call + # Skip if no product was copied but consider it successful + unless context.copied_product && context.original_product + context.message = 'No product available for check copying, skipping' + return + end + + find_original_checks + copy_all_checks + end + + private + + def find_original_checks + context.original_checks = context.original_product.checks.to_a + + context.message = if context.original_checks.empty? + 'No checks found to copy' + else + "Found #{context.original_checks.size} check(s) to copy" + end + end + + def copy_all_checks + return if context.original_checks.empty? + + results = process_checks + store_results(results) + update_completion_message(results) + rescue ActiveRecord::RecordInvalid => e + context.fail!(error: "Failed to create check: #{e.message}") + end + + def process_checks + copied_count = 0 + skipped_count = 0 + + # Fetch all existing check user IDs for the copied product in one query + existing_user_ids = Check.where(checkable: context.copied_product) + .pluck(:user_id) + .to_set + + context.original_checks.each do |original_check| + if copy_check(original_check, existing_user_ids) + copied_count += 1 + else + skipped_count += 1 + end + end + + { copied: copied_count, skipped: skipped_count } + end + + def store_results(results) + context.copied_checks_count = results[:copied] + context.skipped_checks_count = results[:skipped] + end + + def update_completion_message(results) + context.message = build_summary_message(results) + end + + def products_available? + context.original_product && context.copied_product + end + + def build_summary_message(results) + "Copied #{results[:copied]} check(s), skipped #{results[:skipped]} existing check(s)" + end + + def copy_check(original_check, existing_user_ids) + # Check if this user already has a check for the copied product + return false if existing_user_ids.include?(original_check.user_id) + + Check.create!( + user: original_check.user, + checkable: context.copied_product + ) + + # Add the new user ID to the set to avoid duplicates in subsequent iterations + existing_user_ids.add(original_check.user_id) + true + end +end diff --git a/app/interactors/copy_learning.rb b/app/interactors/copy_learning.rb new file mode 100644 index 00000000000..eab632c33e2 --- /dev/null +++ b/app/interactors/copy_learning.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class CopyLearning + include Interactor + + def call + validate_inputs + return if context.failure? + + find_original_learning + return if context.failure? + + check_existing_learning + return if existing_learning_found? + + create_copied_learning + end + + private + + def validate_inputs + return if context.user && context.from_practice && context.to_practice + + context.fail!(error: 'Missing required parameters: user, from_practice, to_practice') + end + + def find_original_learning + context.original_learning = Learning.find_by( + user: context.user, + practice: context.from_practice + ) + + return if context.original_learning + + context.fail!(error: 'Original learning not found') + end + + def check_existing_learning + context.existing_learning = Learning.find_by( + user: context.user, + practice: context.to_practice + ) + end + + def existing_learning_found? + if context.existing_learning + context.message = 'Learning already exists, skipping copy' + true + else + false + end + end + + def create_copied_learning + context.copied_learning = Learning.create!( + user: context.user, + practice: context.to_practice, + status: context.original_learning.status + ) + + context.message = 'Learning copied successfully' + rescue ActiveRecord::RecordInvalid => e + context.fail!(error: "Failed to create learning: #{e.message}") + end +end diff --git a/app/interactors/copy_practice_progress.rb b/app/interactors/copy_practice_progress.rb new file mode 100644 index 00000000000..cea0d51083e --- /dev/null +++ b/app/interactors/copy_practice_progress.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CopyPracticeProgress + include Interactor + + def call + ActiveRecord::Base.transaction do + run_learning_copy + run_product_copy + run_check_copy + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + context.fail!(error: e.message) + end + + private + + def run_learning_copy + result = CopyLearning.call(context.to_h) + merge_result(result) + end + + def run_product_copy + result = CopyProduct.call(context.to_h) + merge_result(result) + end + + def run_check_copy + result = CopyCheck.call(context.to_h) + merge_result(result) + end + + def merge_result(result) + if result.success? + # Merge successful result data into context + result.to_h.each { |key, value| context[key] = value } + else + context.fail!(error: result.error) + end + end +end diff --git a/app/interactors/copy_product.rb b/app/interactors/copy_product.rb new file mode 100644 index 00000000000..17a4f1d667e --- /dev/null +++ b/app/interactors/copy_product.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class CopyProduct + include Interactor + + def call + validate_inputs + return if context.failure? + + find_original_product + return unless context.original_product + + check_existing_product + return if existing_product_found? + + create_copied_product + end + + private + + def validate_inputs + return if context.user && context.from_practice && context.to_practice + + context.fail!(error: 'Missing required parameters: user, from_practice, to_practice') + end + + def find_original_product + context.original_product = Product.find_by( + user: context.user, + practice: context.from_practice + ) + + return if context.original_product + + # Set message but don't fail - let the Organizer continue + context.message = 'Product not found – skipping copy' + end + + def check_existing_product + context.existing_product = Product.find_by( + user: context.user, + practice: context.to_practice + ) + end + + def existing_product_found? + if context.existing_product + # Set both existing and original products in context for subsequent interactors + context.copied_product = context.existing_product + context.message = 'Product already exists, skipping copy' + true + else + false + end + end + + def create_copied_product + context.copied_product = Product.create!( + user: context.user, + practice: context.to_practice, + body: context.original_product.body, + wip: context.original_product.wip + ) + + # original_product is already set in context from find_original_product + context.message = 'Product copied successfully' + rescue ActiveRecord::RecordInvalid => e + context.fail!(error: "Failed to create product: #{e.message}") + end +end diff --git a/app/models/course.rb b/app/models/course.rb index 848c5e251a7..22aa2fed3f0 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class Course < ApplicationRecord - DEFAULT_COURSE = 'Railsエンジニア' + RAILS_COURSE = 'Railsエンジニア' + DEFAULT_COURSE = RAILS_COURSE has_many :courses_categories, dependent: :destroy has_many :categories, through: :courses_categories @@ -14,4 +15,8 @@ class Course < ApplicationRecord def self.default_course find_by(title: DEFAULT_COURSE) end + + def self.rails_course + find_by(title: RAILS_COURSE) + end end diff --git a/app/models/practice.rb b/app/models/practice.rb index df937e7b770..c9a689758fb 100644 --- a/app/models/practice.rb +++ b/app/models/practice.rb @@ -51,10 +51,15 @@ class Practice < ApplicationRecord through: :coding_tests, source: :coding_test_submissions + # Practice copy relationships + has_many :copied_practices, class_name: 'Practice', foreign_key: 'source_id', dependent: :nullify, inverse_of: :source_practice + belongs_to :source_practice, class_name: 'Practice', foreign_key: 'source_id', optional: true, inverse_of: :copied_practices + validates :title, presence: true validates :description, presence: true validates :goal, presence: true validates :categories, presence: true + validate :source_id_cannot_be_self columns_for_keyword_search :title, :description, :goal @@ -217,4 +222,10 @@ def convert_to_hour_minute(learning_minute_statistic) "#{converted_hour}時間#{converted_minute}分" end end + + def source_id_cannot_be_self + return unless source_id && id + + errors.add(:source_id, 'cannot reference itself') if source_id == id + end end diff --git a/app/models/practice_progress_migrator.rb b/app/models/practice_progress_migrator.rb new file mode 100644 index 00000000000..12388b6e3bd --- /dev/null +++ b/app/models/practice_progress_migrator.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class PracticeProgressMigrator + def initialize(user) + @user = user + end + + def migrate(practice_id) + practice = Practice.find(practice_id) + copied_practice = Practice.find_by(source_id: practice_id) + + return false unless copied_practice + + result = CopyPracticeProgress.call( + user: @user, + from_practice: practice, + to_practice: copied_practice + ) + + result.success? + end + + def migrate_all + presenter = PracticeProgressPresenter.new(@user) + completed_practices = presenter.completed_practices + + ActiveRecord::Base.transaction do + raise ActiveRecord::Rollback unless process_all_practices(completed_practices) + end + + true + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + Rails.logger.error "PracticeProgressMigrator#migrate_all failed: #{e.message}" + false + end + + private + + def process_all_practices(completed_practices) + completed_practices.each do |learning| + practice = learning.practice + copied_practice = Practice.find_by(source_id: practice.id) + + next unless copied_practice + + result = CopyPracticeProgress.call( + user: @user, + from_practice: practice, + to_practice: copied_practice + ) + + unless result.success? + Rails.logger.error "Failed to copy practice progress for practice #{practice.id}: #{result.error}" + return false + end + end + + true + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4125291aac8..93771b48b24 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -588,6 +588,10 @@ def practices_with_checked_product Practice.where(products: products.checked) end + def practice_ids_skipped + skipped_practices.pluck(:practice_id) + end + def total_learning_time sql = <<~SQL SELECT @@ -636,10 +640,6 @@ def subscription Subscription.new.retrieve(subscription_id) end - def practice_ids_skipped - skipped_practices.pluck(:practice_id) - end - def student? !admin? && !adviser? && !mentor? && !trainee? end diff --git a/app/presenters/practice_progress_presenter.rb b/app/presenters/practice_progress_presenter.rb new file mode 100644 index 00000000000..213899ce264 --- /dev/null +++ b/app/presenters/practice_progress_presenter.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class PracticeProgressPresenter + attr_reader :user + + def initialize(user, course: Course.rails_course) + @user = user + @course = course + end + + def completed_practices + @completed_practices ||= CompletedLearningsQuery + .new(user.learnings, course: @course) + .call + .includes( + :practice, + practice: { + copied_practices: [ + { learnings: :user }, + { products: %i[user checks] } + ] + } + ) + end + + def copied_practices_for(practice_ids) + Practice.where(source_id: practice_ids) + .includes(%i[learnings products], learnings: :user, products: %i[user checks]) + .where(learnings: { user: }) + end + + def user_products_for(practice_ids) + user.products.where(practice_id: practice_ids) + .includes(:practice, :checks) + end + + def migration_candidates + completed_practices.joins(:practice).where(practices: { source_id: nil }) + .where.not(practice_id: Practice.where(source_id: completed_practice_ids)) + end + + def copied_practice_learnings_for(copied_practice_ids) + user.learnings.where(practice_id: copied_practice_ids) + .includes(:practice) + end + + def copied_practice_products_for(copied_practice_ids) + user.products.where(practice_id: copied_practice_ids) + .includes(:practice, :checks) + end + + delegate :count, to: :completed_practices, prefix: true + + def migration_progress_percentage + return 0 if completed_practices_count.zero? + + migrated_count = copied_practices_for(completed_practice_ids).count + (migrated_count.to_f / completed_practices_count * 100).round(2) + end + + def migration_candidates? + migration_candidates.exists? + end + + def practice_status_for(practice) + return 'copied' if practice.source_id.present? + return 'has_copy' if copy_destination?(practice) + + 'original' + end + + def product_for(learning) + @products_cache ||= user.products.where(practice_id: completed_practice_ids) + .includes(:practice, :checks) + .index_by(&:practice_id) + @products_cache[learning.practice_id] + end + + def copied_practice_for(practice) + @copied_practices_cache ||= {} + @copied_practices_cache[practice.id] ||= practice.copied_practices + .joins(:learnings) + .where(learnings: { user: }) + .includes(learnings: [], products: %i[checks]) + .first + end + + def copied_learning_for(copied_practice) + return nil unless copied_practice + + copied_practice.learnings.find { |l| l.user == user } + end + + def copied_product_for(copied_practice) + return nil unless copied_practice + + copied_practice.products.find { |p| p.user == user } + end + + alias copied_practice_learning_for copied_learning_for + alias copied_practice_product_for copied_product_for + + def copy_destination?(practice) + copy_destinations.include?(practice.id) + end + + def copy_destination_practice_for(practice) + copy_destination_practices[practice.id] + end + + private + + def copy_destinations + @copy_destinations ||= Practice.where(source_id: completed_practice_ids) + .pluck(:source_id) + .to_set + end + + def copy_destination_practices + @copy_destination_practices ||= Practice.where(source_id: completed_practice_ids) + .includes(:learnings, :products, learnings: :user, products: %i[user checks]) + .index_by(&:source_id) + end + + def completed_practice_ids + @completed_practice_ids ||= completed_practices.pluck(:practice_id) + end +end diff --git a/app/queries/completed_learnings_query.rb b/app/queries/completed_learnings_query.rb new file mode 100644 index 00000000000..7441f244566 --- /dev/null +++ b/app/queries/completed_learnings_query.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CompletedLearningsQuery < Patterns::Query + queries Learning + + private + + def query + relation + .joins(practice: { categories: :courses_categories }) + .where(status: Learning.statuses[:complete], courses_categories: { course_id: @course.id }) + .distinct + .includes(:practice) + .order('learnings.updated_at asc') + end + + def initialize(relation = Learning.all, course:) + super(relation) + @course = course + end +end diff --git a/app/views/admin/users/practice_progress/show.html.slim b/app/views/admin/users/practice_progress/show.html.slim new file mode 100644 index 00000000000..2c631c354d3 --- /dev/null +++ b/app/views/admin/users/practice_progress/show.html.slim @@ -0,0 +1,102 @@ +- title '管理ページ' + +header.page-header + .container + .page-header__inner + h1.page-header__title + = title + += render 'admin/admin_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = @user.login_name + | さんのRailsエンジニアコース完了プラクティス一覧 + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to admin_users_path, class: 'a-button is-md is-secondary is-block is-back' do + | ユーザー一覧 + hr.a-border + .page-body + .container + - if @presenter.completed_practices.any? + .admin-table + table.admin-table__table + thead.admin-table__header + tr.admin-table__labels + th.admin-table__label(colspan="5") Railsエンジニア + th.admin-table__label(colspan="1") + th.admin-table__label(colspan="4") Railsエンジニア(Reスキル講座認定) + tr.admin-table__labels + th.admin-table__label ID + th.admin-table__label プラクティス名 + th.admin-table__label ステータス + th.admin-table__label 進捗 + th.admin-table__label 完了日 + th.admin-table__label 操作 + th.admin-table__label ID(Reスキル) + th.admin-table__label プラクティス(Reスキル) + th.admin-table__label ステータス(Reスキル) + th.admin-table__label 進捗(Reスキル) + tbody.admin-table__items + - @presenter.completed_practices.each do |learning| + ruby: + practice = learning.practice + user_product = @presenter.product_for(learning) + copy_destination_practice = @presenter.copy_destination_practice_for(practice) + copied_practice_learning = @presenter.copied_practice_learning_for(copy_destination_practice) + copied_practice_product = @presenter.copied_practice_product_for(copy_destination_practice) + tr.admin-table__item + td.admin-table__item-value.text-center + = learning.practice.id + td.admin-table__item-value.text-left + = link_to learning.practice.title, practice_path(learning.practice), target: '_blank', rel: 'noopener', class: 'a-link' + td.admin-table__item-value.text-center + = t("activerecord.enums.learning.status.#{learning.status}") + td.admin-table__item-value.text-center + - if user_product + = link_to '提出物', product_path(user_product), target: '_blank', rel: 'noopener', class: 'a-link' + - else + span.a-meta なし + td.admin-table__item-value.text-center + = l(learning.updated_at, format: :long) + td.admin-table__item-value.text-center + - if @presenter.copy_destination?(practice) + = link_to admin_user_practice_progress_path(@user, practice_id: learning.practice.id), method: :post, class: 'a-button is-sm is-primary', data: { confirm: '進捗をコピーしますか?' } do + | 進捗コピー + i.fa-solid.fa-arrow-circle-right.ml-1 + - else + span.a-meta なし + td.admin-table__item-value.text-center + = copy_destination_practice&.id || 'なし' + td.admin-table__item-value.text-left + - if copy_destination_practice + = link_to copy_destination_practice.title, practice_path(copy_destination_practice), target: '_blank', rel: 'noopener', class: 'a-link' + - else + span.a-meta なし + td.admin-table__item-value.text-center + - if copied_practice_learning + = t("activerecord.enums.learning.status.#{copied_practice_learning.status}") + - elsif copy_destination_practice + span.a-meta 未着手 + - else + span.a-meta なし + td.admin-table__item-value.text-center + - if copied_practice_product + = link_to '提出物', product_path(copied_practice_product), target: '_blank', rel: 'noopener', class: 'a-link' + - elsif copy_destination_practice + span.a-meta なし + - else + span.a-meta なし + .text-center.mt-8 + = link_to admin_user_practice_progress_batches_path(@user), method: :post, class: 'a-button is-lg is-warning', data: { confirm: '全ての進捗をコピーしますか?' } do + | 全ての進捗をコピー + i.fa-solid.fa-copy.ml-1 + - else + p Railsエンジニアコースで完了したプラクティスがありません。 diff --git a/config/application.rb b/config/application.rb index bc24ee30f52..c07a4a1c333 100644 --- a/config/application.rb +++ b/config/application.rb @@ -22,6 +22,7 @@ class Application < Rails::Application config.i18n.default_locale = :ja config.paths.add "lib", eager_load: true + config.paths.add "app/presenters", eager_load: true config.action_view.field_error_proc = Proc.new do |html_tag, instance| html_tag.html_safe diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index b0593d4557b..c741ac2b097 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -16,4 +16,5 @@ inflect.acronym "AI" inflect.acronym "FAQ" inflect.irregular "buzz", "buzzes" + inflect.uncountable "progress" end diff --git a/config/routes/admin.rb b/config/routes/admin.rb index cf9d4a90028..c4136cf8fd0 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -7,6 +7,8 @@ resources :companies, except: %i(show) resources :users, only: %i(index show edit update destroy) do resource :password, only: %i(edit update), controller: "users/password" + resource :practice_progress, only: %i(show create), controller: "users/practice_progress" + resources :practice_progress_batches, only: %i(create), controller: "users/practice_progress_batches" end resources :campaigns, only: %i(new create index edit update) resources :inquiries, only: %i(index show) diff --git a/db/migrate/20250618142946_add_index_on_source_id_to_practices.rb b/db/migrate/20250618142946_add_index_on_source_id_to_practices.rb new file mode 100644 index 00000000000..973c8fdfdac --- /dev/null +++ b/db/migrate/20250618142946_add_index_on_source_id_to_practices.rb @@ -0,0 +1,5 @@ +class AddIndexOnSourceIdToPractices < ActiveRecord::Migration[6.1] + def change + add_index :practices, :source_id + end +end diff --git a/db/migrate/20250618143741_add_unique_index_to_users_email_and_login_name.rb b/db/migrate/20250618143741_add_unique_index_to_users_email_and_login_name.rb new file mode 100644 index 00000000000..9f1eca91a72 --- /dev/null +++ b/db/migrate/20250618143741_add_unique_index_to_users_email_and_login_name.rb @@ -0,0 +1,6 @@ +class AddUniqueIndexToUsersEmailAndLoginName < ActiveRecord::Migration[6.1] + def change + add_index :users, :email, unique: true + add_index :users, :login_name, unique: true + end +end diff --git a/db/migrate/20250618144325_add_foreign_key_to_practices_source_id.rb b/db/migrate/20250618144325_add_foreign_key_to_practices_source_id.rb new file mode 100644 index 00000000000..0c658a29717 --- /dev/null +++ b/db/migrate/20250618144325_add_foreign_key_to_practices_source_id.rb @@ -0,0 +1,5 @@ +class AddForeignKeyToPracticesSourceId < ActiveRecord::Migration[6.1] + def change + add_foreign_key :practices, :practices, column: :source_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 148e8f947b3..d1aa1829fea 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.define(version: 2025_04_11_103935) do +ActiveRecord::Schema.define(version: 2025_06_18_144325) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" @@ -249,11 +249,11 @@ create_table "courses", force: :cascade do |t| t.string "title", null: false t.text "description", null: false - t.text "summary" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.boolean "published", default: false, null: false t.boolean "grant", default: false, null: false + t.text "summary" end create_table "courses_categories", force: :cascade do |t| @@ -635,6 +635,7 @@ t.text "summary" t.integer "source_id" t.index ["category_id"], name: "index_practices_on_category_id" + t.index ["source_id"], name: "index_practices_on_source_id" end create_table "practices_books", force: :cascade do |t| @@ -1037,6 +1038,7 @@ add_foreign_key "pages", "users" add_foreign_key "participations", "events" add_foreign_key "participations", "users" + add_foreign_key "practices", "practices", column: "source_id" add_foreign_key "practices_books", "books" add_foreign_key "practices_books", "practices" add_foreign_key "practices_movies", "movies" diff --git a/test/interactors/copy_check_test.rb b/test/interactors/copy_check_test.rb new file mode 100644 index 00000000000..5807553f5a1 --- /dev/null +++ b/test/interactors/copy_check_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CopyCheckTest < ActiveSupport::TestCase + def setup + @user1 = users(:kimura) + @user2 = users(:komagata) + @from_practice = practices(:practice1) + @to_practice = practices(:practice2) + + # Clear any existing data + Check.where(checkable_type: 'Product').destroy_all + Product.destroy_all + + # Create products + @original_product = Product.create!( + user: @user1, + practice: @from_practice, + body: 'Original submission' + ) + + @copied_product = Product.create!( + user: @user1, + practice: @to_practice, + body: 'Copied submission' + ) + end + + test 'successfully copies checks when original has checks and target has none' do + # Create checks for original product + Check.create!(user: @user1, checkable: @original_product) + Check.create!(user: @user2, checkable: @original_product) + + result = CopyCheck.call( + original_product: @original_product, + copied_product: @copied_product + ) + + assert result.success? + assert_equal 'Copied 2 check(s), skipped 0 existing check(s)', result.message + assert_equal 2, result.copied_checks_count + assert_equal 0, result.skipped_checks_count + + # Verify the checks were copied + copied_checks = Check.where(checkable: @copied_product) + assert_equal 2, copied_checks.count + assert_includes copied_checks.pluck(:user_id), @user1.id + assert_includes copied_checks.pluck(:user_id), @user2.id + end + + test 'skips existing checks and only copies new ones' do + # Create checks for original product + Check.create!(user: @user1, checkable: @original_product) + Check.create!(user: @user2, checkable: @original_product) + + # Create one existing check for copied product + Check.create!(user: @user1, checkable: @copied_product) + + result = CopyCheck.call( + original_product: @original_product, + copied_product: @copied_product + ) + + assert result.success? + assert_equal 'Copied 1 check(s), skipped 1 existing check(s)', result.message + assert_equal 1, result.copied_checks_count + assert_equal 1, result.skipped_checks_count + + # Verify only one new check was added + copied_checks = Check.where(checkable: @copied_product) + assert_equal 2, copied_checks.count + end + + test 'succeeds when original product has no checks' do + result = CopyCheck.call( + original_product: @original_product, + copied_product: @copied_product + ) + + assert result.success? + assert_equal 'No checks found to copy', result.message + + # Verify no checks were created + copied_checks = Check.where(checkable: @copied_product) + assert_equal 0, copied_checks.count + end + + test 'skips when required parameters are missing' do + result = CopyCheck.call( + original_product: nil, + copied_product: @copied_product + ) + + # Should succeed but skip execution due to missing original_product + assert result.success? + assert_equal 'No product available for check copying, skipping', result.message + + # Verify no checks were created + copied_checks = Check.where(checkable: @copied_product) + assert_equal 0, copied_checks.count + end + + test 'fails when check creation fails' do + # Create check for original product + Check.create!(user: @user1, checkable: @original_product) + + # Mock Check.create! to raise an exception + Check.stub :create!, ->(*) { raise ActiveRecord::RecordInvalid, Check.new } do + result = CopyCheck.call( + original_product: @original_product, + copied_product: @copied_product + ) + + assert_not result.success? + assert_match(/Failed to create check/, result.error) + end + end +end diff --git a/test/interactors/copy_learning_test.rb b/test/interactors/copy_learning_test.rb new file mode 100644 index 00000000000..15308aa207a --- /dev/null +++ b/test/interactors/copy_learning_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CopyLearningTest < ActiveSupport::TestCase + def setup + @user = users(:kimura) + @from_practice = practices(:practice1) + @to_practice = practices(:practice2) + + # Clear any existing data + @user.learnings.destroy_all + end + + test 'successfully copies learning when original exists and target does not' do + # Create original learning + Learning.create!( + user: @user, + practice: @from_practice, + status: 'complete' + ) + + result = CopyLearning.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + assert_equal 'Learning copied successfully', result.message + assert_not_nil result.copied_learning + + # Verify the copied learning was created + copied_learning = Learning.find_by(user: @user, practice: @to_practice) + assert_not_nil copied_learning + assert_equal 'complete', copied_learning.status + end + + test 'skips copy when target learning already exists' do + # Create both original and target learnings + Learning.create!(user: @user, practice: @from_practice, status: 'complete') + existing_learning = Learning.create!(user: @user, practice: @to_practice, status: 'started') + + result = CopyLearning.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + assert_equal 'Learning already exists, skipping copy', result.message + assert_equal existing_learning, result.existing_learning + + # Verify the existing learning was not modified + existing_learning.reload + assert_equal 'started', existing_learning.status + end + + test 'fails when original learning does not exist' do + result = CopyLearning.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_equal 'Original learning not found', result.error + end + + test 'fails when required parameters are missing' do + result = CopyLearning.call( + user: nil, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_equal 'Missing required parameters: user, from_practice, to_practice', result.error + end + + test 'fails when learning creation fails' do + # Create original learning + Learning.create!(user: @user, practice: @from_practice, status: 'complete') + + # Mock Learning.create! to raise an exception + Learning.stub :create!, ->(*) { raise ActiveRecord::RecordInvalid, Learning.new } do + result = CopyLearning.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_match(/Failed to create learning/, result.error) + end + end +end diff --git a/test/interactors/copy_practice_progress_test.rb b/test/interactors/copy_practice_progress_test.rb new file mode 100644 index 00000000000..bd7675c19fd --- /dev/null +++ b/test/interactors/copy_practice_progress_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CopyPracticeProgressTest < ActiveSupport::TestCase + def setup + @user = users(:kimura) + @mentor = users(:komagata) + @from_practice = practices(:practice1) + @to_practice = practices(:practice2) + + # Clear any existing data + @user.learnings.destroy_all + @user.products.destroy_all + Check.where(checkable: @user.products).destroy_all + end + + test 'successfully copies learning, product, and checks when everything exists' do + # Create original data + Learning.create!(user: @user, practice: @from_practice, status: 'complete') + original_product = Product.create!( + user: @user, + practice: @from_practice, + body: 'Original submission', + wip: false + ) + Check.create!(user: @mentor, checkable: original_product) + + result = CopyPracticeProgress.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + # Message comes from the last successful interactor (CopyCheck) + assert_equal 'Copied 1 check(s), skipped 0 existing check(s)', result.message + + # Verify learning was copied + copied_learning = Learning.find_by(user: @user, practice: @to_practice) + assert_not_nil copied_learning + assert_equal 'complete', copied_learning.status + + # Verify product was copied + copied_product = Product.find_by(user: @user, practice: @to_practice) + assert_not_nil copied_product + assert_equal 'Original submission', copied_product.body + + # Verify check was copied + copied_check = Check.find_by(checkable: copied_product, user: @mentor) + assert_not_nil copied_check + end + + test 'successfully copies only learning when no product exists' do + # Create only learning data + Learning.create!(user: @user, practice: @from_practice, status: 'complete') + + result = CopyPracticeProgress.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + # Message comes from CopyCheck when no product is available + assert_equal 'No product available for check copying, skipping', result.message + + # Verify learning was copied + copied_learning = Learning.find_by(user: @user, practice: @to_practice) + assert_not_nil copied_learning + assert_equal 'complete', copied_learning.status + + # Verify no product was copied + copied_product = Product.find_by(user: @user, practice: @to_practice) + assert_nil copied_product + end + + test 'skips copying when target data already exists' do + # Create original data + Learning.create!(user: @user, practice: @from_practice, status: 'complete') + Product.create!(user: @user, practice: @from_practice, body: 'Original submission') + + # Create existing target data + Learning.create!(user: @user, practice: @to_practice, status: 'started') + Product.create!(user: @user, practice: @to_practice, body: 'Existing submission') + + result = CopyPracticeProgress.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + # Message comes from CopyCheck when no checks are found + assert_equal 'No checks found to copy', result.message + + # Verify existing data was not modified + copied_learning = Learning.find_by(user: @user, practice: @to_practice) + assert_equal 'started', copied_learning.status + + copied_product = Product.find_by(user: @user, practice: @to_practice) + assert_equal 'Existing submission', copied_product.body + end + + test 'fails when no original learning exists' do + result = CopyPracticeProgress.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_equal 'Original learning not found', result.error + end + + test 'fails when required parameters are missing' do + result = CopyPracticeProgress.call( + user: nil, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_equal 'Missing required parameters: user, from_practice, to_practice', result.error + end +end diff --git a/test/interactors/copy_product_test.rb b/test/interactors/copy_product_test.rb new file mode 100644 index 00000000000..a9a5f367ced --- /dev/null +++ b/test/interactors/copy_product_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CopyProductTest < ActiveSupport::TestCase + def setup + @user = users(:kimura) + @from_practice = practices(:practice1) + @to_practice = practices(:practice2) + + # Clear any existing data + @user.products.destroy_all + end + + test 'successfully copies product when original exists and target does not' do + # Create original product + Product.create!( + user: @user, + practice: @from_practice, + body: 'Original submission', + wip: false + ) + + result = CopyProduct.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + assert_equal 'Product copied successfully', result.message + assert_not_nil result.copied_product + + # Verify the copied product was created + copied_product = Product.find_by(user: @user, practice: @to_practice) + assert_not_nil copied_product + assert_equal 'Original submission', copied_product.body + assert_not copied_product.wip + end + + test 'skips copy when target product already exists' do + # Create both original and target products + Product.create!(user: @user, practice: @from_practice, body: 'Original submission', wip: false) + existing_product = Product.create!(user: @user, practice: @to_practice, body: 'Existing submission', wip: true) + + result = CopyProduct.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + assert_equal 'Product already exists, skipping copy', result.message + assert_equal existing_product, result.existing_product + + # Verify the existing product was not modified + existing_product.reload + assert_equal 'Existing submission', existing_product.body + assert existing_product.wip + end + + test 'succeeds with message when original product does not exist' do + result = CopyProduct.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert result.success? + assert_equal 'Product not found – skipping copy', result.message + end + + test 'fails when required parameters are missing' do + result = CopyProduct.call( + user: nil, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_equal 'Missing required parameters: user, from_practice, to_practice', result.error + end + + test 'fails when product creation fails' do + # Create original product + Product.create!(user: @user, practice: @from_practice, body: 'Original submission', wip: false) + + # Mock Product.create! to raise an exception + Product.stub :create!, ->(*) { raise ActiveRecord::RecordInvalid, Product.new } do + result = CopyProduct.call( + user: @user, + from_practice: @from_practice, + to_practice: @to_practice + ) + + assert_not result.success? + assert_match(/Failed to create product/, result.error) + end + end +end diff --git a/test/models/practice_progress_migrator_test.rb b/test/models/practice_progress_migrator_test.rb new file mode 100644 index 00000000000..16665cb8bfc --- /dev/null +++ b/test/models/practice_progress_migrator_test.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PracticeProgressMigratorTest < ActiveSupport::TestCase + def setup + @user = users(:kimura) + # Clear all data to ensure test isolation + @user.learnings.destroy_all + @user.products.destroy_all + @migrator = PracticeProgressMigrator.new(@user) + @rails_course = courses(:course1) # Railsエンジニアコース + end + + test 'migrate copies learning and product successfully' do + # Setup original practice + original_practice = practices(:practice1) + original_practice.categories.first.courses << @rails_course unless original_practice.categories.first.courses.include?(@rails_course) + + # Setup copied practice + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{Time.current.to_i}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << original_practice.categories.first + copied_practice.save! + + # Create original learning and product + Learning.create!( + user: @user, + practice: original_practice, + status: 'complete' + ) + + original_product = Product.create!( + user: @user, + practice: original_practice, + body: 'Original submission' + ) + + # Create check for original product + Check.create!( + user: users(:komagata), + checkable: original_product + ) + + result = @migrator.migrate(original_practice.id) + + assert result + + # Verify learning was copied + copied_learning = Learning.find_by(user: @user, practice: copied_practice) + assert_not_nil copied_learning + assert_equal 'complete', copied_learning.status + + # Verify product was copied + copied_product = Product.find_by(user: @user, practice: copied_practice) + assert_not_nil copied_product + assert_equal 'Original submission', copied_product.body + + # Verify check was copied + copied_check = Check.find_by(checkable: copied_product) + assert_not_nil copied_check + assert_equal users(:komagata).id, copied_check.user_id + end + + test 'migrate returns error when copied practice not found' do + practice = practices(:practice1) + + result = @migrator.migrate(practice.id) + + assert_not result + end + + test 'migrate preserves existing learning and product' do + # Setup original practice + original_practice = practices(:practice1) + original_practice.categories.first.courses << @rails_course unless original_practice.categories.first.courses.include?(@rails_course) + + # Setup copied practice + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{Time.current.to_i}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << original_practice.categories.first + copied_practice.save! + + # Create original learning and product + Learning.create!( + user: @user, + practice: original_practice, + status: 'complete' + ) + + Product.create!( + user: @user, + practice: original_practice, + body: 'Updated submission' + ) + + # Create existing learning and product for copied practice + existing_learning = Learning.create!( + user: @user, + practice: copied_practice, + status: 'started' + ) + + existing_product = Product.create!( + user: @user, + practice: copied_practice, + body: 'Old submission' + ) + + result = @migrator.migrate(original_practice.id) + + assert result + + # Verify learning was NOT updated (preserved existing state) + existing_learning.reload + assert_equal 'started', existing_learning.status + + # Verify product was NOT updated (preserved existing state) + existing_product.reload + assert_equal 'Old submission', existing_product.body + end + + test 'migrate_all copies all completed practices' do + # Setup original practices + original_practice1 = practices(:practice1) + original_practice1.categories.first.courses << @rails_course unless original_practice1.categories.first.courses.include?(@rails_course) + + original_practice2 = practices(:practice2) + original_practice2.categories.first.courses << @rails_course unless original_practice2.categories.first.courses.include?(@rails_course) + + # Setup copied practices + copied_practice1 = Practice.new( + title: "#{original_practice1.title} (Reスキル) #{Time.current.to_i}", + description: original_practice1.description, + goal: original_practice1.goal, + source_id: original_practice1.id + ) + copied_practice1.categories << original_practice1.categories.first + copied_practice1.save! + + copied_practice2 = Practice.new( + title: "#{original_practice2.title} (Reスキル) #{Time.current.to_i + 1}", + description: original_practice2.description, + goal: original_practice2.goal, + source_id: original_practice2.id + ) + copied_practice2.categories << original_practice2.categories.first + copied_practice2.save! + + # Create original learnings and products + Learning.create!(user: @user, practice: original_practice1, status: 'complete') + Learning.create!(user: @user, practice: original_practice2, status: 'complete') + + Product.create!(user: @user, practice: original_practice1, body: 'Submission 1') + Product.create!(user: @user, practice: original_practice2, body: 'Submission 2') + + result = @migrator.migrate_all + + assert result + + # Verify both learnings were copied + copied_learning1 = Learning.find_by(user: @user, practice: copied_practice1) + copied_learning2 = Learning.find_by(user: @user, practice: copied_practice2) + assert_not_nil copied_learning1 + assert_not_nil copied_learning2 + assert_equal 'complete', copied_learning1.status + assert_equal 'complete', copied_learning2.status + + # Verify both products were copied + copied_product1 = Product.find_by(user: @user, practice: copied_practice1) + copied_product2 = Product.find_by(user: @user, practice: copied_practice2) + assert_not_nil copied_product1 + assert_not_nil copied_product2 + assert_equal 'Submission 1', copied_product1.body + assert_equal 'Submission 2', copied_product2.body + end + + test 'migrate_all skips practices without copied version' do + # Setup original practice without copied version + original_practice = practices(:practice1) + original_practice.categories.first.courses << @rails_course unless original_practice.categories.first.courses.include?(@rails_course) + + # Create original learning and product + Learning.create!(user: @user, practice: original_practice, status: 'complete') + Product.create!(user: @user, practice: original_practice, body: 'Submission') + + result = @migrator.migrate_all + + assert result + end +end diff --git a/test/models/practice_test.rb b/test/models/practice_test.rb index bf402185f25..8f2514a039b 100644 --- a/test/models/practice_test.rb +++ b/test/models/practice_test.rb @@ -55,4 +55,36 @@ class PracticeTest < ActiveSupport::TestCase assert_equal category, practice.category(course) end + + test 'source_id_cannot_be_self validation prevents self-reference' do + practice = practices(:practice1) + practice.source_id = practice.id + + assert_not practice.valid? + assert_includes practice.errors[:source_id], 'cannot reference itself' + end + + test 'source_id_cannot_be_self validation allows nil source_id' do + practice = practices(:practice1) + practice.source_id = nil + + assert practice.valid? + end + + test 'source_id_cannot_be_self validation allows different practice reference' do + practice1 = practices(:practice1) + practice2 = practices(:practice2) + practice1.source_id = practice2.id + + assert practice1.valid? + end + + test 'foreign key constraint prevents invalid source_id references' do + practice = practices(:practice1) + + # Try to set a non-existent practice ID + assert_raises(ActiveRecord::InvalidForeignKey) do + practice.update!(source_id: 99_999) + end + end end diff --git a/test/system/admin/users/practice_progress_test.rb b/test/system/admin/users/practice_progress_test.rb new file mode 100644 index 00000000000..4429941f859 --- /dev/null +++ b/test/system/admin/users/practice_progress_test.rb @@ -0,0 +1,711 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class Admin::Users::PracticeProgressTest < ApplicationSystemTestCase + test "admin can view user's completed Rails practices only" do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create a dedicated category and practice for this test + category = Category.create!(name: 'Test Category', slug: 'test-category') + category.courses << rails_course + + practice = Practice.new( + title: 'Test Practice', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice:).destroy_all + + # Create a completed learning for the user + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text "#{user.login_name}さんのRailsエンジニアコース完了プラクティス一覧" + assert_text practice.title + assert_text practice.id.to_s + assert_text 'ID' + assert_text 'ステータス' + assert_text '提出物' + assert_text '完了日' + assert_text 'プラクティス(Reスキル)' + assert_text 'ステータス(Reスキル)' + assert_text '進捗(Reスキル)' + assert_text '修了' + assert_selector '.admin-table' + end + + test "admin can see user's current course" do + user = users(:kimura) + course = courses(:course1) + user.update!(course:) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + # NOTE: Current course information is not displayed in the current view + # assert_text "現在のコース: #{course.title}" + # assert_text '対象コース: Railsエンジニア' + end + + test 'shows message when user has no completed Rails practices' do + user = users(:kimura) + # Ensure user has no completed learnings + user.learnings.destroy_all + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text "#{user.login_name}さんのRailsエンジニアコース完了プラクティス一覧" + assert_text 'Railsエンジニアコースで完了したプラクティスがありません。' + end + + test 'does not show practices from other courses' do + user = users(:kimura) + frontend_course = courses(:course4) # フロントエンドエンジニアコース + + # Create a dedicated category and practice for frontend course + category = Category.create!(name: 'Frontend Test Category', slug: 'frontend-test-category') + category.courses << frontend_course + + practice = Practice.new( + title: 'Frontend Test Practice', + description: 'Frontend practice for system test', + goal: 'Frontend goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear all existing learnings for this user to ensure clean state + Learning.where(user:).destroy_all + + # Create a completed learning for the user + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text "#{user.login_name}さんのRailsエンジニアコース完了プラクティス一覧" + assert_no_text practice.title + assert_text 'Railsエンジニアコースで完了したプラクティスがありません。' + end + + test 'shows copied practice when source_id matches' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Copy Source Match', slug: 'test-category-copy-source-match') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Copy Source Match', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (コピー) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice: original_practice).destroy_all + + # Create completed learning for original practice + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text original_practice.title + assert_text original_practice.id.to_s + assert_text copied_practice.title + assert_text copied_practice.id.to_s + end + + test "shows 'なし' when no copied practice exists" do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and practice for this test + category = Category.create!(name: 'Test Category No Copy', slug: 'test-category-no-copy') + category.courses << rails_course + + practice = Practice.new( + title: 'Test Practice No Copy', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice:).destroy_all + + # Create completed learning + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text practice.title + assert_text 'なし' + end + + test 'shows product link when user has submitted work' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and practice for this test + category = Category.create!(name: 'Test Category Product Link', slug: 'test-category-product-link') + category.courses << rails_course + + practice = Practice.new( + title: 'Test Practice Product Link', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear existing learning and product to avoid uniqueness conflicts + Learning.where(user:, practice:).destroy_all + Product.where(user:, practice:).destroy_all + + # Create completed learning + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + # Create product for the practice + product = Product.create!( + user:, + practice:, + body: 'Test submission' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text practice.title + assert_text '修了' + assert_link '提出物', href: product_path(product) + end + + test "shows 'なし' when user has no product for practice" do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and practice for this test + category = Category.create!(name: 'Test Category No Product', slug: 'test-category-no-product') + category.courses << rails_course + + practice = Practice.new( + title: 'Test Practice No Product', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice:).destroy_all + + # Create completed learning but no product + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text practice.title + assert_text '修了' + within 'tr', text: practice.title do + assert_text 'なし' + end + end + + test 'shows Reスキル course status and product when user has completed copied practice' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Reskill Status', slug: 'test-category-reskill-status') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Reskill Status', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning and product to avoid uniqueness conflicts + Learning.where(user:, practice: [original_practice, copied_practice]).destroy_all + Product.where(user:, practice: [original_practice, copied_practice]).destroy_all + + # Create completed learning for original practice + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + # Create completed learning for copied practice (Reスキル) + Learning.create!( + user:, + practice: copied_practice, + status: 'complete' + ) + + # Create product for copied practice (Reスキル) + Product.create!( + user:, + practice: copied_practice, + body: 'Reスキル submission' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text original_practice.title + assert_text copied_practice.title + + # Check Reスキル status and product in the data row containing original practice ID + within 'tbody tr', text: original_practice.id.to_s do + assert_text '修了' # Status for Reスキル + assert_link '提出物' # Product link for Reスキル + end + end + + test "shows 未着手 for Reスキル when copied practice exists but user hasn't started" do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Reskill Unstarted', slug: 'test-category-reskill-unstarted') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Reskill Unstarted', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice: original_practice).destroy_all + + # Create completed learning for original practice only + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text original_practice.title + assert_text copied_practice.title + + # Check Reスキル shows 未着手 and なし for product + within 'tr', text: original_practice.title do + assert_text '未着手' + assert_text 'なし' + end + end + + test 'shows 進捗コピー button when copied practice exists' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Copy Button', slug: 'test-category-copy-button') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Copy Button', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice: original_practice).destroy_all + + # Create completed learning for original practice + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text original_practice.title + + # Look for link within the specific row + within('tbody tr', text: original_practice.title) do + assert_link '進捗コピー' + end + end + + test 'copies learning and product data when 進捗コピー button is clicked' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Copy Data', slug: 'test-category-copy-data') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Copy Data', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning and product to avoid uniqueness conflicts + Learning.where(user:, practice: [original_practice, copied_practice]).destroy_all + Product.where(user:, practice: [original_practice, copied_practice]).destroy_all + + # Create completed learning for original practice + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + # Create product for original practice + original_product = Product.create!( + user:, + practice: original_practice, + body: 'Original submission' + ) + + # Create check (合格) for original product + checker = users(:komagata) + Check.create!( + user: checker, + checkable: original_product + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + # Click the copy button with confirmation + within('tbody tr', text: original_practice.title) do + accept_confirm do + click_link '進捗コピー' + end + end + + assert_text '進捗をコピーしました。' + + # Verify learning was copied + copied_learning = Learning.find_by(user:, practice: copied_practice) + assert_not_nil copied_learning + assert_equal 'complete', copied_learning.status + + # Verify product was copied + copied_product = Product.find_by(user:, practice: copied_practice) + assert_not_nil copied_product + assert_equal 'Original submission', copied_product.body + + # Verify check (合格) was copied + copied_check = Check.find_by(checkable: copied_product) + assert_not_nil copied_check + assert_equal checker.id, copied_check.user_id + end + + test 'skips existing learning and product when they already exist' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and original practice for this test + category = Category.create!(name: 'Test Category Skip Existing', slug: 'test-category-skip-existing') + category.courses << rails_course + + original_practice = Practice.new( + title: 'Test Practice Skip Existing', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice.categories << category + original_practice.save! + + # Create copied practice with source_id pointing to original + timestamp = Time.current.to_i + copied_practice = Practice.new( + title: "#{original_practice.title} (Reスキル) #{timestamp}", + description: original_practice.description, + goal: original_practice.goal, + source_id: original_practice.id + ) + copied_practice.categories << category + copied_practice.save! + + # Clear existing learning and product to avoid uniqueness conflicts + Learning.where(user:, practice: [original_practice, copied_practice]).destroy_all + Product.where(user:, practice: [original_practice, copied_practice]).destroy_all + + # Create completed learning for original practice + Learning.create!( + user:, + practice: original_practice, + status: 'complete' + ) + + # Create product for original practice + Product.create!( + user:, + practice: original_practice, + body: 'Updated submission' + ) + + # Create existing learning and product for copied practice + existing_learning = Learning.create!( + user:, + practice: copied_practice, + status: 'started' + ) + + existing_product = Product.create!( + user:, + practice: copied_practice, + body: 'Old submission' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + # Click the copy button with confirmation + within('tbody tr', text: original_practice.title) do + accept_confirm do + click_link '進捗コピー' + end + end + + assert_text '進捗をコピーしました。' + + # Verify learning was NOT updated (skipped) + existing_learning.reload + assert_equal 'started', existing_learning.status + + # Verify product was NOT updated (skipped) + existing_product.reload + assert_equal 'Old submission', existing_product.body + end + + test 'admin can navigate back to users list' do + user = users(:kimura) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + click_on 'ユーザー一覧' + assert_current_path admin_users_path + end + + test 'shows 全ての進捗をコピー button when user has completed practices' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category and practice for this test + category = Category.create!(name: 'Test Category Bulk Copy Button', slug: 'test-category-bulk-copy-button') + category.courses << rails_course + + practice = Practice.new( + title: 'Test Practice Bulk Copy Button', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + practice.categories << category + practice.save! + + # Clear existing learning to avoid uniqueness conflicts + Learning.where(user:, practice:).destroy_all + + # Create a completed learning for the user + Learning.create!( + user:, + practice:, + status: 'complete' + ) + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + assert_text '全ての進捗をコピー' + assert_link '全ての進捗をコピー' + end + + test 'copies all completed practices when 全ての進捗をコピー button is clicked' do + user = users(:kimura) + rails_course = courses(:course1) # Railsエンジニアコース + + # Create dedicated category for this test + category = Category.create!(name: 'Test Category Bulk Copy All', slug: 'test-category-bulk-copy-all') + category.courses << rails_course + + # Create multiple original practices + original_practice1 = Practice.new( + title: 'Test Practice Bulk Copy All 1', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice1.categories << category + original_practice1.save! + + original_practice2 = Practice.new( + title: 'Test Practice Bulk Copy All 2', + description: 'Test practice for system test', + goal: 'Test goal', + submission: 'product' + ) + original_practice2.categories << category + original_practice2.save! + + # Create copied practices with source_id pointing to originals + timestamp = Time.current.to_i + copied_practice1 = Practice.new( + title: "#{original_practice1.title} (Reスキル) #{timestamp}", + description: original_practice1.description, + goal: original_practice1.goal, + source_id: original_practice1.id + ) + copied_practice1.categories << category + copied_practice1.save! + + copied_practice2 = Practice.new( + title: "#{original_practice2.title} (Reスキル) #{timestamp + 1}", + description: original_practice2.description, + goal: original_practice2.goal, + source_id: original_practice2.id + ) + copied_practice2.categories << category + copied_practice2.save! + + # Clear existing learning and product to avoid uniqueness conflicts + Learning.where(user:, practice: [original_practice1, original_practice2]).destroy_all + Product.where(user:, practice: [original_practice1, original_practice2]).destroy_all + + # Create completed learnings for original practices + Learning.create!(user:, practice: original_practice1, status: 'complete') + Learning.create!(user:, practice: original_practice2, status: 'complete') + + # Create products for original practices + Product.create!(user:, practice: original_practice1, body: 'Submission 1') + Product.create!(user:, practice: original_practice2, body: 'Submission 2') + + visit_with_auth admin_user_practice_progress_path(user), 'komagata' + + # Click the bulk copy button with confirmation + accept_confirm do + click_link '全ての進捗をコピー' + end + + assert_text '全ての進捗をコピーしました。' + + # Verify both learnings were copied + copied_learning1 = Learning.find_by(user:, practice: copied_practice1) + copied_learning2 = Learning.find_by(user:, practice: copied_practice2) + assert_not_nil copied_learning1 + assert_not_nil copied_learning2 + assert_equal 'complete', copied_learning1.status + assert_equal 'complete', copied_learning2.status + + # Verify both products were copied + copied_product1 = Product.find_by(user:, practice: copied_practice1) + copied_product2 = Product.find_by(user:, practice: copied_practice2) + assert_not_nil copied_product1 + assert_not_nil copied_product2 + assert_equal 'Submission 1', copied_product1.body + assert_equal 'Submission 2', copied_product2.body + end +end