diff --git a/Gemfile b/Gemfile index 020dbe491..d3d733e54 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ ruby '3.4.5' gem 'mysql2', '~> 0.5.7' gem 'sqlite3', '~> 1.4' # Alternative for development -gem 'puma', '~> 5.0' +gem 'puma', '~> 6.0' gem 'rails', '~> 8.0', '>= 8.0.1' gem 'mini_portile2', '~> 2.8' # Helps with native gem compilation gem 'observer' # Required for Ruby 3.4.5 compatibility with Rails 8.0 diff --git a/Gemfile.lock b/Gemfile.lock index 5e7341cb0..6fdf41173 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,7 @@ GEM term-ansicolor thor crass (1.0.6) + csv (3.3.5) danger (9.5.3) base64 (~> 0.2) claide (~> 1.0) @@ -128,6 +129,7 @@ GEM debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + delegate (0.4.0) diff-lcs (1.6.2) docile (1.4.1) domain_name (0.6.20240107) @@ -149,8 +151,11 @@ GEM faraday (>= 0.8) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.3.2) + faraday (~> 2.0) find_with_order (1.3.1) activerecord (>= 3) + forwardable (1.3.3) git (2.3.3) activesupport (>= 5.0) addressable (~> 2.8) @@ -197,10 +202,13 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) + mini_portile2 (2.8.9) minitest (5.25.5) mize (0.6.1) + monitor (0.2.0) msgpack (1.8.0) multi_json (1.17.0) + mutex_m (0.3.0) mysql2 (0.5.7) bigdecimal nap (1.1.0) @@ -217,7 +225,7 @@ GEM net-protocol netrc (0.11.0) nio4r (2.5.9) - nokogiri (1.15.2-aarch64-linux) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) @@ -230,6 +238,7 @@ GEM faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + ostruct (0.6.3) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -350,6 +359,7 @@ GEM addressable (>= 2.3.5) faraday (>= 0.17.3, < 3) securerandom (0.4.1) + set (1.1.2) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) simplecov (0.22.0) @@ -358,7 +368,10 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + singleton (0.3.0) spring (4.4.0) + sqlite3 (1.7.3) + mini_portile2 (~> 2.8.0) stringio (3.1.7) sync (0.5.0) term-ansicolor (1.11.3) @@ -398,18 +411,30 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10.0) bcrypt (~> 3.1.7) + bigdecimal bootsnap (>= 1.18.4) coveralls + csv danger database_cleaner-active_record + date debug + delegate factory_bot_rails faker + faraday-retry find_with_order + forwardable jwt (~> 2.7, >= 2.7.1) lingua - mysql2 (~> 0.5.5) + logger + mini_portile2 (~> 2.8) + monitor + mutex_m + mysql2 (~> 0.5.7) observer + ostruct + psych (~> 5.2) puma (~> 6.0) rack-cors rails (~> 8.0, >= 8.0.1) @@ -418,14 +443,19 @@ DEPENDENCIES rswag-specs rswag-ui rubocop + set shoulda-matchers simplecov simplecov_json_formatter + singleton spring + sqlite3 (~> 1.4) + timeout tzinfo-data + uri RUBY VERSION - ruby 3.2.7p253 + ruby 3.4.5p51 BUNDLED WITH 2.4.14 diff --git a/app/controllers/assignments_duties_controller.rb b/app/controllers/assignments_duties_controller.rb new file mode 100644 index 000000000..12e91fd70 --- /dev/null +++ b/app/controllers/assignments_duties_controller.rb @@ -0,0 +1,36 @@ +class AssignmentsDutiesController < ApplicationController + include AuthorizationHelper + + #before_action :authenticate_user! + before_action :action_allowed!, only: [:create, :destroy] + + # GET /assignments/:assignment_id/duties + def index + assignment = Assignment.find(params[:assignment_id]) + render json: assignment.duties + end + + # POST /assignments/:assignment_id/duties + def create + assignment = Assignment.find(params[:assignment_id]) + duty = Duty.find(params[:duty_id]) + assignment.duties << duty unless assignment.duties.include?(duty) + render json: assignment.duties + end + + # DELETE /assignments/:assignment_id/duties/:id + def destroy + assignment = Assignment.find(params[:assignment_id]) + duty = Duty.find(params[:id]) + assignment.duties.delete(duty) + head :no_content + end + + private + + def action_allowed! + unless current_user_has_instructor_privileges? + render json: { error: 'Not authorized' }, status: :forbidden + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/authorization_helper.rb b/app/controllers/concerns/authorization_helper.rb new file mode 100644 index 000000000..1edfe492e --- /dev/null +++ b/app/controllers/concerns/authorization_helper.rb @@ -0,0 +1,160 @@ +module AuthorizationHelper + # PUBLIC METHODS + + def current_user_has_super_admin_privileges? + current_user_has_privileges_of?('Super Administrator') + end + + def current_user_has_admin_privileges? + current_user_has_privileges_of?('Administrator') + end + + def current_user_has_instructor_privileges? + current_user_has_privileges_of?('Instructor') + end + + def current_user_has_ta_privileges? + current_user_has_privileges_of?('Teaching Assistant') + end + + def current_user_has_student_privileges? + current_user_has_privileges_of?('Student') + end + + def current_user_is_assignment_participant?(assignment_id) + current_user.present? && AssignmentParticipant.exists?(parent_id: assignment_id, user_id: current_user.id) + end + + def current_user_teaching_staff_of_assignment?(assignment_id) + assignment = Assignment.find_by(id: assignment_id) + current_user.present? && + (current_user_instructs_assignment?(assignment) || current_user_has_ta_mapping_for_assignment?(assignment)) + end + + def current_user_is_a?(role_name) + current_user.present? && current_user.role&.name == role_name + end + + def current_user_has_id?(id) + current_user.present? && current_user.id == id.to_i + end + + def current_user_created_bookmark_id?(bookmark_id) + current_user.present? && !bookmark_id.nil? && Bookmark.find_by(id: bookmark_id.to_i)&.user_id == current_user.id + end + + def given_user_can_submit?(user_id) + given_user_can?(user_id, 'submit') + end + + def given_user_can_review?(user_id) + given_user_can?(user_id, 'review') + end + + def given_user_can_take_quiz?(user_id) + given_user_can?(user_id, 'take_quiz') + end + + def given_user_can_read?(user_id) + given_user_can_take_quiz?(user_id) + end + + def response_edit_allowed?(map, user_id) + assignment = map.reviewer.assignment + if map.is_a?(ReviewResponseMap) + reviewee_team = AssignmentTeam.find(map.reviewee_id) + return current_user.present? && + ( + current_user_has_id?(user_id) || + reviewee_team.user?(current_user) || + current_user_has_admin_privileges? || + (current_user_is_a?('Instructor') && current_user_instructs_assignment?(assignment)) || + (current_user_is_a?('Teaching Assistant') && current_user_has_ta_mapping_for_assignment?(assignment)) + ) + end + current_user_has_id?(user_id) || + (current_user_is_a?('Instructor') && current_user_instructs_assignment?(assignment)) || + (assignment.course && current_user_is_a?('Teaching Assistant') && current_user_has_ta_mapping_for_assignment?(assignment)) + end + + def user_logged_in? + current_user.present? + end + + def current_user_ancestor_of?(user) + current_user.present? && user && current_user.recursively_parent_of(user) + end + + def current_user_instructs_assignment?(assignment) + current_user.present? && assignment && + (assignment.instructor_id == current_user.id || + (assignment.course_id && Course.find_by(id: assignment.course_id)&.instructor_id == current_user.id)) + end + + def current_user_has_ta_mapping_for_assignment?(assignment) + current_user.present? && assignment && assignment.course && + TaMapping.exists?(ta_id: current_user.id, course_id: assignment.course.id) + end + + def find_assignment_from_response_id(response_id) + response = Response.find_by(id: response_id.to_i) + return nil unless response + response_map = response.response_map + if response_map.assignment + response_map.assignment + else + find_assignment_from_response_id(response_map.reviewed_object_id) + end + end + + def find_assignment_instructor(assignment) + if assignment.course + Course.find_by(id: assignment.course.id)&.instructor + else + assignment.instructor + end + end + + def current_user_instructs_or_tas_course?(course_id) + return false unless current_user.present? && course_id.present? + + course = Course.find_by(id: course_id) + return false unless course + + # Check if the current user is the instructor or a TA for the course + course.instructor_id == current_user.id || TaMapping.exists?(ta_id: current_user.id, course_id: course.id) + end + + # def current_user_instructs_or_tas_duty?(duty) + # return false unless current_user.present? && duty.present? + + # # Check if the current user is the instructor of the duty + # return true if duty.instructor_id == current_user.id + + # # Check if the current user is a TA for the course associated with the duty + # course_id = Course.find_by(instructor_id: duty.instructor_id)&.id + # current_user_instructs_or_tas_course?(course_id) + # end + + # PRIVATE METHODS + private + + def current_user_has_privileges_of?(role_name) + current_user.present? && current_user.role&.all_privileges_of?(Role.find_by(name: role_name)) + end + + def given_user_can?(user_id, action) + participant = Participant.find_by(id: user_id) + return false if participant.nil? + case action + when 'submit' + participant.can_submit + when 'review' + participant.can_review + when 'take_quiz' + participant.can_take_quiz + else + raise "Did not recognize user action '#{action}'" + end + end +end \ No newline at end of file diff --git a/app/controllers/duties_controller.rb b/app/controllers/duties_controller.rb new file mode 100644 index 000000000..3dc46407e --- /dev/null +++ b/app/controllers/duties_controller.rb @@ -0,0 +1,90 @@ +class DutiesController < ApplicationController + include AuthorizationHelper + + #before_action :authenticate_user! + before_action :action_allowed!, only: [:index, :show, :create, :update, :destroy] + before_action :set_duty, only: [:show, :update, :destroy] + + # GET /duties + # /duties?mine=true to get only current user's duties + def index + duties = Duty.all + duties = duties.where('name LIKE ?', "%#{params[:search]}%") if params[:search].present? + if params[:mine].present? && current_user + duties = duties.where(instructor_id: current_user.id) + end + render json: duties + end + + # GET /duties/:id + def show + if @duty.private && @duty.instructor_id != current_user&.id + render json: { error: 'Not authorized to view this duty' }, status: :forbidden + else + render json: @duty + end + end + + # POST /duties + def create + @duty = Duty.new(duty_params) + @duty.instructor_id = current_user.id if current_user + if @duty.save + render json: @duty, status: :created + else + render json: @duty.errors, status: :unprocessable_entity + end + end + + # PATCH/PUT /duties/:id + def update + if @duty.instructor_id != current_user&.id + render json: { error: 'Not authorized to update this duty' }, status: :forbidden + elsif @duty.update(duty_params) + render json: @duty + else + render json: @duty.errors, status: :unprocessable_entity + end + end + + # DELETE /duties/:id + def destroy + if @duty.instructor_id != current_user&.id + render json: { error: 'Not authorized to delete this duty' }, status: :forbidden + else + @duty.destroy + head :no_content + end + end + + def accessible_duties + duties = Duty.where(private: false) # Start with all public duties + + if current_user.present? + # Add private duties created by the current user + duties = duties.or(Duty.where(instructor_id: current_user.id)) + + # Add private duties for courses where the user is the instructor or TA + course_ids = Course.all.select { |course| current_user_instructs_or_tas_course?(course.id) }.map(&:id) + duties = duties.or(Duty.joins("LEFT JOIN courses ON duties.instructor_id = courses.instructor_id").where("courses.id IN (?)", course_ids)) + end + + render json: duties + end + + private + + def set_duty + @duty = Duty.find(params[:id]) + end + + def duty_params + params.require(:duty).permit(:name, :instructor_id, :private) + end + + def action_allowed! + unless current_user_has_instructor_privileges? + render json: { error: 'Not authorized' }, status: :forbidden + end + end +end \ No newline at end of file diff --git a/app/controllers/join_team_requests_controller.rb b/app/controllers/join_team_requests_controller.rb index 2179e6b2c..334ece07a 100644 --- a/app/controllers/join_team_requests_controller.rb +++ b/app/controllers/join_team_requests_controller.rb @@ -15,7 +15,7 @@ def action_allowed? @current_user.student? end - # GET api/v1/join_team_requests + # GET /join_team_requests # gets a list of all the join team requests def index unless @current_user.administrator? @@ -25,13 +25,13 @@ def index render json: join_team_requests, status: :ok end - # GET api/v1join_team_requests/1 + # GET join_team_requests/1 # show the join team request that is passed into the route def show render json: @join_team_request, status: :ok end - # POST api/v1/join_team_requests + # POST /join_team_requests # Creates a new join team request def create join_team_request = JoinTeamRequest.new @@ -55,7 +55,7 @@ def create end end - # PATCH/PUT api/v1/join_team_requests/1 + # PATCH/PUT /join_team_requests/1 # Updates a join team request def update if @join_team_request.update(join_team_request_params) @@ -65,7 +65,7 @@ def update end end - # DELETE api/v1/join_team_requests/1 + # DELETE /join_team_requests/1 # delete a join team request def destroy if @join_team_request.destroy diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 56599cd83..052ee4e47 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -1,14 +1,13 @@ +# frozen_string_literal: true + class TeamsController < ApplicationController # Set the @team instance variable before executing actions except index and create before_action :set_team, except: [:index, :create] - # Validate team type only during team creation - before_action :validate_team_type, only: [:create] - # GET /teams # Fetches all teams and renders them using TeamSerializer def index - @teams = Team.all + @teams = Team.all.includes(teams_participants: :user) render json: @teams, each_serializer: TeamSerializer end @@ -16,15 +15,34 @@ def index # Shows a specific team based on ID def show render json: @team, serializer: TeamSerializer - rescue ActiveRecord::RecordNotFound - render json: { error: 'Team not found' }, status: :not_found end # POST /teams # Creates a new team associated with the current user def create - @team = Team.new(team_params) - @team.user = current_user + # Extract the team type and parameters + team_type_str = team_params[:type] + safe_params = team_params.except(:type) + + # 1. Whitelist the team type to prevent STI-related crashes + # We explicitly check for allowed types rather than passing untrusted input to .new + team_class = case team_type_str + when 'AssignmentTeam' then AssignmentTeam + when 'CourseTeam' then CourseTeam + when 'MentoredTeam' then MentoredTeam + end + + unless team_class + # Return 422 if the type is invalid or missing + render json: { errors: ["Invalid or missing team type: '#{team_type_str}'"] }, + status: :unprocessable_entity + return + end + + # 2. Instantiate the correct team subclass using the safe params + @team = team_class.new(safe_params) + + # 4. Save and render if @team.save render json: @team, serializer: TeamSerializer, status: :created else @@ -40,25 +58,32 @@ def members end # POST /teams/:id/members - # Adds a new member to the team unless it's already full + # Adds a new member to the team. def add_member - return render json: { errors: ['Team is full'] }, status: :unprocessable_entity if @team.full? - + # Find the user specified in the request. user = User.find(team_participant_params[:user_id]) - participant = Participant.find_by(user: user, parent_id: @team.parent_id) + + # Use polymorphic participant_class method instead of type checking + participant = @team.participant_class.find_by( + user_id: user.id, + parent_id: @team.parent_entity.id + ) unless participant - return render json: { error: 'Participant not found for this team context' }, status: :not_found + return render json: { + errors: ["#{user.name} is not a participant in this #{@team.context_label}."] + }, status: :unprocessable_entity end - teams_participants = @team.teams_participants.build(participant: participant, user: participant.user) + # Delegate the add operation to the Team model with the found participant. + result = @team.add_member(participant) - if teams_participants.save - render json: participant.user, serializer: UserSerializer, status: :created + if result[:success] + render json: user, serializer: UserSerializer, status: :created else - render json: { errors: teams_participants.errors.full_messages }, status: :unprocessable_entity + render json: { errors: [result[:error]] }, status: :unprocessable_entity end - rescue ActiveRecord::RecordNotFound + rescue ActiveRecord::RecordNotFound => e render json: { error: 'User not found' }, status: :not_found end @@ -66,7 +91,7 @@ def add_member # Removes a member from the team based on user ID def remove_member user = User.find(params[:user_id]) - participant = Participant.find_by(user: user, parent_id: @team.parent_id) + participant = @team.participant_class.find_by(user_id: user.id, parent_id: @team.parent_entity.id) tp = @team.teams_participants.find_by(participant: participant) if tp @@ -79,11 +104,6 @@ def remove_member render json: { error: 'User not found' }, status: :not_found end - # Placeholder method to get current user (can be replaced by actual auth logic) - def current_user - @current_user - end - private # Finds the team by ID and assigns to @team, else renders not found @@ -95,20 +115,11 @@ def set_team # Whitelists the parameters allowed for team creation/updation def team_params - params.require(:team).permit(:name, :max_team_size, :type, :assignment_id) + params.require(:team).permit(:name, :type, :parent_id) end # Whitelists parameters required to add a team member def team_participant_params params.require(:team_participant).permit(:user_id) end - - # Validates the team type before team creation to ensure it's among allowed types - def validate_team_type - return unless params[:team] && params[:team][:type] - valid_types = ['CourseTeam', 'AssignmentTeam', 'MentoredTeam'] - unless valid_types.include?(params[:team][:type]) - render json: { error: 'Invalid team type' }, status: :unprocessable_entity - end - end -end +end diff --git a/app/helpers/team_operations_helper.rb b/app/helpers/team_operations_helper.rb deleted file mode 100644 index 63ade7dab..000000000 --- a/app/helpers/team_operations_helper.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module TeamOperationsHelper - # Validates whether the given user can be part of the specified team based on parent context - def self.validate_team_membership(team, user) - team.has_member?(user) - end - - # Validates if the given user can be assigned as a mentor to the team - def self.validate_mentor_assignment(team, user) - team.is_a?(MentoredTeam) && user.mentor? - end - - # Copies all members from the source team to the target team - def self.copy_team_members(source_team, target_team) - source_team.users.each do |user| - target_team.add_member(user) - end - end - - # Returns a hash of basic statistics about the given team - def self.team_stats(team) - { - size: team.participants.size, - max_size: team.max_team_size, - is_full: team.full?, - is_empty: team.participants.empty? - } - end -end diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 144cd5369..1824b4bb0 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -2,7 +2,7 @@ class Assignment < ApplicationRecord include MetricHelper - has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'parent_id', dependent: :destroy + has_many :participants, class_name: 'AssignmentParticipant', foreign_key: 'parent_id', inverse_of: :assignment, dependent: :destroy has_many :users, through: :participants, inverse_of: :assignment has_many :teams, class_name: 'AssignmentTeam', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :assignment has_many :invitations, class_name: 'Invitation', foreign_key: 'assignment_id', dependent: :destroy # , inverse_of: :assignment @@ -11,9 +11,12 @@ class Assignment < ApplicationRecord has_many :response_maps, foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :review_mappings, class_name: 'ReviewResponseMap', foreign_key: 'reviewed_object_id', dependent: :destroy, inverse_of: :assignment has_many :sign_up_topics , class_name: 'SignUpTopic', foreign_key: 'assignment_id', dependent: :destroy - has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy + has_many :due_dates,as: :parent, class_name: 'DueDate', dependent: :destroy, as: :parent + has_many :duties, dependent: :destroy belongs_to :course, optional: true belongs_to :instructor, class_name: 'User', inverse_of: :assignments + has_many :assignments_duties, dependent: :destroy + has_many :duties, through: :assignments_duties #This method return the value of the has_badge field for the given assignment object. attr_accessor :title, :description, :has_badge, :enable_pair_programming, :is_calibrated, :staggered_deadline diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 4edeee5c6..bd066ce57 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -1,19 +1,10 @@ # frozen_string_literal: true class AssignmentParticipant < Participant - belongs_to :user + belongs_to :duty, optional: true validates :handle, presence: true - - def set_handle - self.handle = if user.handle.nil? || (user.handle == '') - user.name - elsif Participant.exists?(assignment_id: assignment.id, handle: user.handle) - user.name - else - user.handle - end - self.save + def parent_context + assignment end - end diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 77a99cfd4..ebddf97e3 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -4,40 +4,56 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' + # === Implementation of Abstract Methods === + + def parent_entity + assignment + end + + def participant_class + AssignmentParticipant + end + + def context_label + 'assignment' + end + + # === Overridden Methods === + + # Override max_team_size to pull from assignment + def max_team_size + assignment&.max_team_size + end + + # === Copying Logic === # Copies the current assignment team to a course team - # - Creates a new CourseTeam with a modified name - # - Copies team members from the assignment team to the course team def copy_to_course_team(course) course_team = CourseTeam.new( - name: "#{name} (Course)", # Appends "(Course)" to the team name - max_team_size: max_team_size, # Preserves original max team size - course: course # Associates new team with the given course + name: name, # Keep the same name by default + parent_id: course.id ) + if course_team.save - team_members.each do |member| - course_team.add_member(member.user) # Copies each member to the new course team - end + # Use the protected method from the base Team class + copy_members_to_team(course_team) end - course_team # Returns the newly created course team object - end - protected - - # Validates if a user is eligible to join the team - # - Checks whether the user is a participant of the associated assignment - def validate_membership(user) - # Ensure user is enrolled in the assignment by checking AssignmentParticipant - assignment.participants.exists?(user: user) + course_team end - private + # Copies the current assignment team to another assignment team + def copy_to_assignment_team(target_assignment) + new_team = self.class.new( # Use self.class to support MentoredTeam + name: name, # Keep the same name by default + parent_id: target_assignment.id + ) - - # Validates that the team is an AssignmentTeam or a subclass (e.g., MentoredTeam) - def validate_assignment_team_type - unless self.kind_of?(AssignmentTeam) - errors.add(:type, 'must be an AssignmentTeam or its subclass') + if new_team.save + # Use the protected method from the base Team class + copy_members_to_team(new_team) end + + new_team end -end +end diff --git a/app/models/assignments_duty.rb b/app/models/assignments_duty.rb new file mode 100644 index 000000000..c14d635bb --- /dev/null +++ b/app/models/assignments_duty.rb @@ -0,0 +1,4 @@ +class AssignmentsDuty < ApplicationRecord + belongs_to :assignment + belongs_to :duty +end diff --git a/app/models/course_participant.rb b/app/models/course_participant.rb index 59d83a842..08d6f6f1d 100644 --- a/app/models/course_participant.rb +++ b/app/models/course_participant.rb @@ -1,25 +1,9 @@ # frozen_string_literal: true class CourseParticipant < Participant - belongs_to :user validates :handle, presence: true - def set_handle - # normalize the user’s preferred handle - desired = user.handle.to_s.strip - - self.handle = - if desired.empty? - # no handle on the user, fall back to their name - user.name - elsif CourseParticipant.exists?(parent_id: course.id, handle: desired) - # someone else in this course already has that handle - user.name - else - # it’s unique, so use it - desired - end - - save + def parent_context + course end end diff --git a/app/models/course_team.rb b/app/models/course_team.rb index 91ae746ac..aa22c2580 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -1,46 +1,52 @@ # frozen_string_literal: true class CourseTeam < Team - #Each course team must belong to a course + # Each course team must belong to a course belongs_to :course, class_name: 'Course', foreign_key: 'parent_id' - #adds members to the course team post validation - def add_member(user) - # return false unless validate_membership(user) - super(user) + # === Implementation of Abstract Methods === + + def parent_entity + course + end + + def participant_class + CourseParticipant end + def context_label + 'course' + end + + # === Copying Logic === + # Copies the current course team to an assignment team - # - Creates a new AssignmentTeam with a modified name - # - Copies team members from the assignment team to the course team def copy_to_assignment_team(assignment) assignment_team = AssignmentTeam.new( - name: "#{name} (Assignment)", # Appends "(Assignment)" to the team name - max_team_size: max_team_size, # Preserves original max team size - assignment: assignment # Associates the course team with an assignment + name: name, # Keep the same name by default + parent_id: assignment.id ) + if assignment_team.save - team_members.each do |member| - assignment_team.add_member(member.user) # Copies each member to the new assignment team - end + # Use the protected method from the base Team class + copy_members_to_team(assignment_team) end - assignment_team # Returns the newly created assignment team object - end - - protected - def validate_membership(user) - # Check if user is enrolled in any assignment in the course - course.assignments.any? { |assignment| assignment.participants.exists?(user: user) } + assignment_team end - private + # Copies the current course team to another course team + def copy_to_course_team(target_course) + new_team = CourseTeam.new( + name: name, # Keep the same name by default + parent_id: target_course.id + ) - # Custom validation method for team type - # - Ensures the type is 'CourseTeam' - def type_must_be_course_team - unless self.kind_of?(CourseTeam) - errors.add(:type, 'must be CourseTeam') + if new_team.save + # Use the protected method from the base Team class + copy_members_to_team(new_team) end + + new_team end -end +end diff --git a/app/models/duty.rb b/app/models/duty.rb new file mode 100644 index 000000000..5c62cfc44 --- /dev/null +++ b/app/models/duty.rb @@ -0,0 +1,9 @@ +class Duty < ApplicationRecord + validates :name, presence: true, uniqueness: true + + has_many :assignments_duties, dependent: :destroy + has_many :assignments, through: :assignments_duties + has_many :assignment_participants + has_many :teammate_review_questionnaires + belongs_to :instructor, class_name: 'User' +end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index a6771a6a5..ae7aaeddc 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -1,47 +1,111 @@ # frozen_string_literal: true class MentoredTeam < AssignmentTeam - belongs_to :mentor, class_name: 'User' + # The mentor is determined by a participant on this team having a Duty - #Validates the presence of a mentor in the team - validates :mentor, presence: true + # This validation must be on: :update + # and should only run if mentor duties exist for the assignment. + validate :mentor_must_be_present, on: :update - # Custom validation to ensure the team type is 'MentoredTeam' - validate :type_must_be_mentored_team + # === Public API for Mentors === - # Validates the role of the mentor - validate :mentor_must_have_mentor_role - - # adds members to the team who are not mentors - def add_member(user) - return false if user == mentor - super(user) + # Return the mentor User (or nil) + def mentor + mentor_participant&.user end + alias_method :mentor_user, :mentor - # Assigning a user as mentor of the team - # Validates if user has the role and permission to be a mentor + # Separate method for assigning mentors def assign_mentor(user) - return false unless user.role&.name&.downcase&.include?('mentor') - self.mentor = user - save + duty = find_mentor_duty + return { success: false, error: 'No mentor duty found for this assignment.' } unless duty + + # Find or create the participant record for this user in this assignment + participant = assignment.participants.find_or_create_by!( + user_id: user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant' + ) do |p| + # Set handle only on creation + p.handle = (user.try(:handle).presence || user.name) + end + + # Assign the mentor duty + participant.update!(duty: duty) + + # Add the participant to this team + unless participants.exists?(id: participant.id) + teams_participants.create!( + participant_id: participant.id, + team_id: id, + user_id: participant.user_id + ) + end + + { success: true } + rescue ActiveRecord::RecordInvalid => e + Rails.logger.debug "MentoredTeam#assign_mentor failed: #{e.record.errors.full_messages.join(', ')}" + { success: false, error: e.record.errors.full_messages.join(', ') } end # Unassigns mentor from team def remove_mentor - self.mentor = nil - save + mp = mentor_participant + return { success: false, error: 'No mentor found on this team.' } unless mp + + if mp.update(duty: nil) + { success: true } + else + { success: false, error: mp.errors.full_messages.join(', ') } + end + end + + # === Overridden Methods === + + # Deleted the `add_member` override. + # We now use the `validate_participant_for_add` hook from the base class. + # This fixes the LSP violation. + + # Override to account for mentor not counting toward team size limit + def full? + return false unless max_team_size + + # Don't count the mentor toward the team size limit + non_mentor_count = participants.count - (mentor_participant ? 1 : 0) + non_mentor_count >= max_team_size + end + + protected + + # This is the new hook method that `Team#add_member` calls. + # It allows MentoredTeam to add specific validation without breaking LSP. + def validate_participant_for_add(participant) + if participant_is_mentor?(participant) + return { success: false, error: "Mentors cannot be added as regular members. Use 'assign_mentor' instead." } + end + { success: true } end private - # Check if the team type is 'MentoredTeam' - def type_must_be_mentored_team - errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' + def participant_is_mentor?(participant) + participant.duty&.name&.downcase&.include?('mentor') end - # Check if the user has been given the role 'mentor' - def mentor_must_have_mentor_role - return unless mentor - errors.add(:mentor, 'must have mentor role') unless mentor.role&.name&.downcase&.include?('mentor') + def find_mentor_duty + return nil unless assignment&.persisted? + assignment.duties.detect { |d| d.name.to_s.downcase.include?('mentor') } + end + + def mentor_participant + # Use find on the association to avoid loading all participants if possible + participants.find { |p| participant_is_mentor?(p) } + end + + def mentor_must_be_present + # Only validate if the assignment (and thus duties) exist + return unless assignment&.duties&.any? { |d| d.name.to_s.downcase.include?('mentor') } + + errors.add(:base, 'a mentor must be present') unless mentor_participant.present? end -end +end diff --git a/app/models/participant.rb b/app/models/participant.rb index 94872b3db..e3b1e6d70 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -4,18 +4,54 @@ class Participant < ApplicationRecord # Associations belongs_to :user has_many :join_team_requests, dependent: :destroy - belongs_to :team, optional: true + + # Removed `belongs_to :team` + # This association is incorrect. A participant belongs to a team + # *through* the `teams_participants` join table, not directly. + # The `has_many :participants, through: :teams_participants` on Team + # and `has_many :teams, through: :teams_participants` on Participant + # (if added) would be the correct way. + + has_many :teams_participants, dependent: :destroy + has_many :teams, through: :teams_participants + belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id', optional: true, inverse_of: :participants belongs_to :course, class_name: 'Course', foreign_key: 'parent_id', optional: true, inverse_of: :participants + belongs_to :duty, optional: true # Validations validates :user_id, presence: true validates :parent_id, presence: true - validates :type, presence: true, inclusion: { in: %w[AssignmentParticipant CourseParticipant], message: "must be either 'AssignmentParticipant' or 'CourseParticipant'" } + validates :type, presence: true, inclusion: { + in: %w[AssignmentParticipant CourseParticipant], + message: "must be either 'AssignmentParticipant' or 'CourseParticipant'" + } # Methods def fullname - user.fullname + user.full_name end + # Abstract method for polymorphic use (e.g., in Team#validate_participant_type) + def parent_context + raise NotImplementedError, "#{self.class} must implement #parent_context" + end + + # This method is a good example of the Template Method Pattern. + # It's defined in the base class and relies on `parent_context` + # from the subclasses. + def set_handle + desired = user.handle.to_s.strip + # Use polymorphic parent_context + context_participants = self.class.where(parent_id: parent_context.id) + + self.handle = if desired.blank? + user.name + elsif context_participants.exists?(handle: desired) + user.name + else + desired + end + save + end end diff --git a/app/models/team.rb b/app/models/team.rb index 08ce2b5d2..b6c9e1d56 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -1,118 +1,137 @@ # frozen_string_literal: true class Team < ApplicationRecord - # Core associations has_many :signed_up_teams, dependent: :destroy - has_many :teams_users, dependent: :destroy + has_many :teams_users, dependent: :destroy has_many :teams_participants, dependent: :destroy has_many :users, through: :teams_participants has_many :participants, through: :teams_participants - - # The team is either an AssignmentTeam or a CourseTeam - belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id', optional: true - belongs_to :course, class_name: 'Course', foreign_key: 'parent_id', optional: true belongs_to :user, optional: true # Team creator - - attr_accessor :max_participants + validates :parent_id, presence: true - validates :type, presence: true, inclusion: { in: %w[AssignmentTeam CourseTeam MentoredTeam], message: "must be 'Assignment' or 'Course' or 'Mentor'" } + validates :type, presence: true, inclusion: { + in: %w[AssignmentTeam CourseTeam MentoredTeam], + message: "must be 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'" + } + + attr_accessor :max_participants + + # === Abstract Methods (must be implemented by subclasses) === + + # Returns the parent object (e.g., Course or Assignment) + def parent_entity + raise NotImplementedError, "#{self.class} must implement #parent_entity" + end + + # Returns the required Participant class (e.g., CourseParticipant) + def participant_class + raise NotImplementedError, "#{self.class} must implement #participant_class" + end + + # Returns a string label for the context (e.g., 'course') + def context_label + raise NotImplementedError, "#{self.class} must implement #context_label" + end + + # === Core API === + + def max_team_size + nil # Default: no limit (overridden by AssignmentTeam) + end def has_member?(user) participants.exists?(user_id: user.id) end - - def full? - current_size = participants.count - # assignment teams use the column max_team_size - if is_a?(AssignmentTeam) && assignment&.max_team_size - return current_size >= assignment.max_team_size - end - - # course teams never fill up by default - false + def full? + return false unless max_team_size + # Note: MentoredTeam overrides this to exclude the mentor + participants.count >= max_team_size end - # Checks if the given participant is already on any team for the associated assignment or course. + # Uses polymorphic parent_entity instead of type checking def participant_on_team?(participant) - # pick the correct “scope” (assignment or course) based on this team’s class - scope = - if is_a?(AssignmentTeam) - assignment - elsif is_a?(CourseTeam) - course - end - - return false unless scope + return false unless parent_entity - # “scope.teams” includes this team itself plus any sibling teams; - # check whether any of those teams already has this participant - scope.teams.any? { |team| team.participants.include?(participant) } + TeamsParticipant + .where(participant_id: participant.id, team_id: parent_entity.teams.select(:id)) + .exists? end - # Adds participant in the team - def add_member(participant_or_user) - participant = - if participant_or_user.is_a?(AssignmentParticipant) || participant_or_user.is_a?(CourseParticipant) - participant_or_user - elsif participant_or_user.is_a?(User) - participant_type = is_a?(AssignmentTeam) ? AssignmentParticipant : CourseParticipant - participant_type.find_by(user_id: participant_or_user.id, parent_id: parent_id) - else - nil - end + # Adds a participant to the team. + # This is a Template Method, customizable via hooks. + def add_member(participant) + eligibility = can_participant_join_team?(participant) + return eligibility unless eligibility[:success] - # If participant wasn't found or built correctly - return { success: false, error: "#{participant_or_user.name} is not a participant in this #{is_a?(AssignmentTeam) ? 'assignment' : 'course'}" } if participant.nil? + return { success: false, error: 'Team is at full capacity.' } if full? - return { success: false, error: "Participant already on the team" } if participants.exists?(id: participant.id) - return { success: false, error: "Unable to add participant: team is at full capacity." } if full? + validation_result = validate_participant_type(participant) + return validation_result unless validation_result[:success] - team_participant = TeamsParticipant.create( - participant_id: participant.id, - team_id: id, - user_id: participant.user_id - ) + # Added hook for subclass-specific validation (e.g., MentoredTeam) + hook_result = validate_participant_for_add(participant) + return hook_result unless hook_result[:success] - if team_participant.persisted? - { success: true } - else - { success: false, error: team_participant.errors.full_messages.join(', ') } - end - rescue StandardError => e - { success: false, error: e.message } + teams_participants.create!(participant: participant, user: participant.user) + { success: true } + rescue ActiveRecord::RecordInvalid => e + { success: false, error: e.record.errors.full_messages.join(', ') } end - # Determines whether a given participant is eligible to join the team. + # Uses polymorphic methods instead of type checks def can_participant_join_team?(participant) - # figure out whether we’re in an Assignment or a Course context - scope, participant_type, label = - if is_a?(AssignmentTeam) - [assignment, AssignmentParticipant, "assignment"] - elsif is_a?(CourseTeam) - [course, CourseParticipant, "course"] - else - return { success: false, error: "Team must belong to Assignment or Course" } - end - - # Check if the user is already part of any team for this assignment or course if participant_on_team?(participant) - return { success: false, error: "This user is already assigned to a team for this #{label}" } + return { success: false, error: "This user is already assigned to a team for this #{context_label}" } end - # Check if the user is a registered participant for this assignment or course - registered = participant_type.find_by( - user_id: participant.user_id, - parent_id: scope.id - ) + { success: true } + end + + def size + participants.count + end + + def empty? + participants.empty? + end + + protected + + # Copies members from this team to a target_team. + def copy_members_to_team(target_team) + target_parent = target_team.parent_entity - unless registered - return { success: false, error: "#{participant.user.name} is not a participant in this #{label}" } + participants.each do |source_participant| + # Find or create the corresponding participant in the target context + target_participant = target_team.participant_class.find_or_create_by!( + user_id: source_participant.user_id, + parent_id: target_parent.id + ) do |p| + p.handle = source_participant.handle + end + + # Use the public add_member API to ensure all rules are followed + target_team.add_member(target_participant) end + end - # All checks passed; participant is eligible to join the team + # This ensures a participant's context (Course) matches the team's (Course). + def validate_participant_type(participant) + unless participant.parent_context == parent_entity + return { + success: false, + error: "Participant belongs to #{participant.parent_context.name} (a #{participant.parent_context.class}), " \ + "but this team belongs to #{parent_entity.name} (a #{parent_entity.class})." + } + end { success: true } + end + # Added hook for the Template Method Pattern. + # Subclasses can override this to add custom validation to add_member. + def validate_participant_for_add(_participant) + { success: true } # Default: no extra validation end end diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index d58d33354..03ebd6f70 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,20 +1,27 @@ # frozen_string_literal: true class TeamSerializer < ActiveModel::Serializer - attributes :id, :name, :max_team_size, :type, :team_size, :assignment_id + attributes :id, :name, :type, :team_size, :max_team_size, :parent_id, :parent_type has_many :users, serializer: UserSerializer def users - # Use teams_participants association to get users object.teams_participants.includes(:user).map(&:user) end def team_size - object.teams_participants.count + object.size end - def assignment_id - # Return parent_id for AssignmentTeam, nil for CourseTeam - object.is_a?(AssignmentTeam) ? object.parent_id : nil + # Use polymorphic method instead of type checking + def max_team_size + object.max_team_size end -end + + def parent_id + object.parent_id + end + + def parent_type + object.context_label + end +end diff --git a/config/database.yml b/config/database.yml index 7b9674459..59de540eb 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,9 +13,9 @@ default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: dev + username: root password: expertiza - host: <%= ENV.fetch("DB_HOST", "db") %> + host: <%= ENV.fetch("DB_HOST", "localhost") %> port: <%= ENV.fetch("DB_PORT", "3306") %> diff --git a/config/routes.rb b/config/routes.rb index b77a95f63..1e7608377 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,10 @@ # Defines the root path route ("/") # root "articles#index" post '/login', to: 'authentication#login' + resources :duties + resources :assignments do + resources :duties, controller: 'assignments_duties', only: [:create, :destroy] + end resources :institutions resources :roles do collection do diff --git a/db/migrate/20231102173152_create_teams.rb b/db/migrate/20231102173152_create_teams.rb index ab6a5ce3a..c88e04767 100644 --- a/db/migrate/20231102173152_create_teams.rb +++ b/db/migrate/20231102173152_create_teams.rb @@ -3,15 +3,13 @@ class CreateTeams < ActiveRecord::Migration[8.0] def change create_table :teams do |t| - t.string :name, null: false - t.string :type, null: false - t.integer :max_team_size, null: false, default: 5 - t.references :user, foreign_key: true - t.references :mentor, foreign_key: { to_table: :users } + t.string :name, null: false + t.string :type, null: false # STI class name + t.integer :parent_id, null: false # points to assignment, course, etc. t.timestamps end add_index :teams, :type end -end +end diff --git a/db/migrate/20231129023417_add_assignment_to_teams.rb b/db/migrate/20231129023417_add_assignment_to_teams.rb deleted file mode 100644 index 855eea007..000000000 --- a/db/migrate/20231129023417_add_assignment_to_teams.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class AddAssignmentToTeams < ActiveRecord::Migration[7.0] - def change - add_reference :teams, :assignment, null: false, foreign_key: true - end -end diff --git a/db/migrate/20240420070000_make_assignment_id_optional.rb b/db/migrate/20240420070000_make_assignment_id_optional.rb deleted file mode 100644 index 414aeb07b..000000000 --- a/db/migrate/20240420070000_make_assignment_id_optional.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class MakeAssignmentIdOptional < ActiveRecord::Migration[8.0] - def change - change_column_null :teams, :assignment_id, true - end -end diff --git a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb b/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb deleted file mode 100644 index cd2fb7094..000000000 --- a/db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ChangeToPolymorphicAssociationInTeams < ActiveRecord::Migration[8.0] - def change - # Remove old assignment reference (course reference doesn't exist) - remove_reference :teams, :assignment, foreign_key: true - - # Add polymorphic association fields (type column already exists) - add_column :teams, :parent_id, :integer, null: false - end -end diff --git a/db/migrate/20250803165255_create_duties.rb b/db/migrate/20250803165255_create_duties.rb new file mode 100644 index 000000000..e8846bbd2 --- /dev/null +++ b/db/migrate/20250803165255_create_duties.rb @@ -0,0 +1,9 @@ +class CreateDuties < ActiveRecord::Migration[8.0] + def change + create_table :duties do |t| + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20250803170802_create_assignments_duties.rb b/db/migrate/20250803170802_create_assignments_duties.rb new file mode 100644 index 000000000..1cd9ea92e --- /dev/null +++ b/db/migrate/20250803170802_create_assignments_duties.rb @@ -0,0 +1,10 @@ +class CreateAssignmentsDuties < ActiveRecord::Migration[8.0] + def change + create_table :assignments_duties do |t| + t.references :assignment, null: false, foreign_key: true + t.references :duty, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250803170832_add_duty_to_participants.rb b/db/migrate/20250803170832_add_duty_to_participants.rb new file mode 100644 index 000000000..7701cd7a8 --- /dev/null +++ b/db/migrate/20250803170832_add_duty_to_participants.rb @@ -0,0 +1,5 @@ +class AddDutyToParticipants < ActiveRecord::Migration[8.0] + def change + add_reference :participants, :duty, null: true, foreign_key: true + end +end diff --git a/db/migrate/20250807224208_add_instructor_id_and_private_to_duties.rb b/db/migrate/20250807224208_add_instructor_id_and_private_to_duties.rb new file mode 100644 index 000000000..da73a1aa1 --- /dev/null +++ b/db/migrate/20250807224208_add_instructor_id_and_private_to_duties.rb @@ -0,0 +1,8 @@ +class AddInstructorIdAndPrivateToDuties < ActiveRecord::Migration[8.0] + def change + add_column :duties, :instructor_id, :bigint + add_column :duties, :private, :boolean, default: false + add_foreign_key :duties, :users, column: :instructor_id + add_index :duties, :instructor_id + end +end diff --git a/db/migrate/20250819160547_change_teams_type_not_null.rb b/db/migrate/20250819160547_change_teams_type_not_null.rb new file mode 100644 index 000000000..e24817d61 --- /dev/null +++ b/db/migrate/20250819160547_change_teams_type_not_null.rb @@ -0,0 +1,5 @@ +class ChangeTeamsTypeNotNull < ActiveRecord::Migration[8.0] + def change + change_column_null :teams, :type, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 462029322..570e3ba75 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_04_27_014225) do +ActiveRecord::Schema[8.0].define(version: 2025_08_19_160547) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -106,6 +106,15 @@ t.index ["instructor_id"], name: "index_assignments_on_instructor_id" end + create_table "assignments_duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "assignment_id", null: false + t.bigint "duty_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_assignments_duties_on_assignment_id" + t.index ["duty_id"], name: "index_assignments_duties_on_duty_id" + end + create_table "bookmark_ratings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "bookmark_id" t.integer "user_id" @@ -162,6 +171,15 @@ t.index ["parent_type", "parent_id"], name: "index_due_dates_on_parent" end + create_table "duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "instructor_id" + t.boolean "private", default: false + t.index ["instructor_id"], name: "index_duties_on_instructor_id" + end + create_table "institutions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -232,6 +250,8 @@ t.string "authorization" t.integer "parent_id", null: false t.string "type", null: false + t.bigint "duty_id" + t.index ["duty_id"], name: "index_participants_on_duty_id" t.index ["join_team_request_id"], name: "index_participants_on_join_team_request_id" t.index ["team_id"], name: "index_participants_on_team_id" t.index ["user_id"], name: "fk_participant_users" @@ -345,17 +365,11 @@ end create_table "teams", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.string "name", null: false - t.string "type", null: false - t.integer "max_team_size", default: 5, null: false - t.bigint "user_id" - t.bigint "mentor_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "name", null: false + t.string "type", null: false t.integer "parent_id", null: false - t.index ["mentor_id"], name: "index_teams_on_mentor_id" - t.index ["type"], name: "index_teams_on_type" - t.index ["user_id"], name: "index_teams_on_user_id" end create_table "teams_participants", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -410,9 +424,13 @@ add_foreign_key "account_requests", "roles" add_foreign_key "assignments", "courses" add_foreign_key "assignments", "users", column: "instructor_id" + add_foreign_key "assignments_duties", "assignments" + add_foreign_key "assignments_duties", "duties" add_foreign_key "courses", "institutions" add_foreign_key "courses", "users", column: "instructor_id" + add_foreign_key "duties", "users", column: "instructor_id" add_foreign_key "items", "questionnaires" + add_foreign_key "participants", "duties" add_foreign_key "participants", "join_team_requests" add_foreign_key "participants", "teams" add_foreign_key "participants", "users" @@ -423,8 +441,6 @@ add_foreign_key "signed_up_teams", "teams" add_foreign_key "ta_mappings", "courses" add_foreign_key "ta_mappings", "users" - add_foreign_key "teams", "users" - add_foreign_key "teams", "users", column: "mentor_id" add_foreign_key "teams_participants", "participants" add_foreign_key "teams_participants", "teams" add_foreign_key "teams_users", "teams" diff --git a/db/seeds.rb b/db/seeds.rb index 9828977ea..04761ce42 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,7 +5,19 @@ inst_id = Institution.create!( name: 'North Carolina State University', ).id - + + roles = {} + + roles[:super_admin] = Role.find_or_create_by!(name: "Super Administrator", parent_id: nil) + + roles[:admin] = Role.find_or_create_by!(name: "Administrator", parent_id: roles[:super_admin].id) + + roles[:instructor] = Role.find_or_create_by!(name: "Instructor", parent_id: roles[:admin].id) + + roles[:ta] = Role.find_or_create_by!(name: "Teaching Assistant", parent_id: roles[:instructor].id) + + roles[:student] = Role.find_or_create_by!(name: "Student", parent_id: roles[:ta].id) + # Create an admin user User.create!( name: 'admin', @@ -126,5 +138,6 @@ rescue ActiveRecord::RecordInvalid => e + puts e.message puts 'The db has already been seeded' end diff --git a/spec/factories/participants.rb b/spec/factories/participants.rb index 6cccb71e8..90ad5cf55 100644 --- a/spec/factories/participants.rb +++ b/spec/factories/participants.rb @@ -1,22 +1,41 @@ # frozen_string_literal: true FactoryBot.define do - factory :participant do - association :user - association :assignment, factory: :assignment - end - factory :assignment_participant, class: 'AssignmentParticipant' do + type { 'AssignmentParticipant' } association :user - association :assignment, factory: :assignment + association :assignment parent_id { assignment.id } handle { user.name } + + # Trait to make this participant a mentor + trait :with_mentor_duty do + # We move all logic into the `after(:build)` block + # to ensure we have access to the participant's assignment. + + after(:build) do |participant, evaluator| + # 1. Find or create the duty, now WITH the required instructor + mentor_duty = Duty.find_or_create_by!( + name: 'mentor', + instructor: participant.assignment.instructor + ) + + # 2. Assign the duty to the participant + participant.duty = mentor_duty + + # 3. Ensure the duty is associated with the assignment + unless participant.assignment.duties.include?(mentor_duty) + participant.assignment.duties << mentor_duty + end + end + end end factory :course_participant, class: 'CourseParticipant' do + type { 'CourseParticipant' } association :user - association :course, factory: :course + association :course parent_id { course.id } handle { user.name } end -end +end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 6c854b9c0..d10b7cf9a 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -1,76 +1,53 @@ # frozen_string_literal: true FactoryBot.define do - factory :team do - name { Faker::Team.name } - type { 'CourseTeam' } - max_team_size { 5 } - association :user, factory: :user - - trait :with_assignment do - association :assignment, factory: :assignment - end - end + # NOTE: The base :team factory has been removed. + # Teams are an abstract class and should not be instantiated directly. + # Please use :course_team, :assignment_team, or :mentored_team. factory :course_team, class: 'CourseTeam' do name { Faker::Team.name } - type { 'CourseTeam' } - max_team_size { 5 } - association :user, factory: :user - association :course, factory: :course + association :course end factory :assignment_team, class: 'AssignmentTeam' do name { Faker::Team.name } - type { 'AssignmentTeam' } - max_team_size { 5 } - association :user, factory: :user - + + # Allows passing max_size to the assignment, e.g., + # create(:assignment_team, max_size: 3) transient do - course { create(:course) } + max_size { 5 } end + # Create the assignment and pass transient values to it + association :assignment, factory: :assignment, max_team_size: nil + + # Use after(:build) to handle transient properties after(:build) do |team, evaluator| - if team.assignment.nil? - team.course = evaluator.course - else - team.course = team.assignment.course + # This check ensures we don't override an explicitly passed assignment + # just to set the max_team_size. + if team.assignment&.persisted? && evaluator.max_size + team.assignment.update_column(:max_team_size, evaluator.max_size) end - team.user ||= create(:user) end - trait :with_assignment do - after(:build) do |team, evaluator| - team.assignment = create(:assignment, course: evaluator.course) - team.course = team.assignment.course - team.user ||= create(:user) + # Trait to automatically create a mentor duty for the assignment + trait :with_mentor_duty do + after(:create) do |team| + duty = Duty.find_or_create_by!( + name: 'mentor', + instructor: team.assignment.instructor + ) + team.assignment.duties << duty unless team.assignment.duties.include?(duty) end end end - factory :mentored_team, class: 'MentoredTeam' do - name { Faker::Team.name } - type { 'MentoredTeam' } - max_team_size { 5 } - association :user, factory: :user - - transient do - course { create(:course) } - end - - assignment { create(:assignment, course: course) } - - after(:build) do |team, evaluator| - mentor_role = create(:role, :mentor) - mentor = create(:user, role: mentor_role) - team.mentor = mentor - end - end - - factory :teams_participant, class: 'TeamsParticipant' do - team - participant - user { participant.user } + factory :mentored_team, parent: :assignment_team, class: 'MentoredTeam' do + # This factory now inherits all the logic from :assignment_team, + # including assignment creation, parent_id, and max_size transient. + + # By default, a mentored team factory should set up the mentor duty + with_mentor_duty end - -end +end diff --git a/spec/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb index 4dab54d2e..9a81486cf 100644 --- a/spec/models/assignment_team_spec.rb +++ b/spec/models/assignment_team_spec.rb @@ -3,132 +3,145 @@ require 'rails_helper' RSpec.describe AssignmentTeam, type: :model do - - include RolesHelper - # -------------------------------------------------------------------------- - # Global Setup - # -------------------------------------------------------------------------- - # Create the full roles hierarchy once, to be shared by all examples. - before(:all) do - @roles = create_roles_hierarchy - end - - # ------------------------------------------------------------------------ - # Helper: DRY-up creation of student users with a predictable pattern. - # ------------------------------------------------------------------------ - def create_student(suffix) - User.create!( - name: suffix, - email: "#{suffix}@example.com", - full_name: suffix.split('_').map(&:capitalize).join(' '), - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # ------------------------------------------------------------------------ - # Shared Data Setup: Build core domain objects used across tests. - # ------------------------------------------------------------------------ - let(:institution) do - # All users belong to the same institution to satisfy foreign key constraints. - Institution.create!(name: "NC State") - end - - let(:instructor) do - # The instructor will own assignments and courses in subsequent tests. - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: @roles[:instructor].id, - institution_id: institution.id - ) - end - - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - let(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - let(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } - - let(:assignment_team) do - AssignmentTeam.create!( - parent_id: assignment.id, - name: 'team 1', - user_id: team_owner.id - ) - end - - + # --- FactoryBot Setup --- + let!(:course) { create(:course) } + let!(:assignment) { create(:assignment, max_team_size: 3) } + let!(:other_assignment) { create(:assignment) } + let!(:assignment_team) { create(:assignment_team, assignment: assignment, max_size: 3) } + let!(:participant) { create(:assignment_participant, assignment: assignment) } + let!(:other_participant) { create(:assignment_participant, assignment: other_assignment) } + + # --- Validation Tests --- describe 'validations' do it 'is valid with valid attributes' do expect(assignment_team).to be_valid end it 'is not valid without an assignment' do - team = build(:assignment_team, assignment: nil) + team = build(:assignment_team, assignment: nil, parent_id: nil) expect(team).not_to be_valid + # This validation comes from the `belongs_to :assignment` association expect(team.errors[:assignment]).to include("must exist") + # This validation comes from the base Team class + expect(team.errors[:parent_id]).to include("can't be blank") end + end - it 'validates type must be AssignmentTeam' do - team = build(:assignment_team) - team.type = 'WrongType' - expect(team).not_to be_valid - expect(team.errors[:type]).to include("must be 'Assignment' or 'Course' or 'Mentor'") + # --- Association Tests --- + describe 'associations' do + it { should belong_to(:assignment) } + it { should have_many(:teams_participants).dependent(:destroy) } + it { should have_many(:participants).through(:teams_participants) } + it { should have_many(:users).through(:teams_participants) } + end + + # --- Polymorphic Method Implementation Tests --- + describe 'polymorphic methods' do + it 'returns assignment as parent_entity' do + expect(assignment_team.parent_entity).to eq(assignment) + end + + it 'returns AssignmentParticipant as participant_class' do + expect(assignment_team.participant_class).to eq(AssignmentParticipant) + end + + it 'returns "assignment" as context_label' do + expect(assignment_team.context_label).to eq('assignment') + end + + it 'returns assignment max_team_size' do + expect(assignment_team.max_team_size).to eq(3) end end + # --- Behavior Tests --- describe '#add_member' do - context 'when user is not enrolled in the assignment' do - it 'does not add the member to the team' do - unenrolled_user = create_student("add_user") + context 'when participant is in the same assignment' do + it 'adds the member successfully' do + expect { + result = assignment_team.add_member(participant) + expect(result[:success]).to be true + }.to change(TeamsParticipant, :count).by(1) + expect(assignment_team.participants).to include(participant) + end + end + context 'when participant is from a different assignment' do + it 'does not add the member' do expect { - assignment_team.add_member(unenrolled_user) + result = assignment_team.add_member(other_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Participant belongs to.*but this team belongs to/) }.not_to change(TeamsParticipant, :count) end + end - it 'returns false' do - unenrolled_user = create_student("add_user") - - result = assignment_team.add_member(unenrolled_user) + context 'when participant is a CourseParticipant' do + it 'does not add the member' do + wrong_type_participant = create(:course_participant, course: course) + result = assignment_team.add_member(wrong_type_participant) expect(result[:success]).to be false - expect(result[:error]).to eq("#{unenrolled_user.name} is not a participant in this assignment") + expect(result[:error]).to match(/Participant belongs to.*Course.*but this team belongs to.*Assignment/) end end - end - describe 'associations' do - it { should belong_to(:assignment) } - it { should belong_to(:user).optional } - it { should have_many(:teams_participants).dependent(:destroy) } - it { should have_many(:users).through(:teams_participants) } + context 'when team is full' do + it 'rejects new members' do + # Fill the team to its max size of 3 + 3.times do + assignment_team.add_member(create(:assignment_participant, assignment: assignment)) + end + + expect(assignment_team.full?).to be true + + # Try to add one more + overflow_participant = create(:assignment_participant, assignment: assignment) + result = assignment_team.add_member(overflow_participant) + + expect(result[:success]).to be false + expect(result[:error]).to match(/Team is at full capacity/) + end + end end - describe 'team management' do - let(:enrolled_user) { create(:user) } - let(:unenrolled_user) { create(:user) } + # --- Copy Logic Tests --- + describe '#copy_to_course_team' do + before do + # Add a member to the original team + assignment_team.add_member(participant) + end + + it 'creates a new CourseTeam with copied members' do + new_team = assignment_team.copy_to_course_team(course) + + expect(new_team).to be_a(CourseTeam) + expect(new_team.persisted?).to be true + expect(new_team.name).to eq(assignment_team.name) # Name should be identical + expect(new_team.parent_id).to eq(course.id) + + # Check that members were copied + expect(new_team.participants.count).to eq(1) + expect(new_team.users.first).to eq(participant.user) + end + end + describe '#copy_to_assignment_team' do before do - @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) + # Add a member to the original team + assignment_team.add_member(participant) end - it 'cannot add unenrolled user' do - result = assignment_team.add_member(unenrolled_user) + it 'creates a new AssignmentTeam with copied members' do + new_team = assignment_team.copy_to_assignment_team(other_assignment) + + expect(new_team).to be_an(AssignmentTeam) + expect(new_team.persisted?).to be true + expect(new_team.name).to eq(assignment_team.name) # Name should be identical + expect(new_team.parent_id).to eq(other_assignment.id) - expect(result[:success]).to be false - expect(result[:error]).to eq("#{unenrolled_user.name} is not a participant in this assignment") + # Check that members were copied + expect(new_team.participants.count).to eq(1) + expect(new_team.users.first).to eq(participant.user) end end -end +end diff --git a/spec/models/course_team_spec.rb b/spec/models/course_team_spec.rb index f3758fe2e..60f4a4853 100644 --- a/spec/models/course_team_spec.rb +++ b/spec/models/course_team_spec.rb @@ -3,131 +3,142 @@ require 'rails_helper' RSpec.describe CourseTeam, type: :model do - - include RolesHelper - # -------------------------------------------------------------------------- - # Global Setup - # -------------------------------------------------------------------------- - # Create the full roles hierarchy once, to be shared by all examples. - before(:all) do - @roles = create_roles_hierarchy - end - - # ------------------------------------------------------------------------ - # Helper: DRY-up creation of student users with a predictable pattern. - # ------------------------------------------------------------------------ - def create_student(suffix) - User.create!( - name: suffix, - email: "#{suffix}@example.com", - full_name: suffix.split('_').map(&:capitalize).join(' '), - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # ------------------------------------------------------------------------ - # Shared Data Setup: Build core domain objects used across tests. - # ------------------------------------------------------------------------ - let(:institution) do - # All users belong to the same institution to satisfy foreign key constraints. - Institution.create!(name: "NC State") - end - - let(:instructor) do - # The instructor will own assignments and courses in subsequent tests. - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: @roles[:instructor].id, - institution_id: institution.id - ) - end - - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - let(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - let(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } - - let(:course_team) do - CourseTeam.create!( - parent_id: course.id, - name: 'team 2', - user_id: team_owner.id - ) - end - - + # --- FactoryBot Setup --- + let!(:course) { create(:course) } + let!(:other_course) { create(:course) } + let!(:assignment) { create(:assignment) } + let!(:course_team) { create(:course_team, course: course) } + let!(:participant) { create(:course_participant, course: course) } + let!(:other_participant) { create(:course_participant, course: other_course) } + + # --- Validation Tests --- describe 'validations' do it 'is valid with valid attributes' do expect(course_team).to be_valid end it 'is not valid without a course' do - team = build(:course_team, course: nil) + team = build(:course_team, course: nil, parent_id: nil) expect(team).not_to be_valid + # This validation comes from the `belongs_to :course` association expect(team.errors[:course]).to include("must exist") + # This validation comes from the base Team class + expect(team.errors[:parent_id]).to include("can't be blank") end + end - it 'validates type must be CourseTeam' do - team = build(:course_team) - team.type = 'WrongType' - expect(team).not_to be_valid - expect(team.errors[:type]).to include("must be 'Assignment' or 'Course' or 'Mentor'") + # --- Association Tests --- + describe 'associations' do + it { should belong_to(:course) } + it { should have_many(:teams_participants).dependent(:destroy) } + it { should have_many(:participants).through(:teams_participants) } + it { should have_many(:users).through(:teams_participants) } + end + + # --- Polymorphic Method Implementation Tests --- + describe 'polymorphic methods' do + it 'returns course as parent_entity' do + expect(course_team.parent_entity).to eq(course) + end + + it 'returns CourseParticipant as participant_class' do + expect(course_team.participant_class).to eq(CourseParticipant) + end + + it 'returns "course" as context_label' do + expect(course_team.context_label).to eq('course') + end + + it 'returns nil for max_team_size (no limit for course teams)' do + expect(course_team.max_team_size).to be_nil end end + # --- Behavior Tests --- describe '#add_member' do - context 'when user is not enrolled in the course' do - it 'does not add the member to the team' do - unenrolled_user = create_student("add_user") + context 'when participant is in the same course' do + it 'adds the member successfully' do + expect { + result = course_team.add_member(participant) + expect(result[:success]).to be true + }.to change(TeamsParticipant, :count).by(1) + expect(course_team.participants).to include(participant) + end + end + context 'when participant is from a different course' do + it 'does not add the member' do expect { - course_team.add_member(unenrolled_user) + result = course_team.add_member(other_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Participant belongs to.*but this team belongs to/) }.not_to change(TeamsParticipant, :count) end end - end - describe 'associations' do - it { should belong_to(:course) } - it { should belong_to(:user).optional } - it { should have_many(:teams_participants).dependent(:destroy) } - it { should have_many(:users).through(:teams_participants) } + context 'when participant is an AssignmentParticipant' do + it 'does not add the member' do + wrong_type_participant = create(:assignment_participant, assignment: assignment) + result = course_team.add_member(wrong_type_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Participant belongs to.*Assignment.*but this team belongs to.*Course/) + end + end end - describe 'team management' do - let(:enrolled_user) { create(:user, role: create(:role)) } - let(:unenrolled_user) { create(:user, role: create(:role)) } + describe '#full?' do + it 'always returns false, even with many members' do + expect(course_team.full?).to be false + # Add multiple members + 5.times do + course_team.add_member(create(:course_participant, course: course)) + end + + # Still not full + expect(course_team.size).to eq(5) + expect(course_team.full?).to be false + end + end + + # --- Copy Logic Tests --- + describe '#copy_to_assignment_team' do before do - @participant = create(:course_participant, user: enrolled_user, course: course) + # Add a member to the original team + course_team.add_member(participant) + end + + it 'creates a new AssignmentTeam with copied members' do + new_team = course_team.copy_to_assignment_team(assignment) + + expect(new_team).to be_an(AssignmentTeam) + expect(new_team.persisted?).to be true + expect(new_team.name).to eq(course_team.name) # Name should be identical + expect(new_team.parent_id).to eq(assignment.id) + + # Check that members were copied + expect(new_team.participants.count).to eq(1) + expect(new_team.users.first).to eq(participant.user) end + end - it 'can add enrolled user' do - result = course_team.add_member(enrolled_user) - - expect(result[:success]).to be true - expect(course_team.has_member?(enrolled_user)).to be true + describe '#copy_to_course_team' do + before do + # Add a member to the original team + course_team.add_member(participant) end - it 'cannot add unenrolled user' do - result = course_team.add_member(unenrolled_user) + it 'creates a new CourseTeam with copied members' do + new_team = course_team.copy_to_course_team(other_course) + + expect(new_team).to be_a(CourseTeam) + expect(new_team.persisted?).to be true + expect(new_team.name).to eq(course_team.name) # Name should be identical + expect(new_team.parent_id).to eq(other_course.id) - expect(result[:success]).to be false - expect(result[:error]).to eq("#{unenrolled_user.name} is not a participant in this course") + # Check that members were copied + expect(new_team.participants.count).to eq(1) + expect(new_team.users.first).to eq(participant.user) end end -end +end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index ee5416a3d..d89287f5b 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -3,152 +3,170 @@ require 'rails_helper' RSpec.describe MentoredTeam, type: :model do - - include RolesHelper - # -------------------------------------------------------------------------- - # Global Setup - # -------------------------------------------------------------------------- - # Create the full roles hierarchy once, to be shared by all examples. - let!(:roles) { create_roles_hierarchy } - - # ------------------------------------------------------------------------ - # Helper: DRY-up creation of student users with a predictable pattern. - # ------------------------------------------------------------------------ - def create_student(suffix) - User.create!( - name: suffix, - email: "#{suffix}@example.com", - full_name: suffix.split('_').map(&:capitalize).join(' '), - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) + # --- FactoryBot Setup --- + # The :mentored_team factory automatically uses the :with_mentor_duty trait, + # ensuring the assignment has a mentor duty available. + let!(:assignment) { create(:assignment, max_team_size: 2) } + let!(:team) { create(:mentored_team, assignment: assignment) } + let!(:mentor_user) { create(:user) } + let!(:regular_user) { create(:user) } + let!(:mentor_duty) { Duty.find_by(name: 'mentor') || create(:duty, name: 'mentor', instructor: assignment.instructor) } + + # A participant with the mentor duty + let!(:mentor_participant) do + create(:assignment_participant, :with_mentor_duty, user: mentor_user, assignment: assignment) end - # ------------------------------------------------------------------------ - # Shared Data Setup: Build core domain objects used across tests. - # ------------------------------------------------------------------------ - let(:institution) do - # All users belong to the same institution to satisfy foreign key constraints. - Institution.create!(name: "NC State") + # A regular participant without any duty + let!(:regular_participant) do + create(:assignment_participant, user: regular_user, assignment: assignment) end - let(:instructor) do - # The instructor will own assignments and courses in subsequent tests. - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: roles[:instructor].id, - institution_id: institution.id - ) - end + # --- Validation Tests --- + describe 'validations' do + it 'is valid on :create without a mentor' do + # The factory creates a team with no mentor by default + expect(team).to be_valid + expect(team.mentor).to be_nil + end + + it 'is invalid on :update if no mentor is present' do + # 1. Assign a mentor to make the team valid + team.assign_mentor(mentor_user) + expect(team.update(name: 'A New Name')).to be true + + # 2. Remove the mentor + team.remove_mentor + expect(team.mentor).to be_nil - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) + # 3. Try to update again. This should trigger the :update validation + expect(team.update(name: 'A Second New Name')).to be false + expect(team.errors[:base]).to include('a mentor must be present') + end end - let!(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - let!(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } + # --- Mentor Management API Tests --- + describe '#assign_mentor' do + it 'successfully assigns a user as a mentor' do + result = team.assign_mentor(mentor_user) - let(:mentor_role) { create(:role, :mentor) } + expect(result[:success]).to be true - let(:mentor) do - User.create!( - name: "mentor_user", - full_name: "Mentor User", - email: "mentor@example.com", - password_digest: "password", - role_id: mentor_role.id, - institution_id: institution.id - ) - end + expect(team.reload.mentor).to eq(mentor_user) + end - let(:mentored_team) do - MentoredTeam.create!( - parent_id: mentor.id, - assignment: assignment, - name: 'team 3', - user_id: team_owner.id, - mentor: mentor - ) - end + it 'finds and promotes an existing participant to mentor' do + # 'regular_user' is already a participant, but without a duty + team.add_member(regular_participant) + expect(regular_participant.duty).to be_nil + + # Now, assign them as mentor + result = team.assign_mentor(regular_user) + expect(result[:success]).to be true + expect(regular_participant.reload.duty).to eq(mentor_duty) + + # Reload the team object to get fresh association + expect(team.reload.mentor).to eq(regular_user) + end - let(:user) do - User.create!( - name: "student_user", - full_name: "Student User", - email: "student@example.com", - password_digest: "password", - role_id: roles[:student].id, - institution_id: institution.id - ) + it 'returns an error if no mentor duty exists on the assignment' do + # Create an assignment_team and set type. + # This avoids the :mentored_team factory and its :with_mentor_duty trait. + assignment_no_duty = create(:assignment) + team_no_duty = create(:assignment_team, assignment: assignment_no_duty, type: 'MentoredTeam') + + result = team_no_duty.assign_mentor(mentor_user) + + expect(result[:success]).to be false + expect(result[:error]).to match(/No mentor duty found/) + end end - let!(:team) { create(:mentored_team, user: user, assignment: assignment) } + describe '#remove_mentor' do + before do + team.assign_mentor(mentor_user) + end + it 'removes the mentor duty from the participant' do + # Get the participant via the user + mentor_participant = team.participants.find_by(user_id: mentor_user.id) + expect(mentor_participant).to be_present - describe 'validations' do - it { should validate_presence_of(:mentor) } - it { should validate_presence_of(:type) } - - it 'requires type to be MentoredTeam' do - team.type = 'AssignmentTeam' - expect(team).not_to be_valid - expect(team.errors[:type]).to include('must be MentoredTeam') + result = team.remove_mentor + + expect(result[:success]).to be true + expect(mentor_participant.reload.duty).to be_nil + expect(team.mentor).to be_nil end - it 'requires mentor to have mentor role' do - non_mentor = create(:user) - team.mentor = non_mentor - expect(team).not_to be_valid - expect(team.errors[:mentor]).to include('must have mentor role') + it 'returns an error if no mentor is on the team' do + # Remove the mentor first + team.remove_mentor + + # Try to remove again + result = team.remove_mentor + expect(result[:success]).to be false + expect(result[:error]).to match(/No mentor found/) end end - describe 'associations' do - it { should belong_to(:mentor).class_name('User') } - it { should belong_to(:assignment) } - it { should belong_to(:user).optional } - it { should have_many(:teams_participants).dependent(:destroy) } - it { should have_many(:users).through(:teams_participants) } - end - - describe 'team management' do - let(:enrolled_user) { create(:user) } + # --- LSP Refactor Tests --- + describe '#add_member' do + it 'adds a regular participant successfully' do + result = team.add_member(regular_participant) + + expect(result[:success]).to be true + expect(team.participants).to include(regular_participant) + end - before do - @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) + it 'rejects a participant who has a mentor duty' do + # This tests our 'validate_participant_for_add' hook + result = team.add_member(mentor_participant) + + expect(result[:success]).to be false + expect(result[:error]).to match(/Mentors cannot be added as regular members/) end + end - it 'can add enrolled user' do - expect(team.add_member(enrolled_user)).to be_truthy - expect(team.has_member?(enrolled_user)).to be_truthy + describe '#full?' do + it 'does not count the mentor toward team capacity' do + # Assignment max_team_size is 2 + + # 1. Add a mentor + team.assign_mentor(mentor_user) + expect(team.size).to eq(1) + expect(team.full?).to be false + + # 2. Add first regular member + team.add_member(regular_participant) + expect(team.size).to eq(2) + expect(team.full?).to be false + + # 3. Add second regular member (team is now at capacity) + team.add_member(create(:assignment_participant, assignment: assignment)) + expect(team.size).to eq(3) + expect(team.full?).to be true # 1 mentor + 2 members = full end - it 'cannot add mentor as member' do - expect(team.add_member(team.mentor)).to be_falsey - expect(team.has_member?(team.mentor)).to be_falsey + it 'is full when regular members reach max_team_size' do + # Assignment max_team_size is 2 + team.add_member(regular_participant) + team.add_member(create(:assignment_participant, assignment: assignment)) + + expect(team.size).to eq(2) + expect(team.full?).to be true end + end - it 'can assign new mentor' do - new_mentor = create(:user, role: mentor_role) - expect(team.assign_mentor(new_mentor)).to be_truthy - expect(team.mentor).to eq(new_mentor) + # --- Getter Method Tests --- + describe '#mentor' do + it 'returns the mentor user when one is assigned' do + team.assign_mentor(mentor_user) + expect(team.mentor).to eq(mentor_user) end - it 'cannot assign non-mentor as mentor' do - non_mentor = create(:user) - expect(team.assign_mentor(non_mentor)).to be_falsey - expect(team.mentor).not_to eq(non_mentor) + it 'returns nil when no mentor is assigned' do + expect(team.mentor).to be_nil end end -end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 0106cb971..4fad330c8 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -2,314 +2,242 @@ require 'rails_helper' -# This spec exercises the Team model, covering: -# - Presence and inclusion validations on parent_id and STI type -# - The full? method, which determines if a team has reached capacity -# - The can_participant_join_team? method, which enforces eligibility rules -# - The add_member method, which creates TeamsParticipant records RSpec.describe Team, type: :model do - include RolesHelper - # -------------------------------------------------------------------------- - # Global Setup - # -------------------------------------------------------------------------- - # Create the full roles hierarchy once, to be shared by all examples. - before(:all) do - @roles = create_roles_hierarchy - end + let!(:course) { create(:course) } + let!(:assignment) { create(:assignment, max_team_size: 3) } - # ------------------------------------------------------------------------ - # Helper: DRY-up creation of student users with a predictable pattern. - # ------------------------------------------------------------------------ - def create_student(suffix) - User.create!( - name: suffix, - email: "#{suffix}@example.com", - full_name: suffix.split('_').map(&:capitalize).join(' '), - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # ------------------------------------------------------------------------ - # Shared Data Setup: Build core domain objects used across tests. - # ------------------------------------------------------------------------ - let(:institution) do - # All users belong to the same institution to satisfy foreign key constraints. - Institution.create!(name: "NC State") - end - - let(:instructor) do - # The instructor will own assignments and courses in subsequent tests. - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: @roles[:instructor].id, - institution_id: institution.id - ) - end - - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # Two assignments with explicit max_team_size values, for testing AssignmentTeam.full? - let(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - let(:assignment2) { Assignment.create!(name: "Assignment 2", instructor_id: instructor.id, max_team_size: 2) } - - # Two courses (Course model does not have max_team_size column) - let(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } - let(:course2) { Course.create!(name: "Course 2", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course2") } - - # ------------------------------------------------------------------------ - # Create one team per context using STI subclasses - # ------------------------------------------------------------------------ - let(:assignment_team) do - AssignmentTeam.create!( - parent_id: assignment.id, - name: 'team 1', - user_id: team_owner.id - ) - end - - let(:course_team) do - CourseTeam.create!( - parent_id: course.id, - name: 'team 2', - user_id: team_owner.id - ) - end + # Create one team for each context using factories + let!(:course_team) { create(:course_team, course: course) } + let!(:assignment_team) { create(:assignment_team, assignment: assignment, max_size: 3) } # ------------------------------------------------------------------------ # Validation Tests - # - # Ensure presence of parent_id and type, and correct inclusion for STI. # ------------------------------------------------------------------------ describe 'validations' do it 'is invalid without parent_id' do - # Missing parent_id should trigger a blank error on the parent_id column. - team = Team.new(type: 'AssignmentTeam') + team = build(:assignment_team, parent_id: nil) expect(team).not_to be_valid expect(team.errors[:parent_id]).to include("can't be blank") end it 'is invalid without type' do - # Missing STI type should trigger a blank error on the type column. - team = Team.new(parent_id: assignment.id) + team = build(:assignment_team, type: nil) expect(team).not_to be_valid expect(team.errors[:type]).to include("can't be blank") end it 'is invalid with incorrect type' do - # An unsupported value for type should trigger an inclusion error. - team = Team.new(parent_id: assignment.id, type: 'Team') + team = build(:assignment_team, type: 'InvalidTeamType') expect(team).not_to be_valid - expect(team.errors[:type]).to include("must be 'Assignment' or 'Course' or 'Mentor'") + expect(team.errors[:type]).to include("must be 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'") end it 'is valid as AssignmentTeam' do - # Correct STI subclass automatically sets type = 'AssignmentTeam' - expect(assignment_team).to be_valid + # We must create the parent to get a valid parent_id + assignment = create(:assignment) + expect(build(:assignment_team, assignment: assignment, parent_id: assignment.id)).to be_valid end it 'is valid as CourseTeam' do - # Correct STI subclass automatically sets type = 'CourseTeam' - expect(course_team).to be_valid + # We must create the parent to get a valid parent_id + course = create(:course) + expect(build(:course_team, course: course, parent_id: course.id)).to be_valid + end + end + + # ------------------------------------------------------------------------ + # Tests for polymorphic abstract methods + # ------------------------------------------------------------------------ + describe 'abstract methods' do + + it 'implements parent_entity for AssignmentTeam' do + expect(assignment_team.parent_entity).to eq(assignment) + end + + it 'implements parent_entity for CourseTeam' do + expect(course_team.parent_entity).to eq(course) + end + + it 'implements participant_class for AssignmentTeam' do + expect(assignment_team.participant_class).to eq(AssignmentParticipant) + end + + it 'implements participant_class for CourseTeam' do + expect(course_team.participant_class).to eq(CourseParticipant) + end + + it 'implements context_label for AssignmentTeam' do + expect(assignment_team.context_label).to eq('assignment') + end + + it 'implements context_label for CourseTeam' do + expect(course_team.context_label).to eq('course') end end # ------------------------------------------------------------------------ # Tests for #full? - # - # AssignmentTeam: compares participants.count to assignment.max_team_size. - # CourseTeam: always returns false (no cap). # ------------------------------------------------------------------------ describe '#full?' do it 'returns true when participants count >= assignment.max_team_size' do - # Seed exactly max_team_size participants into the assignment_team. - 3.times do |i| - user = create_student("student#{i}") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) - TeamsParticipant.create!( - participant_id: participant.id, - team_id: assignment_team.id, - user_id: user.id - ) + # Fill the team up to its max size (3) + 3.times do + participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(participant) end - assignment_team.reload expect(assignment_team.participants.count).to eq(3) expect(assignment_team.full?).to be true end it 'returns false when participants count < assignment.max_team_size' do - # No participants seeded; count is 0 < 3. expect(assignment_team.full?).to be false end it 'always returns false for a CourseTeam (no capacity limit)' do - # Seed multiple participants into the course_team. - 5.times do |i| - user = create_student("cstudent#{i}") - participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) - TeamsParticipant.create!( - participant_id: participant.id, - team_id: course_team.id, - user_id: user.id - ) + # Add more participants than the assignment team's limit + 5.times do + participant = create(:course_participant, course: course) + course_team.add_member(participant) end - course_team.reload expect(course_team.full?).to be false end end # ------------------------------------------------------------------------ # Tests for #can_participant_join_team? - # - # Ensures a participant: - # - Cannot join if already on any team in the same context - # - Cannot join if not registered in that assignment/course - # - Can join otherwise # ------------------------------------------------------------------------ describe '#can_participant_join_team?' do context 'AssignmentTeam context' do + let!(:participant) { create(:assignment_participant, assignment: assignment) } + it 'rejects a participant already on a team' do - user = create_student("student_team") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) - TeamsParticipant.create!( - participant_id: participant.id, - team_id: assignment_team.id, - user_id: user.id - ) - - result = assignment_team.can_participant_join_team?(participant) - expect(result[:success]).to be false - expect(result[:error]).to match(/already assigned/) - end + assignment_team.add_member(participant) # Add to this team - it 'rejects a participant registered under a different assignment' do - user = create_student("wrong_assignment") - participant = AssignmentParticipant.create!(parent_id: assignment2.id, user: user, handle: user.name) + # Create a second team in the same assignment + other_team = create(:assignment_team, assignment: assignment) + result = other_team.can_participant_join_team?(participant) - result = assignment_team.can_participant_join_team?(participant) expect(result[:success]).to be false - expect(result[:error]).to match(/not a participant/) - end - - it 'allows a properly registered, not-yet-teamed participant' do - user = create_student("eligible") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) - - result = assignment_team.can_participant_join_team?(participant) - expect(result[:success]).to be true + expect(result[:error]).to match(/already assigned/) end end context 'CourseTeam context' do + let!(:participant) { create(:course_participant, course: course) } + it 'rejects a participant already on a course team' do - user = create_student("course_user") - participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) - TeamsParticipant.create!( - participant_id: participant.id, - team_id: course_team.id, - user_id: user.id - ) - - result = course_team.can_participant_join_team?(participant) - expect(result[:success]).to be false - expect(result[:error]).to match(/already assigned/) - end + course_team.add_member(participant) # Add to this team - it 'rejects a participant registered under a different course' do - user = create_student("wrong_course") - participant = CourseParticipant.create!(parent_id: course2.id, user: user, handle: user.name) + # Create a second team in the same course + other_team = create(:course_team, course: course) + result = other_team.can_participant_join_team?(participant) - result = course_team.can_participant_join_team?(participant) expect(result[:success]).to be false - expect(result[:error]).to match(/not a participant/) - end - - it 'allows a properly registered, not-yet-teamed course participant' do - user = create_student("c_eligible") - participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) - - result = course_team.can_participant_join_team?(participant) - expect(result[:success]).to be true + expect(result[:error]).to match(/already assigned/) end end end # ------------------------------------------------------------------------ # Tests for #add_member - # - # AssignmentTeam: - # - Should create a TeamsParticipant record when capacity allows - # - Should return an error when at capacity - # CourseTeam: - # - Should always add (unless manually overridden, but model does not cap) # ------------------------------------------------------------------------ describe '#add_member' do context 'AssignmentTeam' do - it 'creates a TeamsParticipant record on success' do - user = create_student("add_user") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + let!(:participant) { create(:assignment_participant, assignment: assignment) } + it 'creates a TeamsParticipant record on success' do expect { - assignment_team.add_member(participant) + result = assignment_team.add_member(participant) + expect(result[:success]).to be true }.to change { TeamsParticipant.where(team_id: assignment_team.id).count }.by(1) end it 'returns an error if the assignment team is already full' do # Fill up to assignment.max_team_size (3) - 3.times do |i| - user_i = create_student("f#{i}") - part_i = AssignmentParticipant.create!(parent_id: assignment.id, user: user_i, handle: user_i.name) - assignment_team.add_member(part_i) + 3.times do + p = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(p) end - extra_user = create_student("f_extra") - extra_part = AssignmentParticipant.create!(parent_id: assignment.id, user: extra_user, handle: extra_user.name) - result = assignment_team.add_member(extra_part) + extra_participant = create(:assignment_participant, assignment: assignment) + result = assignment_team.add_member(extra_participant) + + expect(result[:success]).to be false + expect(result[:error]).to include("Team is at full capacity") + end + + it 'validates participant type matches team type' do + # Create a CourseParticipant + wrong_participant = create(:course_participant, course: course) - expect(result[:error]).to include("team is at full capacity") + result = assignment_team.add_member(wrong_participant) + expect(result[:success]).to be false + + expect(result[:error]).to match(/Participant belongs to.*Course.*but this team belongs to.*Assignment/) end end context 'CourseTeam' do + let!(:participant) { create(:course_participant, course: course) } + it 'creates a TeamsParticipant record on success' do - user = create_student("cadd") - participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) - expect { - course_team.add_member(participant) + result = course_team.add_member(participant) + expect(result[:success]).to be true }.to change { TeamsParticipant.where(team_id: course_team.id).count }.by(1) end - it 'still adds even if max_participants is manually set (no cap by default)' do - # override max_participants, but full? remains false - course_team.max_participants = 1 - - first_user = create_student("cf1") - first_part = CourseParticipant.create!(parent_id: course.id, user: first_user, handle: first_user.name) - course_team.add_member(first_part) + it 'still adds even if team is "full" (CourseTeam#full? is always false)' do + # Add a few members + create(:course_participant, course: course).tap { |p| course_team.add_member(p) } + create(:course_participant, course: course).tap { |p| course_team.add_member(p) } - second_user = create_student("cf2") - second_part = CourseParticipant.create!(parent_id: course.id, user: second_user, handle: second_user.name) - result = course_team.add_member(second_part) + # Add one more + result = course_team.add_member(participant) - # CourseTeam.full? is false, so add_member should succeed + # CourseTeam.full? is always false, so add_member should succeed expect(result[:success]).to be true + expect(course_team.size).to eq(3) + end + + it 'validates participant type matches team type' do + # Create an AssignmentParticipant + wrong_participant = create(:assignment_participant, assignment: assignment) + + result = course_team.add_member(wrong_participant) + expect(result[:success]).to be false + + expect(result[:error]).to match(/Participant belongs to.*Assignment.*but this team belongs to.*Course/) end end end + + # ------------------------------------------------------------------------ + # Tests for helper methods + # ------------------------------------------------------------------------ + describe '#size' do + it 'returns the number of participants' do + expect(assignment_team.size).to eq(0) + + participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(participant) + + expect(assignment_team.size).to eq(1) + end + end + + describe '#empty?' do + it 'returns true when no participants' do + expect(assignment_team.empty?).to be true + end + + it 'returns false when participants exist' do + participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(participant) + + expect(assignment_team.empty?).to be false + end + end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9d528540b..065ee3f7b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -6,6 +6,10 @@ require_relative '../config/environment' # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? +# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file +# that will avoid rails generators crashing because migrations haven't been run yet +return unless Rails.env.test? + require 'rspec/rails' require 'factory_bot_rails' @@ -66,9 +70,11 @@ # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -# Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f } +# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f } -# Checks for pending migrations and applies them before tests are run. +# Ensures that the test database schema matches the current schema file. +# If there are pending migrations it will invoke `db:test:prepare` to +# recreate the test database by loading the schema. # If you are not using ActiveRecord, you can remove these lines. begin ActiveRecord::Migration.maintain_test_schema! @@ -77,7 +83,9 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = Rails.root.join('spec/fixtures') + config.fixture_paths = [ + Rails.root.join('spec/fixtures') + ] # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false @@ -87,19 +95,15 @@ # You can uncomment this line to turn off ActiveRecord support entirely. # config.use_active_record = false - # RSpec Rails can automatically mix in different behaviours to your tests - # based on their file location, for example enabling you to call `get` and - # `post` in specs under `spec/controllers`. - # - # You can disable this behaviour by removing the line below, and instead - # explicitly tag your specs with their type, e.g.: + # RSpec Rails uses metadata to mix in different behaviours to your tests, + # for example enabling you to call `get` and `post` in request specs. e.g.: # - # RSpec.describe UsersController, type: :controller do + # RSpec.describe UsersController, type: :request do # # ... # end # # The different available types are documented in the features, such as in - # https://rspec.info/features/6-0/rspec-rails + # https://rspec.info/features/8-0/rspec-rails config.infer_spec_type_from_file_location! # Filter lines from Rails gems in backtraces. diff --git a/spec/requests/api/v1/teams_participants_controller_spec.rb b/spec/requests/api/v1/teams_participants_controller_spec.rb index f16bd5599..2b973f3a3 100644 --- a/spec/requests/api/v1/teams_participants_controller_spec.rb +++ b/spec/requests/api/v1/teams_participants_controller_spec.rb @@ -91,14 +91,12 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end let(:team_with_course) do CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end @@ -356,7 +354,6 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end let(:assignment_participant1) { AssignmentParticipant.create!(parent_id: assignment.id, user: student_user, handle: student_user.name) } @@ -369,7 +366,6 @@ CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end let(:course_participant1) { CourseParticipant.create!(parent_id: course.id, user: student_user, handle: student_user.name) } diff --git a/spec/requests/api/v1/teams_spec.rb b/spec/requests/api/v1/teams_spec.rb index cf42dc697..6d4c25dda 100644 --- a/spec/requests/api/v1/teams_spec.rb +++ b/spec/requests/api/v1/teams_spec.rb @@ -28,121 +28,42 @@ ) end - # A generic "new participant" used when adding to teams - let(:user) do - User.create!( - full_name: "New Participant", - name: "NewParticipant", - email: "newparticipant@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # A student user used for ownership checks in update_duty, list, etc. - let(:other_user) do - User.create!( - full_name: "Student Member", - name: "student_member", - email: "studentmember@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - - # -------------------------------------------------------------------------- - # Primary Resources - # -------------------------------------------------------------------------- - # Two assignments: one main and one alternate, both owned by the instructor. - let(:assignment) { Assignment.create!(name: "Assignment 1", instructor_id: instructor.id, max_team_size: 3) } - # A single course, also owned by the instructor. - let(:course) do - Course.create!( - name: "Sample Course", - instructor_id: instructor.id, - institution_id: institution.id, - directory_path: "/some/path" - ) - end - - let(:team_owner) do - User.create!( - name: "team_owner", - full_name: "Team Owner", - email: "team_owner@example.com", - password_digest: "password", - role_id: @roles[:student].id, - institution_id: institution.id - ) - end - let(:team_with_course) do - CourseTeam.create!( - parent_id: course.id, - name: 'team 2', - user_id: team_owner.id - ) - end - - let(:team_with_assignment) do - AssignmentTeam.create!( - parent_id: assignment.id, - name: 'team 1', - user_id: team_owner.id - ) - end - - # Create one participant record per context for a baseline student_user: - let!(:participant_for_assignment) do - AssignmentParticipant.create!( - parent_id: assignment.id, - user: other_user, - handle: other_user.name - ) - end - let!(:participant_for_course) do - CourseParticipant.create!( - parent_id: course.id, - user: other_user, - handle: other_user.name - ) - end - - # Link those participants into TeamsParticipant join records: - let(:teams_participant_assignment) do - TeamsParticipant.create!( - participant_id: participant_for_assignment.id, - team_id: team_with_assignment.id, - user_id: participant_for_assignment.user_id - ) - end - let(:teams_participant_course) do - TeamsParticipant.create!( - participant_id: participant_for_course.id, - team_id: team_with_course.id, - user_id: participant_for_course.user_id - ) - end + # --- FactoryBot Setup --- + let!(:course) { create(:course, instructor: instructor) } + let!(:assignment) { create(:assignment, instructor: instructor, max_team_size: 3) } + let!(:course_team) { create(:course_team, course: course) } + let!(:assignment_team) { create(:assignment_team, assignment: assignment) } + # --- Auth & Helpers --- let(:token) { JsonWebToken.encode(id: instructor.id) } let(:auth_headers) { { Authorization: "Bearer #{token}" } } + let(:json_response) { JSON.parse(response.body) } + # --- Controller Action Tests --- describe 'GET /teams' do - it 'returns all teams' do - team_with_course + it 'returns all teams of different types' do + # The let! blocks for course_team and assignment_team + # have already created these. get '/teams', headers: auth_headers expect(response).to have_http_status(:success) - expect(json_response.size).to eq(1) - expect(json_response.first['id']).to eq(team_with_course.id) + expect(json_response.size).to eq(2) + expect(json_response.map { |t| t['id'] }).to include(course_team.id, assignment_team.id) end end describe 'GET /teams/:id' do - it 'returns a specific team' do - get "/teams/#{team_with_course.id}", headers: auth_headers + it 'returns a specific course team' do + get "/teams/#{course_team.id}", headers: auth_headers expect(response).to have_http_status(:success) - expect(json_response['id']).to eq(team_with_course.id) + expect(json_response['id']).to eq(course_team.id) + expect(json_response['parent_type']).to eq('course') # Check polymorphic method + end + + it 'returns a specific assignment team' do + get "/teams/#{assignment_team.id}", headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response['id']).to eq(assignment_team.id) + expect(json_response['parent_type']).to eq('assignment') # Check polymorphic method end it 'returns 404 for non-existent team' do @@ -152,7 +73,50 @@ end describe 'POST /teams' do - it 'returns error for invalid params' do + it 'creates an AssignmentTeam with valid params' do + valid_params = { + team: { + name: 'New Assignment Team', + type: 'AssignmentTeam', + parent_id: assignment.id + } + } + + expect { + post '/teams', params: valid_params, headers: auth_headers + }.to change(AssignmentTeam, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response['name']).to eq('New Assignment Team') + end + + it 'creates a CourseTeam with valid params' do + valid_params = { + team: { + name: 'New Course Team', + type: 'CourseTeam', + parent_id: course.id + } + } + + expect { + post '/teams', params: valid_params, headers: auth_headers + }.to change(CourseTeam, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json_response['name']).to eq('New Course Team') + end + + it 'rejects invalid team type' do + invalid_params = { + team: { name: 'Invalid Team', type: 'InvalidTeam', parent_id: assignment.id } + } + + post '/teams', params: invalid_params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns error for missing params' do post '/teams', params: { team: { name: '' } }, headers: auth_headers expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to have_key('errors') @@ -160,75 +124,109 @@ end describe 'Team Members' do + let!(:new_user) { create(:user) } + describe 'GET /teams/:id/members' do it 'returns all team members' do - teams_participant_course - get "/teams/#{team_with_course.id}/members", headers: auth_headers + participant = create(:course_participant, course: course) + course_team.add_member(participant) + + get "/teams/#{course_team.id}/members", headers: auth_headers expect(response).to have_http_status(:success) expect(json_response.size).to eq(1) - expect(json_response.first['id']).to eq(other_user.id) + expect(json_response.first['id']).to eq(participant.user.id) end - end - describe 'POST /teams/:id/members' do - let(:new_user) { create(:user) } - let!(:new_participant) { create(:course_participant, user: new_user, parent_id: course.id) } - - let(:valid_participant_params) do - { - team_participant: { - user_id: new_user.id - } - } + it 'returns empty array for team with no members' do + get "/teams/#{course_team.id}/members", headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response).to be_empty end + end - it 'adds a new team member' do - expect { - post "/teams/#{team_with_course.id}/members", params: valid_participant_params, headers: auth_headers - }.to change(TeamsParticipant, :count).by(1) - expect(response).to have_http_status(:created) - expect(json_response['id']).to eq(new_user.id) + describe 'POST /teams/:id/members' do + let(:participant_params) { { team_participant: { user_id: new_user.id } } } + + context 'for CourseTeam' do + # Create the participant record so the controller can find them + let!(:new_participant) { create(:course_participant, user: new_user, course: course) } + + it 'adds a new team member' do + expect { + post "/teams/#{course_team.id}/members", params: participant_params, headers: auth_headers + }.to change(TeamsParticipant, :count).by(1) + expect(response).to have_http_status(:created) + expect(json_response['id']).to eq(new_user.id) + end + + it 'returns error when user is not a participant in course' do + non_participant_user = create(:user) + params = { team_participant: { user_id: non_participant_user.id } } + + post "/teams/#{course_team.id}/members", params: params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['errors'].first).to match(/not a participant in this course/) + end end - it 'returns error when team is full' do - # For AssignmentTeam, set max_team_size on the assignment, not the team - assignment.update(max_team_size: 1) - teams_participant_assignment # This creates the first member - - # Create a new participant for the assignment - new_assignment_participant = create(:assignment_participant, user: new_user, parent_id: assignment.id) - - assignment_params = { - team_participant: { - user_id: new_user.id - } - } - - post "/teams/#{team_with_assignment.id}/members", params: assignment_params, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) - expect(json_response).to have_key('errors') + context 'for AssignmentTeam' do + # Create the participant record + let!(:new_assignment_participant) { create(:assignment_participant, user: new_user, assignment: assignment) } + + it 'adds a new team member' do + expect { + post "/teams/#{assignment_team.id}/members", params: participant_params, headers: auth_headers + }.to change(TeamsParticipant, :count).by(1) + expect(response).to have_http_status(:created) + end + + it 'returns error when team is full' do + assignment.update!(max_team_size: 1) + first_participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(first_participant) + + # Try to add the second member (new_user) + post "/teams/#{assignment_team.id}/members", params: participant_params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['errors'].first).to match(/full capacity/) + end + + it 'returns error when user not a participant in assignment' do + non_participant_user = create(:user) + params = { team_participant: { user_id: non_participant_user.id } } + + post "/teams/#{assignment_team.id}/members", params: params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['errors'].first).to match(/not a participant in this assignment/) + end end end describe 'DELETE /teams/:id/members/:user_id' do - it 'removes a team member' do - teams_participant_course + it 'removes a team member from a course team' do + participant = create(:course_participant, course: course) + course_team.add_member(participant) + + expect { + delete "/teams/#{course_team.id}/members/#{participant.user.id}", headers: auth_headers + }.to change(TeamsParticipant, :count).by(-1) + expect(response).to have_http_status(:no_content) + end + + it 'removes a team member from an assignment team' do + participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(participant) + expect { - delete "/teams/#{team_with_course.id}/members/#{other_user.id}", headers: auth_headers + delete "/teams/#{assignment_team.id}/members/#{participant.user.id}", headers: auth_headers }.to change(TeamsParticipant, :count).by(-1) expect(response).to have_http_status(:no_content) end it 'returns 404 for non-existent member' do - delete "/teams/#{team_with_course.id}/members/0", headers: auth_headers + delete "/teams/#{course_team.id}/members/0", headers: auth_headers expect(response).to have_http_status(:not_found) end end end - - private - - def json_response - JSON.parse(response.body) - end end