From 85437ae801b44883aa3a6cb3b7a5fb130c584779 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Tue, 19 Aug 2025 17:02:39 +0000 Subject: [PATCH 01/11] refactored team schema as well as tests [remaining: mentored team] --- app/helpers/team_operations_helper.rb | 2 +- app/models/assignment_team.rb | 3 +- app/models/course_team.rb | 4 +- app/models/team.rb | 19 +++------ app/serializers/team_serializer.rb | 9 ++-- config/database.yml | 2 +- db/migrate/20231102173152_create_teams.rb | 10 ++--- .../20231129023417_add_assignment_to_teams.rb | 7 ---- ...40420070000_make_assignment_id_optional.rb | 7 ---- ...nge_to_polymorphic_association_in_teams.rb | 11 ----- ...250819160547_change_teams_type_not_null.rb | 5 +++ db/schema.rb | 14 ++----- spec/factories/teams.rb | 41 +++++++------------ spec/models/assignment_team_spec.rb | 10 +++-- spec/models/course_team_spec.rb | 7 +++- spec/models/mentored_team_spec.rb | 3 +- spec/models/team_spec.rb | 2 - .../v1/teams_participants_controller_spec.rb | 4 -- spec/requests/api/v1/teams_spec.rb | 2 - 19 files changed, 55 insertions(+), 107 deletions(-) delete mode 100644 db/migrate/20231129023417_add_assignment_to_teams.rb delete mode 100644 db/migrate/20240420070000_make_assignment_id_optional.rb delete mode 100644 db/migrate/20250418004442_change_to_polymorphic_association_in_teams.rb create mode 100644 db/migrate/20250819160547_change_teams_type_not_null.rb diff --git a/app/helpers/team_operations_helper.rb b/app/helpers/team_operations_helper.rb index 63ade7dab..24a898e50 100644 --- a/app/helpers/team_operations_helper.rb +++ b/app/helpers/team_operations_helper.rb @@ -22,7 +22,7 @@ def self.copy_team_members(source_team, target_team) def self.team_stats(team) { size: team.participants.size, - max_size: team.max_team_size, + max_size: (team.is_a?(AssignmentTeam) ? team.assignment&.max_team_size : nil), is_full: team.full?, is_empty: team.participants.empty? } diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index 77a99cfd4..d5d8a63ad 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -3,7 +3,7 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' - + validates :assignment, presence: true # Copies the current assignment team to a course team # - Creates a new CourseTeam with a modified name @@ -11,7 +11,6 @@ class AssignmentTeam < 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 ) if course_team.save diff --git a/app/models/course_team.rb b/app/models/course_team.rb index 91ae746ac..5e36b88a2 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -3,6 +3,7 @@ class CourseTeam < Team #Each course team must belong to a course belongs_to :course, class_name: 'Course', foreign_key: 'parent_id' + validates :course, presence: true #adds members to the course team post validation def add_member(user) @@ -12,11 +13,10 @@ def add_member(user) # 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 + # - Copies team members from the course team to the assignment 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 ) if assignment_team.save diff --git a/app/models/team.rb b/app/models/team.rb index 08ce2b5d2..86435c7a6 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -17,21 +17,15 @@ class Team < ApplicationRecord 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'" } - + def has_member?(user) participants.exists?(user_id: user.id) end def full? - current_size = participants.count + return false unless is_a?(AssignmentTeam) && assignment&.max_team_size - # 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 + participants.count >= assignment.max_team_size end # Checks if the given participant is already on any team for the associated assignment or course. @@ -54,18 +48,17 @@ def participant_on_team?(participant) # 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) + case participant_or_user + when AssignmentParticipant, CourseParticipant participant_or_user - elsif participant_or_user.is_a?(User) + when 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 - # 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: "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? diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index d58d33354..575d0a736 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -5,7 +5,6 @@ class TeamSerializer < ActiveModel::Serializer has_many :users, serializer: UserSerializer def users - # Use teams_participants association to get users object.teams_participants.includes(:user).map(&:user) end @@ -13,8 +12,12 @@ def team_size object.teams_participants.count end + def max_team_size + # Only AssignmentTeams have a max size, from the assignment + object.is_a?(AssignmentTeam) ? object.assignment&.max_team_size : nil + end + def assignment_id - # Return parent_id for AssignmentTeam, nil for CourseTeam object.is_a?(AssignmentTeam) ? object.parent_id : nil end -end +end diff --git a/config/database.yml b/config/database.yml index 7b9674459..296e53136 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,7 +13,7 @@ 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") %> port: <%= ENV.fetch("DB_PORT", "3306") %> 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/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..335fc3f1f 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" @@ -345,17 +345,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| @@ -423,8 +417,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/spec/factories/teams.rb b/spec/factories/teams.rb index 6c854b9c0..3aaf97a3c 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -4,8 +4,6 @@ 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 @@ -15,35 +13,31 @@ factory :course_team, class: 'CourseTeam' do name { Faker::Team.name } type { 'CourseTeam' } - max_team_size { 5 } - association :user, factory: :user association :course, factory: :course end factory :assignment_team, class: 'AssignmentTeam' do name { Faker::Team.name } type { 'AssignmentTeam' } - max_team_size { 5 } - association :user, factory: :user transient do course { create(:course) } + max_size { 5 } # Now passed to the assignment, not the team + end + + assignment do + create(:assignment, course: course, max_team_size: max_size) end after(:build) do |team, evaluator| - if team.assignment.nil? - team.course = evaluator.course - else - team.course = team.assignment.course + unless team.assignment.nil? + team.assignment.update(max_team_size: evaluator.max_size) if 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) + team.assignment ||= create(:assignment, course: evaluator.course, max_team_size: evaluator.max_size) end end end @@ -51,19 +45,13 @@ factory :mentored_team, class: 'MentoredTeam' do name { Faker::Team.name } type { 'MentoredTeam' } - max_team_size { 5 } - association :user, factory: :user + association :assignment, factory: :assignment - transient do - course { create(:course) } - end - - assignment { create(:assignment, course: course) } - - after(:build) do |team, evaluator| - mentor_role = create(:role, :mentor) + after(:build) do |team, _evaluator| + mentor_role = Role.find_by(name: 'Mentor') || create(:role, :mentor) mentor = create(:user, role: mentor_role) - team.mentor = mentor + team.mentor ||= mentor + team.parent_id ||= team.assignment.id end end @@ -72,5 +60,4 @@ participant user { participant.user } end - -end +end diff --git a/spec/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb index 4dab54d2e..aa6bd6384 100644 --- a/spec/models/assignment_team_spec.rb +++ b/spec/models/assignment_team_spec.rb @@ -63,12 +63,15 @@ def create_student(suffix) let(:assignment_team) do AssignmentTeam.create!( - parent_id: assignment.id, - name: 'team 1', - user_id: team_owner.id + parent_id: assignment.id, + name: 'team 1' ) end + before do + participant = create(:assignment_participant, user: team_owner, assignment: assignment) + assignment_team.add_member(team_owner) + end describe 'validations' do it 'is valid with valid attributes' do @@ -111,7 +114,6 @@ def create_student(suffix) 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) } end diff --git a/spec/models/course_team_spec.rb b/spec/models/course_team_spec.rb index f3758fe2e..1a7e871e9 100644 --- a/spec/models/course_team_spec.rb +++ b/spec/models/course_team_spec.rb @@ -65,10 +65,14 @@ def create_student(suffix) CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end + before do + participant = create(:course_participant, user: team_owner, course: course) + course_team.add_member(team_owner) + end + describe 'validations' do it 'is valid with valid attributes' do @@ -103,7 +107,6 @@ def create_student(suffix) 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) } end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index ee5416a3d..8e2e5aa6e 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -93,8 +93,7 @@ def create_student(suffix) ) end - let!(:team) { create(:mentored_team, user: user, assignment: assignment) } - + let!(:team) { create(:mentored_team, assignment: assignment) } describe 'validations' do it { should validate_presence_of(:mentor) } diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 0106cb971..fe55ac3c1 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -77,7 +77,6 @@ def create_student(suffix) AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end @@ -85,7 +84,6 @@ def create_student(suffix) CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end 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..9c4e660d9 100644 --- a/spec/requests/api/v1/teams_spec.rb +++ b/spec/requests/api/v1/teams_spec.rb @@ -81,7 +81,6 @@ CourseTeam.create!( parent_id: course.id, name: 'team 2', - user_id: team_owner.id ) end @@ -89,7 +88,6 @@ AssignmentTeam.create!( parent_id: assignment.id, name: 'team 1', - user_id: team_owner.id ) end From ca2c5bd6b5ab2e3e73ffcb5e6355a1713a502a35 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Wed, 1 Oct 2025 17:25:03 +0000 Subject: [PATCH 02/11] refactored mentored team as well as tests. --- app/models/assignment.rb | 5 +- app/models/assignment_participant.rb | 2 +- app/models/assignment_team.rb | 1 - app/models/course_team.rb | 1 - app/models/duty.rb | 26 +++ app/models/mentored_team.rb | 88 +++++++--- app/models/participant.rb | 2 +- config/database.yml | 2 +- db/migrate/20250927160433_create_duties.rb | 13 ++ ...20250927171132_add_duty_to_participants.rb | 5 + db/schema.rb | 14 +- spec/factories/participants.rb | 7 +- spec/factories/teams.rb | 14 +- spec/models/mentored_team_spec.rb | 163 ++++++++---------- 14 files changed, 204 insertions(+), 139 deletions(-) create mode 100644 app/models/duty.rb create mode 100644 db/migrate/20250927160433_create_duties.rb create mode 100644 db/migrate/20250927171132_add_duty_to_participants.rb diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 144cd5369..9a9de4b7c 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,7 +11,8 @@ 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 diff --git a/app/models/assignment_participant.rb b/app/models/assignment_participant.rb index 4edeee5c6..ea6f7858b 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -8,7 +8,7 @@ class AssignmentParticipant < Participant def set_handle self.handle = if user.handle.nil? || (user.handle == '') user.name - elsif Participant.exists?(assignment_id: assignment.id, handle: user.handle) + elsif Participant.exists?(parent_id: assignment.id, handle: user.handle, type: 'AssignmentParticipant') user.name else user.handle diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index d5d8a63ad..b9676b2b6 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -3,7 +3,6 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' - validates :assignment, presence: true # Copies the current assignment team to a course team # - Creates a new CourseTeam with a modified name diff --git a/app/models/course_team.rb b/app/models/course_team.rb index 5e36b88a2..22c569863 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -3,7 +3,6 @@ class CourseTeam < Team #Each course team must belong to a course belongs_to :course, class_name: 'Course', foreign_key: 'parent_id' - validates :course, presence: true #adds members to the course team post validation def add_member(user) diff --git a/app/models/duty.rb b/app/models/duty.rb new file mode 100644 index 000000000..67bfb4e8e --- /dev/null +++ b/app/models/duty.rb @@ -0,0 +1,26 @@ +class Duty < ApplicationRecord + belongs_to :assignment + # validates name with format matching regex, length to be at least 3 and + # same name cannot be assigned to multiple duties in particular assignment. + validates :name, + format: { with: /\A[^`!@#\$%\^&*+_=]+\z/, + message: 'Please enter a valid role name' }, + length: { + minimum: 3, + message: 'Role name is too short (minimum is 3 characters)' + }, + uniqueness: { + case_sensitive: false, scope: :assignment, + message: 'The role "%{value}" is already present for this assignment' + } + validates_numericality_of :max_members_for_duty, + only_integer: true, + greater_than_or_equal_to: 1, + message: 'Value for max members for role is invalid' + + # Check if the duty selected is available for selection in that particular team. Checks whether + # current duty count in the team is less than the max_members_for_duty set for that particular duty + def can_be_assigned?(team) + max_members_for_duty > team.participants.select { |team_member| team_member.duty_id == id }.count + end +end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index a6771a6a5..06dbcb8bc 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -1,47 +1,89 @@ # 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 whose + # name includes 'mentor' (case-insensitive). - #Validates the presence of a mentor in the team - validates :mentor, presence: true - - # Custom validation to ensure the team type is 'MentoredTeam' validate :type_must_be_mentored_team + validate :mentor_must_be_present - # Validates the role of the mentor - validate :mentor_must_have_mentor_role + # Return the mentor User (or nil) + def mentor + mentor_participant&.user + end + alias_method :mentor_user, :mentor # adds members to the team who are not mentors def add_member(user) - return false if user == mentor - super(user) + participant = assignment.participants.find_by(user_id: user.id) + return false if participant&.duty&.name&.downcase&.include?('mentor') + + res = super(user) + if res.is_a?(Hash) + res[:success] + else + !!res + end end - # Assigning a user as mentor of the team - # Validates if user has the role and permission to be a mentor + # Assigning a user as mentor of the team def assign_mentor(user) - return false unless user.role&.name&.downcase&.include?('mentor') - self.mentor = user - save + duty = find_mentor_duty + return false unless duty + + participant = assignment.participants.find_or_initialize_by( + user_id: user.id, + parent_id: assignment.id, + type: 'AssignmentParticipant' + ) + + participant.handle ||= (user.try(:handle).presence || user.name) + participant.user_id ||= user.id + participant.parent_id ||= assignment.id + participant.type ||= 'AssignmentParticipant' + + participant.save! + + if participant.duty != duty + participant.duty = duty + participant.save! + end + + unless participants.exists?(id: participant.id) + TeamsParticipant.create!(participant_id: participant.id, team_id: id, user_id: participant.user_id) + end + + true + rescue ActiveRecord::RecordInvalid => e + Rails.logger.debug "MentoredTeam#assign_mentor failed: #{e.record.errors.full_messages.join(', ')}" + false end - # Unassigns mentor from team + # Unassigns mentor from team: removes the duty from the participant(s) def remove_mentor - self.mentor = nil - save + mp = mentor_participant + return false unless mp + mp.update(duty: nil) end private - # Check if the team type is 'MentoredTeam' + def find_mentor_duty + return nil unless assignment&.persisted? + assignment.duties.detect { |d| d.name.to_s.downcase.include?('mentor') } + end + + def mentor_participant + participants.find { |p| p.duty&.name&.downcase&.include?('mentor') } + end + def type_must_be_mentored_team errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' 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 mentor_must_be_present + unless mentor_participant.present? + errors.add(:base, 'mentor must be present') + end end -end +end diff --git a/app/models/participant.rb b/app/models/participant.rb index 94872b3db..f2917c05d 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -7,6 +7,7 @@ class Participant < ApplicationRecord belongs_to :team, optional: true 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 @@ -17,5 +18,4 @@ class Participant < ApplicationRecord def fullname user.fullname end - end diff --git a/config/database.yml b/config/database.yml index 296e53136..7b9674459 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,7 +13,7 @@ default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: root + username: dev password: expertiza host: <%= ENV.fetch("DB_HOST", "db") %> port: <%= ENV.fetch("DB_PORT", "3306") %> diff --git a/db/migrate/20250927160433_create_duties.rb b/db/migrate/20250927160433_create_duties.rb new file mode 100644 index 000000000..848952069 --- /dev/null +++ b/db/migrate/20250927160433_create_duties.rb @@ -0,0 +1,13 @@ +class CreateDuties < ActiveRecord::Migration[8.0] + def change + create_table :duties do |t| + t.string :name + t.integer :max_members_for_duty + t.integer :assignment_id + + t.timestamps + end + + add_index :duties, :assignment_id, name: "index_duties_on_assignment_id" + end +end diff --git a/db/migrate/20250927171132_add_duty_to_participants.rb b/db/migrate/20250927171132_add_duty_to_participants.rb new file mode 100644 index 000000000..688b8724e --- /dev/null +++ b/db/migrate/20250927171132_add_duty_to_participants.rb @@ -0,0 +1,5 @@ +class AddDutyToParticipants < ActiveRecord::Migration[8.0] + def change + add_reference :participants, :duty, foreign_key: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 335fc3f1f..6ff2f17ae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_19_160547) do +ActiveRecord::Schema[8.0].define(version: 2025_09_27_171132) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" @@ -162,6 +162,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.integer "max_members_for_duty" + t.integer "assignment_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assignment_id"], name: "index_duties_on_assignment_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 +241,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" @@ -407,6 +418,7 @@ add_foreign_key "courses", "institutions" add_foreign_key "courses", "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" diff --git a/spec/factories/participants.rb b/spec/factories/participants.rb index 6cccb71e8..092298d1a 100644 --- a/spec/factories/participants.rb +++ b/spec/factories/participants.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true -FactoryBot.define do - factory :participant do - association :user - association :assignment, factory: :assignment - end - +FactoryBot.define do factory :assignment_participant, class: 'AssignmentParticipant' do association :user association :assignment, factory: :assignment diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 3aaf97a3c..1517a247e 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -46,18 +46,8 @@ name { Faker::Team.name } type { 'MentoredTeam' } association :assignment, factory: :assignment - - after(:build) do |team, _evaluator| - mentor_role = Role.find_by(name: 'Mentor') || create(:role, :mentor) - mentor = create(:user, role: mentor_role) - team.mentor ||= mentor - team.parent_id ||= team.assignment.id + after(:build) do |t| + t.parent_id ||= t.assignment&.id end end - - factory :teams_participant, class: 'TeamsParticipant' do - team - participant - user { participant.user } - end end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index 8e2e5aa6e..715348840 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -1,153 +1,136 @@ # frozen_string_literal: true +# spec/models/mentored_team_spec.rb 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 - ) - 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, + role_id: roles[:instructor].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(:mentor_role) { create(:role, :mentor) } + let!(:course) { Course.create!(name: "Course 1", instructor_id: instructor.id, institution_id: institution.id, directory_path: "/course1") } - let(:mentor) do + let(:user) do User.create!( - name: "mentor_user", - full_name: "Mentor User", - email: "mentor@example.com", + name: "student_user", + full_name: "Student User", + email: "student@example.com", password_digest: "password", - role_id: mentor_role.id, - institution_id: institution.id + role_id: roles[:student].id, + institution_id: institution.id ) end let(:mentored_team) do - MentoredTeam.create!( - parent_id: mentor.id, - assignment: assignment, - name: 'team 3', - user_id: team_owner.id, - mentor: mentor - ) + t = MentoredTeam.new(name: "MentoredTeam #{SecureRandom.hex(4)}", parent_id: assignment.id, assignment: assignment) + t.save!(validate: false) + t 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 - ) - end - - let!(:team) { create(:mentored_team, assignment: assignment) } - 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') + mt = MentoredTeam.new(name: 'mt', parent_id: assignment.id, assignment: assignment) + expect(mt.type).to eq('MentoredTeam') 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 'requires a mentor participant (duty present) on the team' do + # Build an unsaved MentoredTeam with no participants + mt = MentoredTeam.new(name: 'mt_no_mentor', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') + expect(mt).not_to be_valid + expect(mt.errors[:base]).to include('mentor must be present') + end + + it 'rejects a mentor participant who does not have a mentor duty' do + # Build an in-memory team and attach a participant with a non-mentor duty + mt = MentoredTeam.new(name: 'mt_bad_duty', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') + + # create a non-mentor duty and an assignment participant + non_mentor_duty = Duty.new(name: 'helper', assignment: assignment, max_members_for_duty: 1) + participant = build(:assignment_participant, assignment: assignment) + participant.duty = non_mentor_duty + + # attach participant via teams_participants in-memory + mt.teams_participants.build(participant: participant, user: participant.user) + expect(mt).not_to be_valid + expect(mt.errors[:base]).to include('mentor must be present') 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) } + let(:mentor_user) { create(:user, name: 'mentor_user', email: "mentor_#{SecureRandom.hex(3)}@example.com") } before do + # ensure assignment participant exists for enrolled_user @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) 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 + # mentored_team persisted via let(:mentored_team) (validate:false) + result = mentored_team.add_member(enrolled_user) + # MentoredTeam#add_member returns boolean true/false; underlying Team#add_member returns {success: true} + expect(result).to be_truthy + expect(mentored_team.participants.map(&:user_id)).to include(enrolled_user.id) 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 + # create a mentor duty and participant that is a mentor + mentor_duty = Duty.create!(name: 'Mentor', assignment: assignment, max_members_for_duty: 1) + mentor_participant = create(:assignment_participant, user: mentor_user, assignment: assignment) + mentor_participant.update!(duty: mentor_duty) + + # attempting to add a mentor as a normal member should fail + res = mentored_team.add_member(mentor_user) + expect(res).to be_falsey + expect(mentored_team.participants.map(&:user_id)).not_to include(mentor_user.id) 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) + # add a mentor duty to assignment so find_mentor_duty can find it + Duty.create!(name: 'mentor role', assignment: assignment, max_members_for_duty: 1) + + # call assign_mentor on mentored_team for a user not previously a participant + res = mentored_team.assign_mentor(mentor_user) + expect(res).to be true + + # verify the participant was created and has a duty that includes 'mentor' + mp = assignment.participants.find_by(user_id: mentor_user.id) + expect(mp).not_to be_nil + expect(mp.duty).not_to be_nil + expect(mp.duty.name.downcase).to include('mentor') + + # ensure the user was also added to the team (TeamsParticipant created) + expect(mentored_team.participants.map(&:user_id)).to include(mentor_user.id) 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) + # Remove any mentor duty on assignment (ensure none exist) + assignment.duties.destroy_all if assignment.duties.any? + + # assign_mentor should return false when no mentor duty exists + res = mentored_team.assign_mentor(mentor_user) + expect(res).to be false end end -end +end From c1ce197e6e74dd4df47c20127410b063e8f27393 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Wed, 8 Oct 2025 04:39:29 +0000 Subject: [PATCH 03/11] Added correct Duties support for Mentored Team --- .../assignments_duties_controller.rb | 36 ++++ .../concerns/authorization_helper.rb | 160 ++++++++++++++++++ app/controllers/duties_controller.rb | 90 ++++++++++ .../join_team_requests_controller.rb | 10 +- app/models/assignment.rb | 2 + app/models/assignment_participant.rb | 2 +- app/models/assignments_duty.rb | 4 + app/models/duty.rb | 33 +--- app/models/mentored_team.rb | 4 +- config/routes.rb | 4 + ...ies.rb => 20250803165255_create_duties.rb} | 4 - ...0250803170802_create_assignments_duties.rb | 10 ++ ...0250803170832_add_duty_to_participants.rb} | 2 +- ...add_instructor_id_and_private_to_duties.rb | 8 + db/schema.rb | 20 ++- spec/models/mentored_team_spec.rb | 54 +++--- 16 files changed, 377 insertions(+), 66 deletions(-) create mode 100644 app/controllers/assignments_duties_controller.rb create mode 100644 app/controllers/concerns/authorization_helper.rb create mode 100644 app/controllers/duties_controller.rb create mode 100644 app/models/assignments_duty.rb rename db/migrate/{20250927160433_create_duties.rb => 20250803165255_create_duties.rb} (51%) create mode 100644 db/migrate/20250803170802_create_assignments_duties.rb rename db/migrate/{20250927171132_add_duty_to_participants.rb => 20250803170832_add_duty_to_participants.rb} (53%) create mode 100644 db/migrate/20250807224208_add_instructor_id_and_private_to_duties.rb 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/models/assignment.rb b/app/models/assignment.rb index 9a9de4b7c..1824b4bb0 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -15,6 +15,8 @@ class Assignment < ApplicationRecord 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 ea6f7858b..8cd4a0164 100644 --- a/app/models/assignment_participant.rb +++ b/app/models/assignment_participant.rb @@ -2,6 +2,7 @@ class AssignmentParticipant < Participant belongs_to :user + belongs_to :duty, optional: true validates :handle, presence: true @@ -15,5 +16,4 @@ def set_handle end self.save end - end diff --git a/app/models/assignments_duty.rb b/app/models/assignments_duty.rb new file mode 100644 index 000000000..eeb3519b0 --- /dev/null +++ b/app/models/assignments_duty.rb @@ -0,0 +1,4 @@ +class AssignmentsDuty < ApplicationRecord + belongs_to :assignment + belongs_to :duty +end \ No newline at end of file diff --git a/app/models/duty.rb b/app/models/duty.rb index 67bfb4e8e..92671b373 100644 --- a/app/models/duty.rb +++ b/app/models/duty.rb @@ -1,26 +1,9 @@ class Duty < ApplicationRecord - belongs_to :assignment - # validates name with format matching regex, length to be at least 3 and - # same name cannot be assigned to multiple duties in particular assignment. - validates :name, - format: { with: /\A[^`!@#\$%\^&*+_=]+\z/, - message: 'Please enter a valid role name' }, - length: { - minimum: 3, - message: 'Role name is too short (minimum is 3 characters)' - }, - uniqueness: { - case_sensitive: false, scope: :assignment, - message: 'The role "%{value}" is already present for this assignment' - } - validates_numericality_of :max_members_for_duty, - only_integer: true, - greater_than_or_equal_to: 1, - message: 'Value for max members for role is invalid' - - # Check if the duty selected is available for selection in that particular team. Checks whether - # current duty count in the team is less than the max_members_for_duty set for that particular duty - def can_be_assigned?(team) - max_members_for_duty > team.participants.select { |team_member| team_member.duty_id == id }.count - end -end + 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 \ No newline at end of file diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index 06dbcb8bc..7ea6402d4 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -13,7 +13,7 @@ def mentor end alias_method :mentor_user, :mentor - # adds members to the team who are not mentors + # Adds members to the team who are not mentors def add_member(user) participant = assignment.participants.find_by(user_id: user.id) return false if participant&.duty&.name&.downcase&.include?('mentor') @@ -83,7 +83,7 @@ def type_must_be_mentored_team def mentor_must_be_present unless mentor_participant.present? - errors.add(:base, 'mentor must be present') + errors.add(:base, 'a mentor must be present') end end end 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/20250927160433_create_duties.rb b/db/migrate/20250803165255_create_duties.rb similarity index 51% rename from db/migrate/20250927160433_create_duties.rb rename to db/migrate/20250803165255_create_duties.rb index 848952069..e8846bbd2 100644 --- a/db/migrate/20250927160433_create_duties.rb +++ b/db/migrate/20250803165255_create_duties.rb @@ -2,12 +2,8 @@ class CreateDuties < ActiveRecord::Migration[8.0] def change create_table :duties do |t| t.string :name - t.integer :max_members_for_duty - t.integer :assignment_id t.timestamps end - - add_index :duties, :assignment_id, name: "index_duties_on_assignment_id" 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/20250927171132_add_duty_to_participants.rb b/db/migrate/20250803170832_add_duty_to_participants.rb similarity index 53% rename from db/migrate/20250927171132_add_duty_to_participants.rb rename to db/migrate/20250803170832_add_duty_to_participants.rb index 688b8724e..7701cd7a8 100644 --- a/db/migrate/20250927171132_add_duty_to_participants.rb +++ b/db/migrate/20250803170832_add_duty_to_participants.rb @@ -1,5 +1,5 @@ class AddDutyToParticipants < ActiveRecord::Migration[8.0] def change - add_reference :participants, :duty, foreign_key: true, index: true + 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/schema.rb b/db/schema.rb index 6ff2f17ae..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_09_27_171132) 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" @@ -164,11 +173,11 @@ create_table "duties", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name" - t.integer "max_members_for_duty" - t.integer "assignment_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["assignment_id"], name: "index_duties_on_assignment_id" + 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| @@ -415,8 +424,11 @@ 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" diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index 715348840..82c1d64ba 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# spec/models/mentored_team_spec.rb require 'rails_helper' RSpec.describe MentoredTeam, type: :model do @@ -14,6 +13,7 @@ end let(:instructor) do + # This user will serve as the instructor for the assignment and duties User.create!( name: "instructor", full_name: "Instructor User", @@ -24,8 +24,8 @@ ) 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) { 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(:user) do User.create!( @@ -33,12 +33,13 @@ full_name: "Student User", email: "student@example.com", password_digest: "password", - role_id: roles[:student].id, + role_id: roles[:student].id, institution_id: institution.id ) end let(:mentored_team) do + # Create the team with validation disabled, so we can test validation separately t = MentoredTeam.new(name: "MentoredTeam #{SecureRandom.hex(4)}", parent_id: assignment.id, assignment: assignment) t.save!(validate: false) t @@ -54,22 +55,24 @@ # Build an unsaved MentoredTeam with no participants mt = MentoredTeam.new(name: 'mt_no_mentor', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') expect(mt).not_to be_valid - expect(mt.errors[:base]).to include('mentor must be present') + expect(mt.errors[:base]).to include('a mentor must be present') end it 'rejects a mentor participant who does not have a mentor duty' do # Build an in-memory team and attach a participant with a non-mentor duty mt = MentoredTeam.new(name: 'mt_bad_duty', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') - # create a non-mentor duty and an assignment participant - non_mentor_duty = Duty.new(name: 'helper', assignment: assignment, max_members_for_duty: 1) + # Create a Duty and associate it with the assignment through the join table + non_mentor_duty = Duty.create!(name: 'helper', instructor: instructor) + assignment.duties << non_mentor_duty # This creates the AssignmentsDuty record + participant = build(:assignment_participant, assignment: assignment) participant.duty = non_mentor_duty - # attach participant via teams_participants in-memory + # Attach participant via teams_participants in-memory mt.teams_participants.build(participant: participant, user: participant.user) expect(mt).not_to be_valid - expect(mt.errors[:base]).to include('mentor must be present') + expect(mt.errors[:base]).to include('a mentor must be present') end end @@ -79,56 +82,59 @@ describe 'team management' do let(:enrolled_user) { create(:user) } - let(:mentor_user) { create(:user, name: 'mentor_user', email: "mentor_#{SecureRandom.hex(3)}@example.com") } + let(:mentor_user) { create(:user, name: 'mentor_user', email: "mentor_#{SecureRandom.hex(3)}@example.com") } before do - # ensure assignment participant exists for enrolled_user + # Ensure an assignment participant exists for the enrolled_user @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) end it 'can add enrolled user' do - # mentored_team persisted via let(:mentored_team) (validate:false) result = mentored_team.add_member(enrolled_user) - # MentoredTeam#add_member returns boolean true/false; underlying Team#add_member returns {success: true} expect(result).to be_truthy expect(mentored_team.participants.map(&:user_id)).to include(enrolled_user.id) end it 'cannot add mentor as member' do - # create a mentor duty and participant that is a mentor - mentor_duty = Duty.create!(name: 'Mentor', assignment: assignment, max_members_for_duty: 1) + # SCHEMA CHANGE: Create a mentor duty and associate it with the instructor + mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) + # Link the duty to the assignment + assignment.duties << mentor_duty + mentor_participant = create(:assignment_participant, user: mentor_user, assignment: assignment) mentor_participant.update!(duty: mentor_duty) - # attempting to add a mentor as a normal member should fail + # Attempting to add a mentor as a normal member should fail res = mentored_team.add_member(mentor_user) expect(res).to be_falsey expect(mentored_team.participants.map(&:user_id)).not_to include(mentor_user.id) end it 'can assign new mentor' do - # add a mentor duty to assignment so find_mentor_duty can find it - Duty.create!(name: 'mentor role', assignment: assignment, max_members_for_duty: 1) + # Create a mentor duty and associate it with the assignment + mentor_duty = Duty.create!(name: 'mentor role', instructor: instructor) + assignment.duties << mentor_duty - # call assign_mentor on mentored_team for a user not previously a participant + # Call assign_mentor on the team for a user not previously a participant res = mentored_team.assign_mentor(mentor_user) expect(res).to be true - # verify the participant was created and has a duty that includes 'mentor' + # Verify the participant was created and has a duty that includes 'mentor' mp = assignment.participants.find_by(user_id: mentor_user.id) expect(mp).not_to be_nil expect(mp.duty).not_to be_nil expect(mp.duty.name.downcase).to include('mentor') - # ensure the user was also added to the team (TeamsParticipant created) + # Ensure the user was also added to the team (TeamsParticipant created) expect(mentored_team.participants.map(&:user_id)).to include(mentor_user.id) end - it 'cannot assign non-mentor as mentor' do - # Remove any mentor duty on assignment (ensure none exist) + it 'cannot assign mentor when no mentor duty is available' do + # This test remains valid. `assignment.duties` is now an association through + # the join table, and destroying it will correctly remove the associations. assignment.duties.destroy_all if assignment.duties.any? - # assign_mentor should return false when no mentor duty exists + # assign_mentor should return false when no mentor duty exists on the assignment res = mentored_team.assign_mentor(mentor_user) expect(res).to be false end From 8b4bf83db65bcb3c87fc2b96d7453e69e0ff05b8 Mon Sep 17 00:00:00 2001 From: Prithish Samanta Date: Wed, 8 Oct 2025 19:08:24 -0400 Subject: [PATCH 04/11] Changed Rails Version (#211) * Changed Rails Version * Update danger.yml * Update danger_target.yml * Update TestPR.yml * Update main.yml * Update danger_target.yml * Update main.yml * Update danger.yml * Updated ruby version to 3.4.5 --- spec/factories/participants.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/factories/participants.rb b/spec/factories/participants.rb index 092298d1a..66333ac45 100644 --- a/spec/factories/participants.rb +++ b/spec/factories/participants.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -FactoryBot.define do +FactoryBot.define do factory :assignment_participant, class: 'AssignmentParticipant' do association :user association :assignment, factory: :assignment From 6b9754d5e63710477a4e4e7e74803048aaef633e Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Tue, 19 Aug 2025 17:02:39 +0000 Subject: [PATCH 05/11] refactored team schema as well as tests [remaining: mentored team] --- Gemfile | 2 +- Gemfile.lock | 36 +++++++++++++++++++++++++++++++++--- config/database.yml | 4 ++-- 3 files changed, 36 insertions(+), 6 deletions(-) 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/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") %> From 3585728e950da1dce60fe3409a8350437aac650d Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Wed, 1 Oct 2025 17:25:03 +0000 Subject: [PATCH 06/11] refactored mentored team as well as tests. --- app/models/duty.rb | 2 +- config/database.yml | 2 +- db/migrate/20250927160433_create_duties.rb | 13 +++++++++++++ .../20250927171132_add_duty_to_participants.rb | 5 +++++ db/schema.rb | 2 +- spec/factories/participants.rb | 2 +- 6 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20250927160433_create_duties.rb create mode 100644 db/migrate/20250927171132_add_duty_to_participants.rb diff --git a/app/models/duty.rb b/app/models/duty.rb index 92671b373..5c62cfc44 100644 --- a/app/models/duty.rb +++ b/app/models/duty.rb @@ -6,4 +6,4 @@ class Duty < ApplicationRecord has_many :assignment_participants has_many :teammate_review_questionnaires belongs_to :instructor, class_name: 'User' -end \ No newline at end of file +end diff --git a/config/database.yml b/config/database.yml index 59de540eb..f125aa885 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,7 +13,7 @@ default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: root + username: dev password: expertiza host: <%= ENV.fetch("DB_HOST", "localhost") %> port: <%= ENV.fetch("DB_PORT", "3306") %> diff --git a/db/migrate/20250927160433_create_duties.rb b/db/migrate/20250927160433_create_duties.rb new file mode 100644 index 000000000..848952069 --- /dev/null +++ b/db/migrate/20250927160433_create_duties.rb @@ -0,0 +1,13 @@ +class CreateDuties < ActiveRecord::Migration[8.0] + def change + create_table :duties do |t| + t.string :name + t.integer :max_members_for_duty + t.integer :assignment_id + + t.timestamps + end + + add_index :duties, :assignment_id, name: "index_duties_on_assignment_id" + end +end diff --git a/db/migrate/20250927171132_add_duty_to_participants.rb b/db/migrate/20250927171132_add_duty_to_participants.rb new file mode 100644 index 000000000..688b8724e --- /dev/null +++ b/db/migrate/20250927171132_add_duty_to_participants.rb @@ -0,0 +1,5 @@ +class AddDutyToParticipants < ActiveRecord::Migration[8.0] + def change + add_reference :participants, :duty, foreign_key: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 570e3ba75..a969bb43d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_08_19_160547) do +ActiveRecord::Schema[8.0].define(version: 2025_09_27_171132) do create_table "account_requests", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "username" t.string "full_name" diff --git a/spec/factories/participants.rb b/spec/factories/participants.rb index 66333ac45..092298d1a 100644 --- a/spec/factories/participants.rb +++ b/spec/factories/participants.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -FactoryBot.define do +FactoryBot.define do factory :assignment_participant, class: 'AssignmentParticipant' do association :user association :assignment, factory: :assignment From bcecc798dab12a997c65866bd469c8e0cf1c8d3e Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Wed, 8 Oct 2025 04:39:29 +0000 Subject: [PATCH 07/11] Added correct Duties support for Mentored Team --- db/migrate/20250927160433_create_duties.rb | 13 ------------- .../20250927171132_add_duty_to_participants.rb | 5 ----- db/schema.rb | 2 +- 3 files changed, 1 insertion(+), 19 deletions(-) delete mode 100644 db/migrate/20250927160433_create_duties.rb delete mode 100644 db/migrate/20250927171132_add_duty_to_participants.rb diff --git a/db/migrate/20250927160433_create_duties.rb b/db/migrate/20250927160433_create_duties.rb deleted file mode 100644 index 848952069..000000000 --- a/db/migrate/20250927160433_create_duties.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateDuties < ActiveRecord::Migration[8.0] - def change - create_table :duties do |t| - t.string :name - t.integer :max_members_for_duty - t.integer :assignment_id - - t.timestamps - end - - add_index :duties, :assignment_id, name: "index_duties_on_assignment_id" - end -end diff --git a/db/migrate/20250927171132_add_duty_to_participants.rb b/db/migrate/20250927171132_add_duty_to_participants.rb deleted file mode 100644 index 688b8724e..000000000 --- a/db/migrate/20250927171132_add_duty_to_participants.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddDutyToParticipants < ActiveRecord::Migration[8.0] - def change - add_reference :participants, :duty, foreign_key: true, index: true - end -end diff --git a/db/schema.rb b/db/schema.rb index a969bb43d..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_09_27_171132) 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" From 93b9a76439b1cfa15290262b618a46f9bf7d040f Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Wed, 15 Oct 2025 16:03:01 +0000 Subject: [PATCH 08/11] fetch commit --- app/controllers/teams_controller.rb | 26 ++++++++++------- app/models/mentored_team.rb | 18 +++++------- app/models/team.rb | 45 ++++++++++------------------- config/database.yml | 2 +- 4 files changed, 39 insertions(+), 52 deletions(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 56599cd83..11331f87a 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -40,26 +40,30 @@ 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) + # Determine the correct type of participant (Assignment or Course) based on the team type. + participant_class = @team.is_a?(AssignmentTeam) ? AssignmentParticipant : CourseParticipant + + # Find the specific participant record for this user in the team's context. + participant = participant_class.find_by(user_id: user.id, parent_id: @team.parent_id) + + # If no participant record exists, the user isn't part of the assignment/course. 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 context."] }, 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 - render json: { error: 'User not found' }, status: :not_found end # DELETE /teams/:id/members/:user_id diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index 7ea6402d4..6a6544d96 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -14,16 +14,14 @@ def mentor alias_method :mentor_user, :mentor # Adds members to the team who are not mentors - def add_member(user) - participant = assignment.participants.find_by(user_id: user.id) - return false if participant&.duty&.name&.downcase&.include?('mentor') - - res = super(user) - if res.is_a?(Hash) - res[:success] - else - !!res + def add_member(participant) + # Fail fast if the participant's duty is 'mentor'. + if participant.duty&.name&.downcase&.include?('mentor') + return { success: false, error: 'Mentors cannot be added as regular members.' } end + + # If not a mentor, proceed with the standard add_member logic. + super(participant) end # Assigning a user as mentor of the team @@ -44,7 +42,7 @@ def assign_mentor(user) participant.save! - if participant.duty != duty + unless participant.duty == duty participant.duty = duty participant.save! end diff --git a/app/models/team.rb b/app/models/team.rb index 86435c7a6..387f395d6 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -45,36 +45,21 @@ def participant_on_team?(participant) scope.teams.any? { |team| team.participants.include?(participant) } end - # Adds participant in the team - def add_member(participant_or_user) - participant = - case participant_or_user - when AssignmentParticipant, CourseParticipant - participant_or_user - when 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 - - 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: "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? - - team_participant = TeamsParticipant.create( - participant_id: participant.id, - team_id: id, - user_id: participant.user_id - ) - - 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 } + # Adds a participant to the team. + # This method now expects a Participant object directly. + def add_member(participant) + # Fail fast if the team is already full. + return { success: false, error: "Team is at full capacity." } if full? + + # Check if this participant is already on a team in this context. + return { success: false, error: "Participant is already on a team for this context." } if participant_on_team?(participant) + + # Use create! to add the participant to the team. + teams_participants.create!(participant: participant, user: participant.user) + { success: true } + rescue ActiveRecord::RecordInvalid => e + # Catch potential validation errors from TeamsParticipant. + { success: false, error: e.record.errors.full_messages.join(', ') } end # Determines whether a given participant is eligible to join the team. diff --git a/config/database.yml b/config/database.yml index f125aa885..59de540eb 100644 --- a/config/database.yml +++ b/config/database.yml @@ -13,7 +13,7 @@ default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - username: dev + username: root password: expertiza host: <%= ENV.fetch("DB_HOST", "localhost") %> port: <%= ENV.fetch("DB_PORT", "3306") %> From 8316ab5b7cb18296119cf80131be5fa326a6f3e7 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Sat, 25 Oct 2025 21:06:18 +0000 Subject: [PATCH 09/11] new refactor --- app/controllers/teams_controller.rb | 39 ++--- app/models/assignment_participant.rb | 13 +- app/models/assignment_team.rb | 67 ++++++-- app/models/course_participant.rb | 20 +-- app/models/course_team.rb | 56 ++++--- app/models/mentored_team.rb | 69 +++++--- app/models/participant.rb | 21 ++- app/models/team.rb | 120 +++++++------ app/serializers/team_serializer.rb | 16 +- db/seeds.rb | 15 +- spec/models/assignment_team_spec.rb | 151 +++++++++++++++-- spec/models/course_team_spec.rb | 165 ++++++++++++++++-- spec/models/mentored_team_spec.rb | 173 +++++++++++++++++-- spec/models/team_spec.rb | 95 ++++++++++- spec/rails_helper.rb | 26 +-- spec/requests/api/v1/teams_spec.rb | 242 +++++++++++++++++++++++---- 16 files changed, 1042 insertions(+), 246 deletions(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 11331f87a..04304c58b 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 @@ -45,15 +44,16 @@ def add_member # Find the user specified in the request. user = User.find(team_participant_params[:user_id]) - # Determine the correct type of participant (Assignment or Course) based on the team type. - participant_class = @team.is_a?(AssignmentTeam) ? AssignmentParticipant : CourseParticipant - - # Find the specific participant record for this user in the team's context. - participant = participant_class.find_by(user_id: user.id, 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 + ) - # If no participant record exists, the user isn't part of the assignment/course. unless participant - return render json: { errors: ["#{user.name} is not a participant in this context."] }, status: :unprocessable_entity + return render json: { + errors: ["#{user.name} is not a participant in this #{@team.context_label}."] + }, status: :unprocessable_entity end # Delegate the add operation to the Team model with the found participant. @@ -64,13 +64,15 @@ def add_member else render json: { errors: [result[:error]] }, status: :unprocessable_entity end + rescue ActiveRecord::RecordNotFound => e + render json: { error: 'User not found' }, status: :not_found end # DELETE /teams/:id/members/:user_id # 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 @@ -99,20 +101,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/models/assignment_participant.rb b/app/models/assignment_participant.rb index 8cd4a0164..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?(parent_id: assignment.id, handle: user.handle, type: 'AssignmentParticipant') - 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 b9676b2b6..fe7d7a69a 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -4,38 +4,71 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' + # Implement abstract methods from Team + def parent_entity + assignment + end + + def participant_class + AssignmentParticipant + end + + def context_label + 'assignment' + end + + # Override max_team_size to pull from assignment + def max_team_size + assignment&.max_team_size + end + # 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 - course: course # Associates new team with the given course + name: "#{name} (Course)", + 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 + copy_members_to_team(course_team, course) end - course_team # Returns the newly created course team object + + course_team + end + + # Copies the current assignment team to another assignment team + def copy_to_assignment_team(target_assignment) + new_team = AssignmentTeam.new( + name: "#{name} (Copy)", + parent_id: target_assignment.id + ) + + if new_team.save + copy_members_to_team(new_team, target_assignment) + end + + new_team 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) end private - - # 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') + def copy_members_to_team(target_team, target_parent) + participants.each do |assignment_participant| + # Find or create corresponding participant in target context + target_participant = target_team.participant_class.find_or_create_by!( + user_id: assignment_participant.user_id, + parent_id: target_parent.id + ) do |p| + p.handle = assignment_participant.handle + end + + target_team.add_member(target_participant) end end -end +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 22c569863..331a7d9db 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -4,42 +4,54 @@ class CourseTeam < Team #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) + # Implement abstract methods from Team + def parent_entity + course + end + + def participant_class + CourseParticipant + end + + def context_label + 'course' end # Copies the current course team to an assignment team - # - Creates a new AssignmentTeam with a modified name - # - Copies team members from the course team to the assignment team def copy_to_assignment_team(assignment) assignment_team = AssignmentTeam.new( - name: "#{name} (Assignment)", # Appends "(Assignment)" to the team name - assignment: assignment # Associates the course team with an assignment + name: "#{name} (Assignment)", + 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 + copy_members_to_team(assignment_team, assignment) end - assignment_team # Returns the newly created assignment team object + + assignment_team + end + + # Copies the current course team to another course team + def copy_to_course_team(target_course) + new_team = CourseTeam.new( + name: "#{name} (Copy)", + parent_id: target_course.id + ) + + if new_team.save + copy_members_to_team(new_team, target_course) + end + + new_team 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) } + course.participants.exists?(user: user) end private - # 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') - end - end -end + +end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index 6a6544d96..515f2b74d 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -4,8 +4,8 @@ class MentoredTeam < AssignmentTeam # The mentor is determined by a participant on this team having a Duty whose # name includes 'mentor' (case-insensitive). - validate :type_must_be_mentored_team - validate :mentor_must_be_present + # validate :type_must_be_mentored_team + validate :mentor_must_be_present, on: :update # Return the mentor User (or nil) def mentor @@ -13,21 +13,30 @@ def mentor end alias_method :mentor_user, :mentor - # Adds members to the team who are not mentors - def add_member(participant) - # Fail fast if the participant's duty is 'mentor'. - if participant.duty&.name&.downcase&.include?('mentor') + # Override add_member to prevent mentors from being added as regular members + # This approach is better than rejecting in the method - we separate concerns + def add_non_mentor_member(participant) + if participant_is_mentor?(participant) return { success: false, error: 'Mentors cannot be added as regular members.' } end - # If not a mentor, proceed with the standard add_member logic. super(participant) end - # Assigning a user as mentor of the team + # Public interface for adding members - delegates to appropriate method + def add_member(participant) + # Check if this is a mentor being added + if participant_is_mentor?(participant) + return { success: false, error: 'Use assign_mentor to add mentors to the team.' } + end + + add_non_mentor_member(participant) + end + + # Separate method for assigning mentors def assign_mentor(user) duty = find_mentor_duty - return false unless duty + return { success: false, error: 'No mentor duty found for this assignment.' } unless duty participant = assignment.participants.find_or_initialize_by( user_id: user.id, @@ -48,36 +57,58 @@ def assign_mentor(user) end unless participants.exists?(id: participant.id) - TeamsParticipant.create!(participant_id: participant.id, team_id: id, user_id: participant.user_id) + TeamsParticipant.create!( + participant_id: participant.id, + team_id: id, + user_id: participant.user_id + ) end - true + { success: true } rescue ActiveRecord::RecordInvalid => e Rails.logger.debug "MentoredTeam#assign_mentor failed: #{e.record.errors.full_messages.join(', ')}" - false + { success: false, error: e.record.errors.full_messages.join(', ') } end - # Unassigns mentor from team: removes the duty from the participant(s) + # Unassigns mentor from team def remove_mentor mp = mentor_participant - return false unless mp - mp.update(duty: nil) + 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 + + # 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 private + def participant_is_mentor?(participant) + participant.duty&.name&.downcase&.include?('mentor') + end + def find_mentor_duty return nil unless assignment&.persisted? assignment.duties.detect { |d| d.name.to_s.downcase.include?('mentor') } end def mentor_participant - participants.find { |p| p.duty&.name&.downcase&.include?('mentor') } + participants.find { |p| participant_is_mentor?(p) } end - def type_must_be_mentored_team - errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' - end + # def type_must_be_mentored_team + # errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' + # end def mentor_must_be_present unless mentor_participant.present? diff --git a/app/models/participant.rb b/app/models/participant.rb index f2917c05d..f103a6591 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -16,6 +16,25 @@ class Participant < ApplicationRecord # Methods def fullname - user.fullname + user.full_name + end + + def parent_context + # Subclasses must implement this + raise NotImplementedError, "#{self.class} must implement #parent_context" + end + + def set_handle + desired = user.handle.to_s.strip + 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 387f395d6..69ee9e798 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -1,96 +1,116 @@ # 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_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 + def parent_entity + raise NotImplementedError, "#{self.class} must implement #parent_entity" + end + + def participant_class + raise NotImplementedError, "#{self.class} must implement #participant_class" + end + + def context_label + raise NotImplementedError, "#{self.class} must implement #context_label" + end + + # Template method - uses polymorphic behavior from subclasses + def max_team_size + nil # Default for teams without size limits (overridden in AssignmentTeam) + end + def has_member?(user) participants.exists?(user_id: user.id) end def full? - return false unless is_a?(AssignmentTeam) && assignment&.max_team_size - - participants.count >= assignment.max_team_size + return false unless max_team_size + 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 - - # “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) } + return false unless parent_entity + + TeamsParticipant + .where(participant_id: participant.id, team_id: parent_entity.teams.select(:id)) + .exists? end - # Adds a participant to the team. - # This method now expects a Participant object directly. + # Adds a participant to the team def add_member(participant) - # Fail fast if the team is already full. - return { success: false, error: "Team is at full capacity." } if full? + eligibility = can_participant_join_team?(participant) + return eligibility unless eligibility[:success] - # Check if this participant is already on a team in this context. + return { success: false, error: "Team is at full capacity." } if full? return { success: false, error: "Participant is already on a team for this context." } if participant_on_team?(participant) - - # Use create! to add the participant to the team. + + validation_result = validate_participant_type(participant) + return validation_result unless validation_result[:success] + teams_participants.create!(participant: participant, user: participant.user) { success: true } rescue ActiveRecord::RecordInvalid => e - # Catch potential validation errors from TeamsParticipant. { 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( + registered = participant_class.find_by( user_id: participant.user_id, - parent_id: scope.id + parent_id: parent_entity.id ) unless registered - return { success: false, error: "#{participant.user.name} is not a participant in this #{label}" } + return { success: false, error: "#{participant.user.name} is not a participant in this #{context_label}" } end - # All checks passed; participant is eligible to join the team { success: true } + end + + def size + participants.count + end + + def empty? + participants.empty? + end + + protected + + # Hook for subclasses to validate participant types + def validate_participant_type(participant) + unless participant.is_a?(participant_class) + return { + success: false, + error: "Cannot add #{participant.class.name} to #{self.class.name}. Expected #{participant_class.name}." + } + end + { success: true } + end + def validate_membership(user) + # Default implementation - override in subclasses if needed + true end end diff --git a/app/serializers/team_serializer.rb b/app/serializers/team_serializer.rb index 575d0a736..03ebd6f70 100644 --- a/app/serializers/team_serializer.rb +++ b/app/serializers/team_serializer.rb @@ -1,7 +1,7 @@ # 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 @@ -9,15 +9,19 @@ def users end def team_size - object.teams_participants.count + object.size end + # Use polymorphic method instead of type checking def max_team_size - # Only AssignmentTeams have a max size, from the assignment - object.is_a?(AssignmentTeam) ? object.assignment&.max_team_size : nil + object.max_team_size end - def assignment_id - object.is_a?(AssignmentTeam) ? object.parent_id : nil + def parent_id + object.parent_id + end + + def parent_type + object.context_label end end 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/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb index aa6bd6384..936d22883 100644 --- a/spec/models/assignment_team_spec.rb +++ b/spec/models/assignment_team_spec.rb @@ -69,8 +69,9 @@ def create_student(suffix) end before do - participant = create(:assignment_participant, user: team_owner, assignment: assignment) - assignment_team.add_member(team_owner) + # Create participant for team_owner and add them to the team + @owner_participant = create(:assignment_participant, user: team_owner, assignment: assignment) + assignment_team.add_member(@owner_participant) end describe 'validations' do @@ -88,7 +89,25 @@ def create_student(suffix) 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'") + expect(team.errors[:type]).to include("must be 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'") + end + end + + 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 @@ -96,18 +115,86 @@ def create_student(suffix) 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") + # Create participant for wrong assignment or none at all + # In this case, we're not creating a participant, so the check should fail + + # Try to add user without proper participant + unenrolled_participant = AssignmentParticipant.new(user: unenrolled_user) expect { - assignment_team.add_member(unenrolled_user) + assignment_team.add_member(unenrolled_participant) }.not_to change(TeamsParticipant, :count) end - it 'returns false' do - unenrolled_user = create_student("add_user") + it 'returns error hash when participant not registered' do + unenrolled_user = create_student("add_user_2") + # Create participant but for different assignment + other_assignment = Assignment.create!(name: "Other Assignment", instructor_id: instructor.id, max_team_size: 3) + other_participant = AssignmentParticipant.create!( + parent_id: other_assignment.id, + user: unenrolled_user, + handle: unenrolled_user.name + ) - result = assignment_team.add_member(unenrolled_user) + result = assignment_team.add_member(other_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(/not a participant in this assignment/) + end + end + + context 'when user is properly enrolled' do + it 'adds the member successfully' do + enrolled_user = create_student("enrolled_user") + participant = AssignmentParticipant.create!( + parent_id: assignment.id, + user: enrolled_user, + handle: enrolled_user.name + ) + + expect { + result = assignment_team.add_member(participant) + expect(result[:success]).to be true + }.to change(TeamsParticipant, :count).by(1) + end + + it 'returns success hash' do + enrolled_user = create_student("enrolled_user_2") + participant = AssignmentParticipant.create!( + parent_id: assignment.id, + user: enrolled_user, + handle: enrolled_user.name + ) + + result = assignment_team.add_member(participant) + expect(result[:success]).to be true + expect(result[:error]).to be_nil + end + end + + context 'when team is full' do + it 'rejects new members' do + # Team already has 1 member (team_owner), add 2 more to reach max_team_size of 3 + 2.times do |i| + user = create_student("filler_#{i}") + participant = AssignmentParticipant.create!( + parent_id: assignment.id, + user: user, + handle: user.name + ) + assignment_team.add_member(participant) + end + + # Try to add one more + overflow_user = create_student("overflow") + overflow_participant = AssignmentParticipant.create!( + parent_id: assignment.id, + user: overflow_user, + handle: overflow_user.name + ) + + result = assignment_team.add_member(overflow_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/full capacity/) end end end @@ -126,11 +213,53 @@ def create_student(suffix) @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) end + it 'can add enrolled user via participant' do + result = assignment_team.add_member(@participant) + expect(result[:success]).to be true + expect(assignment_team.has_member?(enrolled_user)).to be true + end + it 'cannot add unenrolled user' do - result = assignment_team.add_member(unenrolled_user) + # Create participant for different assignment + other_assignment = Assignment.create!(name: "Different Assignment", instructor_id: instructor.id, max_team_size: 3) + wrong_participant = create(:assignment_participant, user: unenrolled_user, assignment: other_assignment) + + result = assignment_team.add_member(wrong_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(/not a participant in this assignment/) + end + end + + describe '#copy_to_course_team' do + it 'creates a new CourseTeam with copied members' do + # Add another member to the team + member = create(:user) + participant = create(:assignment_participant, user: member, assignment: assignment) + assignment_team.add_member(participant) + + # Copy to course team + course_team = assignment_team.copy_to_course_team(course) + + expect(course_team).to be_a(CourseTeam) + expect(course_team.name).to include('Course') + expect(course_team.parent_id).to eq(course.id) + # Members should be copied (note: copying creates CourseParticipants) + expect(course_team.participants.count).to eq(assignment_team.participants.count) + end + end + + describe '#copy_to_assignment_team' do + it 'creates a new AssignmentTeam with copied members' do + other_assignment = Assignment.create!(name: "Assignment 2", instructor_id: instructor.id, max_team_size: 3) + + # Copy to new assignment team + new_team = assignment_team.copy_to_assignment_team(other_assignment) + + expect(new_team).to be_a(AssignmentTeam) + expect(new_team.name).to include('Copy') + expect(new_team.parent_id).to eq(other_assignment.id) + expect(new_team.participants.count).to eq(assignment_team.participants.count) end end -end +end diff --git a/spec/models/course_team_spec.rb b/spec/models/course_team_spec.rb index 1a7e871e9..87072aca6 100644 --- a/spec/models/course_team_spec.rb +++ b/spec/models/course_team_spec.rb @@ -69,11 +69,11 @@ def create_student(suffix) end before do - participant = create(:course_participant, user: team_owner, course: course) - course_team.add_member(team_owner) + # Create participant for team_owner and add them to the team + @owner_participant = create(:course_participant, user: team_owner, course: course) + course_team.add_member(@owner_participant) end - describe 'validations' do it 'is valid with valid attributes' do expect(course_team).to be_valid @@ -89,7 +89,25 @@ def create_student(suffix) 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'") + expect(team.errors[:type]).to include("must be 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'") + end + end + + 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 @@ -97,11 +115,72 @@ def create_student(suffix) 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") + # Create participant for different course + other_course = Course.create!( + name: "Other Course", + instructor_id: instructor.id, + institution_id: institution.id, + directory_path: "/other" + ) + other_participant = CourseParticipant.create!( + parent_id: other_course.id, + user: unenrolled_user, + handle: unenrolled_user.name + ) expect { - course_team.add_member(unenrolled_user) + course_team.add_member(other_participant) }.not_to change(TeamsParticipant, :count) end + + it 'returns error hash when participant not registered' do + unenrolled_user = create_student("add_user_2") + # Create participant for different course + other_course = Course.create!( + name: "Different Course", + instructor_id: instructor.id, + institution_id: institution.id, + directory_path: "/different" + ) + other_participant = CourseParticipant.create!( + parent_id: other_course.id, + user: unenrolled_user, + handle: unenrolled_user.name + ) + + result = course_team.add_member(other_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/not a participant in this course/) + end + end + + context 'when user is properly enrolled' do + it 'adds the member successfully' do + enrolled_user = create_student("enrolled_user") + participant = CourseParticipant.create!( + parent_id: course.id, + user: enrolled_user, + handle: enrolled_user.name + ) + + expect { + result = course_team.add_member(participant) + expect(result[:success]).to be true + }.to change(TeamsParticipant, :count).by(1) + end + + it 'returns success hash' do + enrolled_user = create_student("enrolled_user_2") + participant = CourseParticipant.create!( + parent_id: course.id, + user: enrolled_user, + handle: enrolled_user.name + ) + + result = course_team.add_member(participant) + expect(result[:success]).to be true + expect(result[:error]).to be_nil + end end end @@ -119,18 +198,84 @@ def create_student(suffix) @participant = create(:course_participant, user: enrolled_user, course: course) end - it 'can add enrolled user' do - result = course_team.add_member(enrolled_user) + it 'can add enrolled user via participant' do + result = course_team.add_member(@participant) expect(result[:success]).to be true expect(course_team.has_member?(enrolled_user)).to be true end it 'cannot add unenrolled user' do - result = course_team.add_member(unenrolled_user) + # Create participant for different course + other_course = Course.create!( + name: "Another Course", + instructor_id: instructor.id, + institution_id: institution.id, + directory_path: "/another" + ) + wrong_participant = create(:course_participant, user: unenrolled_user, course: other_course) + + result = course_team.add_member(wrong_participant) expect(result[:success]).to be false - expect(result[:error]).to eq("#{unenrolled_user.name} is not a participant in this course") + expect(result[:error]).to match(/not a participant in this course/) + end + end + + describe '#full?' do + it 'always returns false (no capacity limit for course teams)' do + expect(course_team.full?).to be false + + # Add multiple members + 5.times do |i| + user = create_student("member_#{i}") + participant = CourseParticipant.create!( + parent_id: course.id, + user: user, + handle: user.name + ) + course_team.add_member(participant) + end + + # Still not full + expect(course_team.full?).to be false + end + end + + describe '#copy_to_assignment_team' do + it 'creates a new AssignmentTeam with copied members' do + # Add another member to the team + member = create(:user) + participant = create(:course_participant, user: member, course: course) + course_team.add_member(participant) + + # Copy to assignment team + assignment_team = course_team.copy_to_assignment_team(assignment) + + expect(assignment_team).to be_a(AssignmentTeam) + expect(assignment_team.name).to include('Assignment') + expect(assignment_team.parent_id).to eq(assignment.id) + # Members should be copied (note: copying creates AssignmentParticipants) + expect(assignment_team.participants.count).to eq(course_team.participants.count) + end + end + + describe '#copy_to_course_team' do + it 'creates a new CourseTeam with copied members' do + other_course = Course.create!( + name: "Course 2", + instructor_id: instructor.id, + institution_id: institution.id, + directory_path: "/course2" + ) + + # Copy to new course team + new_team = course_team.copy_to_course_team(other_course) + + expect(new_team).to be_a(CourseTeam) + expect(new_team.name).to include('Copy') + expect(new_team.parent_id).to eq(other_course.id) + expect(new_team.participants.count).to eq(course_team.participants.count) end end -end +end diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index 82c1d64ba..245e3d9ad 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -58,6 +58,35 @@ expect(mt.errors[:base]).to include('a mentor must be present') end + it 'is valid on :create without a mentor' do + # This test assumes the validation is ONLY on: :update + mt = MentoredTeam.new(name: 'mt_no_mentor', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') + # Note: This might fail if the *base* Team validation for `type` is running, + # but the logic for the `on: :update` test below is the critical part. + # If this must be valid, you might need to adjust the base validation. + # For now, let's focus on the :update test. + end + + it 'is invalid on :update if no mentor is present' do + # 1. Create a valid team WITH a mentor + mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) + assignment.duties << mentor_duty + mentor_user = create(:user, name: 'valid_mentor', email: 'vm@e.com') + + # Use the let block that saves with validate: false + mentored_team.assign_mentor(mentor_user) # Now it has a mentor + mentored_team.save! # Should be valid + + # 2. Manually remove the mentor's duty, making the team invalid + mentor_participant = mentored_team.mentor_participant + mentor_participant.update!(duty: nil) + mentored_team.reload + + # 3. Try to update the team. This should trigger the :update validation + expect(mentored_team.update(name: 'A New Name')).to be false + expect(mentored_team.errors[:base]).to include('a mentor must be present') + end + it 'rejects a mentor participant who does not have a mentor duty' do # Build an in-memory team and attach a participant with a non-mentor duty mt = MentoredTeam.new(name: 'mt_bad_duty', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') @@ -76,6 +105,24 @@ end end + describe 'polymorphic methods' do + it 'inherits parent_entity from AssignmentTeam' do + expect(mentored_team.parent_entity).to eq(assignment) + end + + it 'inherits participant_class from AssignmentTeam' do + expect(mentored_team.participant_class).to eq(AssignmentParticipant) + end + + it 'inherits context_label from AssignmentTeam' do + expect(mentored_team.context_label).to eq('assignment') + end + + it 'has max_team_size from assignment' do + expect(mentored_team.max_team_size).to eq(3) + end + end + describe 'associations' do it { should belong_to(:assignment) } end @@ -89,14 +136,14 @@ @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) end - it 'can add enrolled user' do - result = mentored_team.add_member(enrolled_user) - expect(result).to be_truthy + it 'can add enrolled user via participant' do + result = mentored_team.add_member(@participant) + expect(result[:success]).to be true expect(mentored_team.participants.map(&:user_id)).to include(enrolled_user.id) end - it 'cannot add mentor as member' do - # SCHEMA CHANGE: Create a mentor duty and associate it with the instructor + it 'cannot add mentor as regular member' do + # Create a mentor duty and associate it with the instructor mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) # Link the duty to the assignment assignment.duties << mentor_duty @@ -105,8 +152,9 @@ mentor_participant.update!(duty: mentor_duty) # Attempting to add a mentor as a normal member should fail - res = mentored_team.add_member(mentor_user) - expect(res).to be_falsey + result = mentored_team.add_member(mentor_participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Use assign_mentor/) expect(mentored_team.participants.map(&:user_id)).not_to include(mentor_user.id) end @@ -116,8 +164,8 @@ assignment.duties << mentor_duty # Call assign_mentor on the team for a user not previously a participant - res = mentored_team.assign_mentor(mentor_user) - expect(res).to be true + result = mentored_team.assign_mentor(mentor_user) + expect(result[:success]).to be true # Verify the participant was created and has a duty that includes 'mentor' mp = assignment.participants.find_by(user_id: mentor_user.id) @@ -130,13 +178,110 @@ end it 'cannot assign mentor when no mentor duty is available' do - # This test remains valid. `assignment.duties` is now an association through - # the join table, and destroying it will correctly remove the associations. + # Destroy all duties to ensure no mentor duty exists assignment.duties.destroy_all if assignment.duties.any? - # assign_mentor should return false when no mentor duty exists on the assignment - res = mentored_team.assign_mentor(mentor_user) - expect(res).to be false + # assign_mentor should return failure hash when no mentor duty exists + result = mentored_team.assign_mentor(mentor_user) + expect(result[:success]).to be false + expect(result[:error]).to match(/No mentor duty found/) + end + end + + describe '#remove_mentor' do + let(:mentor_user) { create(:user, name: 'mentor_to_remove', email: "remove_mentor_#{SecureRandom.hex(3)}@example.com") } + + before do + # Set up a mentor duty and assign a mentor + @mentor_duty = Duty.create!(name: 'Lead Mentor', instructor: instructor) + assignment.duties << @mentor_duty + mentored_team.assign_mentor(mentor_user) + end + + it 'removes the mentor duty from participant' do + result = mentored_team.remove_mentor + expect(result[:success]).to be true + + # Check that the participant's duty is now nil + participant = assignment.participants.find_by(user_id: mentor_user.id) + expect(participant.duty).to be_nil + end + + it 'returns error when no mentor exists' do + # Remove the mentor first + mentored_team.remove_mentor + + # Try to remove again + result = mentored_team.remove_mentor + expect(result[:success]).to be false + expect(result[:error]).to match(/No mentor found/) + end + end + + describe '#full?' do + it 'does not count mentor toward team capacity' do + # Create and assign a mentor + mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) + assignment.duties << mentor_duty + mentor_user = create(:user, name: 'capacity_mentor', email: "cap_mentor_#{SecureRandom.hex(3)}@example.com") + mentored_team.assign_mentor(mentor_user) + + # Add regular members up to max_team_size (3) + 3.times do |i| + user = create(:user, name: "member_#{i}", email: "member_#{i}_#{SecureRandom.hex(2)}@example.com") + participant = create(:assignment_participant, user: user, assignment: assignment) + mentored_team.add_member(participant) + end + + # Team should be full (3 regular members + 1 mentor, but mentor doesn't count) + expect(mentored_team.full?).to be true + expect(mentored_team.participants.count).to eq(4) # 3 members + 1 mentor + end + + it 'returns false when under capacity' do + # Create and assign a mentor + mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) + assignment.duties << mentor_duty + mentor_user = create(:user, name: 'capacity_mentor_2', email: "cap_mentor2_#{SecureRandom.hex(3)}@example.com") + mentored_team.assign_mentor(mentor_user) + + # Add only 1 regular member (capacity is 3) + user = create(:user, name: "member_solo", email: "solo_#{SecureRandom.hex(2)}@example.com") + participant = create(:assignment_participant, user: user, assignment: assignment) + mentored_team.add_member(participant) + + expect(mentored_team.full?).to be false + end + end + + describe '#mentor' do + it 'returns the mentor user' do + mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) + assignment.duties << mentor_duty + mentor_user = create(:user, name: 'mentor_getter', email: "get_mentor_#{SecureRandom.hex(3)}@example.com") + + mentored_team.assign_mentor(mentor_user) + + expect(mentored_team.mentor).to eq(mentor_user) + end + + it 'returns nil when no mentor assigned' do + # Team without mentor (using validation bypass) + team_no_mentor = MentoredTeam.new(name: "No Mentor Team", parent_id: assignment.id, assignment: assignment) + team_no_mentor.save!(validate: false) + + expect(team_no_mentor.mentor).to be_nil + end + end + + describe '#add_non_mentor_member' do + it 'is called internally by add_member for non-mentor participants' do + user = create(:user) + participant = create(:assignment_participant, user: user, assignment: assignment) + + # add_member should delegate to add_non_mentor_member for regular participants + expect(mentored_team).to receive(:add_non_mentor_member).and_call_original + mentored_team.add_member(participant) end end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index fe55ac3c1..b076dbbab 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -111,7 +111,7 @@ def create_student(suffix) # An unsupported value for type should trigger an inclusion error. team = Team.new(parent_id: assignment.id, type: 'Team') 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 @@ -125,6 +125,41 @@ def create_student(suffix) end end + # ------------------------------------------------------------------------ + # Tests for polymorphic abstract methods + # ------------------------------------------------------------------------ + describe 'abstract methods' do + it 'raises NotImplementedError for parent_entity on base Team class' do + team = Team.new(parent_id: assignment.id, type: 'AssignmentTeam') + # Since we're using STI, we can't instantiate base Team, but we can test the behavior + expect { Team.new.parent_entity }.to raise_error(NotImplementedError) + end + + 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? # @@ -263,7 +298,8 @@ def create_student(suffix) participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) 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 @@ -279,7 +315,18 @@ def create_student(suffix) extra_part = AssignmentParticipant.create!(parent_id: assignment.id, user: extra_user, handle: extra_user.name) result = assignment_team.add_member(extra_part) - expect(result[:error]).to include("team is at full capacity") + expect(result[:success]).to be false + expect(result[:error]).to include("full capacity") + end + + it 'validates participant type matches team type' do + user = create_student("wrong_type") + # Create a CourseParticipant instead of AssignmentParticipant + participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) + + result = assignment_team.add_member(participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Cannot add CourseParticipant to AssignmentTeam/) end end @@ -289,7 +336,8 @@ def create_student(suffix) 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 @@ -308,6 +356,45 @@ def create_student(suffix) # CourseTeam.full? is false, so add_member should succeed expect(result[:success]).to be true end + + it 'validates participant type matches team type' do + user = create_student("wrong_type_course") + # Create an AssignmentParticipant instead of CourseParticipant + participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + + result = course_team.add_member(participant) + expect(result[:success]).to be false + expect(result[:error]).to match(/Cannot add AssignmentParticipant to CourseTeam/) + end + end + end + + # ------------------------------------------------------------------------ + # Tests for helper methods + # ------------------------------------------------------------------------ + describe '#size' do + it 'returns the number of participants' do + expect(assignment_team.size).to eq(0) + + user = create_student("size_test") + participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + 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 + user = create_student("empty_test") + participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + 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_spec.rb b/spec/requests/api/v1/teams_spec.rb index 9c4e660d9..e1e5d25aa 100644 --- a/spec/requests/api/v1/teams_spec.rb +++ b/spec/requests/api/v1/teams_spec.rb @@ -77,6 +77,7 @@ institution_id: institution.id ) end + let(:team_with_course) do CourseTeam.create!( parent_id: course.id, @@ -99,6 +100,7 @@ handle: other_user.name ) end + let!(:participant_for_course) do CourseParticipant.create!( parent_id: course.id, @@ -115,6 +117,7 @@ user_id: participant_for_assignment.user_id ) end + let(:teams_participant_course) do TeamsParticipant.create!( participant_id: participant_for_course.id, @@ -134,6 +137,14 @@ expect(json_response.size).to eq(1) expect(json_response.first['id']).to eq(team_with_course.id) end + + it 'returns multiple teams of different types' do + team_with_course + team_with_assignment + get '/teams', headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response.size).to eq(2) + end end describe 'GET /teams/:id' do @@ -143,6 +154,18 @@ expect(json_response['id']).to eq(team_with_course.id) end + it 'returns team with correct parent_type using polymorphic method' do + get "/teams/#{team_with_course.id}", headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response['parent_type']).to eq('course') + end + + it 'returns assignment team with correct parent_type' do + get "/teams/#{team_with_assignment.id}", headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response['parent_type']).to eq('assignment') + end + it 'returns 404 for non-existent team' do get '/teams/0', headers: auth_headers expect(response).to have_http_status(:not_found) @@ -155,6 +178,53 @@ expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to have_key('errors') end + + 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 end describe 'Team Members' do @@ -166,45 +236,127 @@ expect(json_response.size).to eq(1) expect(json_response.first['id']).to eq(other_user.id) end + + it 'returns empty array for team with no members' do + get "/teams/#{team_with_course.id}/members", headers: auth_headers + expect(response).to have_http_status(:success) + expect(json_response).to be_empty + 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 + context 'for CourseTeam' do + 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 + } } - } + end + + it 'adds a new team member using polymorphic participant_class' 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) + end + + it 'returns error when user not a participant in course' do + non_participant_user = create(:user) + params = { + team_participant: { + user_id: non_participant_user.id + } + } + + post "/teams/#{team_with_course.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 '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) + context 'for AssignmentTeam' do + let!(:new_assignment_participant) { create(:assignment_participant, user: new_user, parent_id: assignment.id) } + + let(:assignment_params) do + { + team_participant: { + user_id: new_user.id + } + } + end + + it 'adds a new team member using polymorphic participant_class' do + expect { + post "/teams/#{team_with_assignment.id}/members", params: assignment_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 + # Set max_team_size to 1 and add first member + assignment.update(max_team_size: 1) + teams_participant_assignment # This creates the first member + + # Try to add a second member when team is full + post "/teams/#{team_with_assignment.id}/members", params: assignment_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/#{team_with_assignment.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 - 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 + context 'type validation' do + it 'prevents adding CourseParticipant to AssignmentTeam' do + # Create a course participant + course_user = create(:user) + course_participant = create(:course_participant, user: course_user, parent_id: course.id) + + # Try to add to assignment team (this should fail in the model layer) + # The controller will find no participant because it looks for AssignmentParticipant + params = { + team_participant: { + user_id: course_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') + + post "/teams/#{team_with_assignment.id}/members", params: params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'prevents adding AssignmentParticipant to CourseTeam' do + # Create an assignment participant + assignment_user = create(:user) + assignment_participant = create(:assignment_participant, user: assignment_user, parent_id: assignment.id) + + # Try to add to course team (this should fail in the model layer) + # The controller will find no participant because it looks for CourseParticipant + params = { + team_participant: { + user_id: assignment_user.id + } + } + + post "/teams/#{team_with_course.id}/members", params: params, headers: auth_headers + expect(response).to have_http_status(:unprocessable_entity) + end end end @@ -221,6 +373,40 @@ delete "/teams/#{team_with_course.id}/members/0", headers: auth_headers expect(response).to have_http_status(:not_found) end + + it 'removes member from assignment team' do + teams_participant_assignment + expect { + delete "/teams/#{team_with_assignment.id}/members/#{other_user.id}", headers: auth_headers + }.to change(TeamsParticipant, :count).by(-1) + expect(response).to have_http_status(:no_content) + end + end + end + + describe 'Polymorphic behavior' do + it 'CourseTeam uses CourseParticipant class' do + expect(team_with_course.participant_class).to eq(CourseParticipant) + end + + it 'AssignmentTeam uses AssignmentParticipant class' do + expect(team_with_assignment.participant_class).to eq(AssignmentParticipant) + end + + it 'CourseTeam has correct parent_entity' do + expect(team_with_course.parent_entity).to eq(course) + end + + it 'AssignmentTeam has correct parent_entity' do + expect(team_with_assignment.parent_entity).to eq(assignment) + end + + it 'CourseTeam has correct context_label' do + expect(team_with_course.context_label).to eq('course') + end + + it 'AssignmentTeam has correct context_label' do + expect(team_with_assignment.context_label).to eq('assignment') end end From caff8435fbb6b3d2bc5d544eb6054105a6dc9375 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Sun, 26 Oct 2025 23:19:59 +0000 Subject: [PATCH 10/11] updated teams hierarchy --- app/controllers/teams_controller.rb | 2 - app/helpers/team_operations_helper.rb | 30 --------- app/models/assignment_team.rb | 49 ++++++--------- app/models/course_team.rb | 35 +++++------ app/models/mentored_team.rb | 88 ++++++++++++--------------- app/models/participant.rb | 23 ++++++- app/models/team.rb | 87 ++++++++++++++++---------- 7 files changed, 145 insertions(+), 169 deletions(-) delete mode 100644 app/helpers/team_operations_helper.rb diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 04304c58b..6034e19f9 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -15,8 +15,6 @@ 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 diff --git a/app/helpers/team_operations_helper.rb b/app/helpers/team_operations_helper.rb deleted file mode 100644 index 24a898e50..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.is_a?(AssignmentTeam) ? team.assignment&.max_team_size : nil), - is_full: team.full?, - is_empty: team.participants.empty? - } - end -end diff --git a/app/models/assignment_team.rb b/app/models/assignment_team.rb index fe7d7a69a..ebddf97e3 100644 --- a/app/models/assignment_team.rb +++ b/app/models/assignment_team.rb @@ -4,7 +4,8 @@ class AssignmentTeam < Team # Each AssignmentTeam must belong to a specific assignment belongs_to :assignment, class_name: 'Assignment', foreign_key: 'parent_id' - # Implement abstract methods from Team + # === Implementation of Abstract Methods === + def parent_entity assignment end @@ -17,58 +18,42 @@ 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 def copy_to_course_team(course) course_team = CourseTeam.new( - name: "#{name} (Course)", + name: name, # Keep the same name by default parent_id: course.id ) - + if course_team.save - copy_members_to_team(course_team, course) + # Use the protected method from the base Team class + copy_members_to_team(course_team) end - + course_team end # Copies the current assignment team to another assignment team def copy_to_assignment_team(target_assignment) - new_team = AssignmentTeam.new( - name: "#{name} (Copy)", + new_team = self.class.new( # Use self.class to support MentoredTeam + name: name, # Keep the same name by default parent_id: target_assignment.id ) - + if new_team.save - copy_members_to_team(new_team, target_assignment) + # Use the protected method from the base Team class + copy_members_to_team(new_team) end - - new_team - end - - protected - - def validate_membership(user) - assignment.participants.exists?(user: user) - end - private - - def copy_members_to_team(target_team, target_parent) - participants.each do |assignment_participant| - # Find or create corresponding participant in target context - target_participant = target_team.participant_class.find_or_create_by!( - user_id: assignment_participant.user_id, - parent_id: target_parent.id - ) do |p| - p.handle = assignment_participant.handle - end - - target_team.add_member(target_participant) - end + new_team end end diff --git a/app/models/course_team.rb b/app/models/course_team.rb index 331a7d9db..aa22c2580 100644 --- a/app/models/course_team.rb +++ b/app/models/course_team.rb @@ -1,10 +1,11 @@ # 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' - # Implement abstract methods from Team + # === Implementation of Abstract Methods === + def parent_entity course end @@ -17,41 +18,35 @@ def context_label 'course' end + # === Copying Logic === + # Copies the current course team to an assignment team def copy_to_assignment_team(assignment) assignment_team = AssignmentTeam.new( - name: "#{name} (Assignment)", + name: name, # Keep the same name by default parent_id: assignment.id ) - + if assignment_team.save - copy_members_to_team(assignment_team, assignment) + # Use the protected method from the base Team class + copy_members_to_team(assignment_team) end - + assignment_team end # Copies the current course team to another course team def copy_to_course_team(target_course) new_team = CourseTeam.new( - name: "#{name} (Copy)", + name: name, # Keep the same name by default parent_id: target_course.id ) - + if new_team.save - copy_members_to_team(new_team, target_course) + # Use the protected method from the base Team class + copy_members_to_team(new_team) end - - new_team - end - - protected - def validate_membership(user) - course.participants.exists?(user: user) + new_team end - - private - - end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index 515f2b74d..ae85cb8fc 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true class MentoredTeam < AssignmentTeam - # The mentor is determined by a participant on this team having a Duty whose - # name includes 'mentor' (case-insensitive). + # The mentor is determined by a participant on this team having a Duty + validate :mentor_must_be_present, on: :update, if: -> { assignment.present? } - # validate :type_must_be_mentored_team - validate :mentor_must_be_present, on: :update + # === Public API for Mentors === # Return the mentor User (or nil) def mentor @@ -13,53 +12,29 @@ def mentor end alias_method :mentor_user, :mentor - # Override add_member to prevent mentors from being added as regular members - # This approach is better than rejecting in the method - we separate concerns - def add_non_mentor_member(participant) - if participant_is_mentor?(participant) - return { success: false, error: 'Mentors cannot be added as regular members.' } - end - - super(participant) - end - - # Public interface for adding members - delegates to appropriate method - def add_member(participant) - # Check if this is a mentor being added - if participant_is_mentor?(participant) - return { success: false, error: 'Use assign_mentor to add mentors to the team.' } - end - - add_non_mentor_member(participant) - end - # Separate method for assigning mentors def assign_mentor(user) duty = find_mentor_duty return { success: false, error: 'No mentor duty found for this assignment.' } unless duty - participant = assignment.participants.find_or_initialize_by( + # 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' - ) - - participant.handle ||= (user.try(:handle).presence || user.name) - participant.user_id ||= user.id - participant.parent_id ||= assignment.id - participant.type ||= 'AssignmentParticipant' - - participant.save! - - unless participant.duty == duty - participant.duty = duty - participant.save! + ) 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) - TeamsParticipant.create!( - participant_id: participant.id, - team_id: id, + teams_participants.create!( + participant_id: participant.id, + team_id: id, user_id: participant.user_id ) end @@ -74,7 +49,7 @@ def assign_mentor(user) def remove_mentor mp = mentor_participant return { success: false, error: 'No mentor found on this team.' } unless mp - + if mp.update(duty: nil) { success: true } else @@ -82,15 +57,32 @@ def remove_mentor end end + # === Overridden Methods === + + # REFACTOR: 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 def participant_is_mentor?(participant) @@ -103,16 +95,14 @@ def find_mentor_duty 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 type_must_be_mentored_team - # errors.add(:type, 'must be MentoredTeam') unless type == 'MentoredTeam' - # end - def mentor_must_be_present - unless mentor_participant.present? - errors.add(:base, 'a mentor must be present') - end + # 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 diff --git a/app/models/participant.rb b/app/models/participant.rb index f103a6591..a01dc8441 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -4,7 +4,17 @@ class Participant < ApplicationRecord # Associations belongs_to :user has_many :join_team_requests, dependent: :destroy - belongs_to :team, optional: true + + # REFACTOR: 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 @@ -12,20 +22,27 @@ class Participant < ApplicationRecord # 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.full_name end + # Abstract method for polymorphic use (e.g., in Team#validate_participant_type) def parent_context - # Subclasses must implement this 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? diff --git a/app/models/team.rb b/app/models/team.rb index 69ee9e798..b6c9e1d56 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -3,67 +3,77 @@ 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 belongs_to :user, optional: true # Team creator - + validates :parent_id, presence: true - validates :type, presence: true, inclusion: { - in: %w[AssignmentTeam CourseTeam MentoredTeam], - message: "must be 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'" + 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 + + # === 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 - # Template method - uses polymorphic behavior from subclasses + # === Core API === + def max_team_size - nil # Default for teams without size limits (overridden in AssignmentTeam) + nil # Default: no limit (overridden by AssignmentTeam) end def has_member?(user) participants.exists?(user_id: user.id) end - + def full? return false unless max_team_size + # Note: MentoredTeam overrides this to exclude the mentor participants.count >= max_team_size end # Uses polymorphic parent_entity instead of type checking def participant_on_team?(participant) return false unless parent_entity - + TeamsParticipant - .where(participant_id: participant.id, team_id: parent_entity.teams.select(:id)) - .exists? + .where(participant_id: participant.id, team_id: parent_entity.teams.select(:id)) + .exists? end - # Adds a participant to the team + # 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] - return { success: false, error: "Team is at full capacity." } if full? - return { success: false, error: "Participant is already on a team for this context." } if participant_on_team?(participant) + return { success: false, error: 'Team is at full capacity.' } if full? validation_result = validate_participant_type(participant) return validation_result unless validation_result[:success] + # Added hook for subclass-specific validation (e.g., MentoredTeam) + hook_result = validate_participant_for_add(participant) + return hook_result unless hook_result[:success] + teams_participants.create!(participant: participant, user: participant.user) { success: true } rescue ActiveRecord::RecordInvalid => e @@ -76,15 +86,6 @@ def can_participant_join_team?(participant) return { success: false, error: "This user is already assigned to a team for this #{context_label}" } end - registered = participant_class.find_by( - user_id: participant.user_id, - parent_id: parent_entity.id - ) - - unless registered - return { success: false, error: "#{participant.user.name} is not a participant in this #{context_label}" } - end - { success: true } end @@ -98,19 +99,39 @@ def empty? protected - # Hook for subclasses to validate participant types + # Copies members from this team to a target_team. + def copy_members_to_team(target_team) + target_parent = target_team.parent_entity + + 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 + + # This ensures a participant's context (Course) matches the team's (Course). def validate_participant_type(participant) - unless participant.is_a?(participant_class) - return { - success: false, - error: "Cannot add #{participant.class.name} to #{self.class.name}. Expected #{participant_class.name}." + 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 - def validate_membership(user) - # Default implementation - override in subclasses if needed - true + # 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 From d2027dcde32964ac2d7e192b12b56d61d3c83832 Mon Sep 17 00:00:00 2001 From: Chinmay Singhania Date: Mon, 27 Oct 2025 05:44:48 +0000 Subject: [PATCH 11/11] all tests pass except mentored_team_spec --- app/controllers/teams_controller.rb | 36 ++- app/models/assignments_duty.rb | 2 +- app/models/mentored_team.rb | 7 +- app/models/participant.rb | 2 +- spec/factories/participants.rb | 32 ++- spec/factories/teams.rb | 58 ++--- spec/models/assignment_team_spec.rb | 262 ++++++--------------- spec/models/course_team_spec.rb | 275 ++++++----------------- spec/models/mentored_team_spec.rb | 337 +++++++++------------------- spec/models/team_spec.rb | 295 ++++++------------------ spec/requests/api/v1/teams_spec.rb | 336 +++++++-------------------- 11 files changed, 486 insertions(+), 1156 deletions(-) diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 6034e19f9..052ee4e47 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -20,8 +20,29 @@ def show # 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 @@ -44,13 +65,13 @@ def add_member # Use polymorphic participant_class method instead of type checking participant = @team.participant_class.find_by( - user_id: user.id, + user_id: user.id, parent_id: @team.parent_entity.id ) unless participant - return render json: { - errors: ["#{user.name} is not a participant in this #{@team.context_label}."] + return render json: { + errors: ["#{user.name} is not a participant in this #{@team.context_label}."] }, status: :unprocessable_entity end @@ -83,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 diff --git a/app/models/assignments_duty.rb b/app/models/assignments_duty.rb index eeb3519b0..c14d635bb 100644 --- a/app/models/assignments_duty.rb +++ b/app/models/assignments_duty.rb @@ -1,4 +1,4 @@ class AssignmentsDuty < ApplicationRecord belongs_to :assignment belongs_to :duty -end \ No newline at end of file +end diff --git a/app/models/mentored_team.rb b/app/models/mentored_team.rb index ae85cb8fc..ae7aaeddc 100644 --- a/app/models/mentored_team.rb +++ b/app/models/mentored_team.rb @@ -2,7 +2,10 @@ class MentoredTeam < AssignmentTeam # The mentor is determined by a participant on this team having a Duty - validate :mentor_must_be_present, on: :update, if: -> { assignment.present? } + + # This validation must be on: :update + # and should only run if mentor duties exist for the assignment. + validate :mentor_must_be_present, on: :update # === Public API for Mentors === @@ -59,7 +62,7 @@ def remove_mentor # === Overridden Methods === - # REFACTOR: Deleted the `add_member` override. + # Deleted the `add_member` override. # We now use the `validate_participant_for_add` hook from the base class. # This fixes the LSP violation. diff --git a/app/models/participant.rb b/app/models/participant.rb index a01dc8441..e3b1e6d70 100644 --- a/app/models/participant.rb +++ b/app/models/participant.rb @@ -5,7 +5,7 @@ class Participant < ApplicationRecord belongs_to :user has_many :join_team_requests, dependent: :destroy - # REFACTOR: Removed `belongs_to :team` + # 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 diff --git a/spec/factories/participants.rb b/spec/factories/participants.rb index 092298d1a..90ad5cf55 100644 --- a/spec/factories/participants.rb +++ b/spec/factories/participants.rb @@ -1,17 +1,41 @@ # frozen_string_literal: true -FactoryBot.define do +FactoryBot.define do 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 1517a247e..d10b7cf9a 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -1,53 +1,53 @@ # frozen_string_literal: true FactoryBot.define do - factory :team do - name { Faker::Team.name } - type { 'CourseTeam' } - - 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' } - association :course, factory: :course + association :course end factory :assignment_team, class: 'AssignmentTeam' do name { Faker::Team.name } - type { 'AssignmentTeam' } - + + # Allows passing max_size to the assignment, e.g., + # create(:assignment_team, max_size: 3) transient do - course { create(:course) } - max_size { 5 } # Now passed to the assignment, not the team + max_size { 5 } end - assignment do - create(:assignment, course: course, max_team_size: max_size) - 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| - unless team.assignment.nil? - team.assignment.update(max_team_size: evaluator.max_size) if evaluator.max_size + # 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 end - trait :with_assignment do - after(:build) do |team, evaluator| - team.assignment ||= create(:assignment, course: evaluator.course, max_team_size: evaluator.max_size) + # 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' } - association :assignment, factory: :assignment - after(:build) do |t| - t.parent_id ||= t.assignment&.id - end + 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 diff --git a/spec/models/assignment_team_spec.rb b/spec/models/assignment_team_spec.rb index 936d22883..9a81486cf 100644 --- a/spec/models/assignment_team_spec.rb +++ b/spec/models/assignment_team_spec.rb @@ -3,96 +3,39 @@ 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' - ) - end - - before do - # Create participant for team_owner and add them to the team - @owner_participant = create(:assignment_participant, user: team_owner, assignment: assignment) - assignment_team.add_member(@owner_participant) - 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 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'") - end + # --- 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) @@ -102,7 +45,7 @@ def create_student(suffix) expect(assignment_team.participant_class).to eq(AssignmentParticipant) end - it 'returns assignment as context_label' do + it 'returns "assignment" as context_label' do expect(assignment_team.context_label).to eq('assignment') end @@ -111,155 +54,94 @@ def create_student(suffix) 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") - # Create participant for wrong assignment or none at all - # In this case, we're not creating a participant, so the check should fail - - # Try to add user without proper participant - unenrolled_participant = AssignmentParticipant.new(user: unenrolled_user) - - expect { - assignment_team.add_member(unenrolled_participant) - }.not_to change(TeamsParticipant, :count) - end - - it 'returns error hash when participant not registered' do - unenrolled_user = create_student("add_user_2") - # Create participant but for different assignment - other_assignment = Assignment.create!(name: "Other Assignment", instructor_id: instructor.id, max_team_size: 3) - other_participant = AssignmentParticipant.create!( - parent_id: other_assignment.id, - user: unenrolled_user, - handle: unenrolled_user.name - ) - - result = assignment_team.add_member(other_participant) - expect(result[:success]).to be false - expect(result[:error]).to match(/not a participant in this assignment/) - end - end - - context 'when user is properly enrolled' do + context 'when participant is in the same assignment' do it 'adds the member successfully' do - enrolled_user = create_student("enrolled_user") - participant = AssignmentParticipant.create!( - parent_id: assignment.id, - user: enrolled_user, - handle: enrolled_user.name - ) - 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 - it 'returns success hash' do - enrolled_user = create_student("enrolled_user_2") - participant = AssignmentParticipant.create!( - parent_id: assignment.id, - user: enrolled_user, - handle: enrolled_user.name - ) + context 'when participant is from a different assignment' do + it 'does not add the member' do + expect { + 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 - result = assignment_team.add_member(participant) - expect(result[:success]).to be true - expect(result[:error]).to be_nil + 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 match(/Participant belongs to.*Course.*but this team belongs to.*Assignment/) end end context 'when team is full' do it 'rejects new members' do - # Team already has 1 member (team_owner), add 2 more to reach max_team_size of 3 - 2.times do |i| - user = create_student("filler_#{i}") - participant = AssignmentParticipant.create!( - parent_id: assignment.id, - user: user, - handle: user.name - ) - assignment_team.add_member(participant) + # Fill the team to its max size of 3 + 3.times do + assignment_team.add_member(create(:assignment_participant, assignment: assignment)) end - # Try to add one more - overflow_user = create_student("overflow") - overflow_participant = AssignmentParticipant.create!( - parent_id: assignment.id, - user: overflow_user, - handle: overflow_user.name - ) + 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(/full capacity/) + expect(result[:error]).to match(/Team is at full capacity/) end end end - describe 'associations' do - it { should belong_to(:assignment) } - 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) } - let(:unenrolled_user) { create(:user) } - + # --- Copy Logic Tests --- + describe '#copy_to_course_team' do before do - @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) - end - - it 'can add enrolled user via participant' do - result = assignment_team.add_member(@participant) - expect(result[:success]).to be true - expect(assignment_team.has_member?(enrolled_user)).to be true + # Add a member to the original team + assignment_team.add_member(participant) end - it 'cannot add unenrolled user' do - # Create participant for different assignment - other_assignment = Assignment.create!(name: "Different Assignment", instructor_id: instructor.id, max_team_size: 3) - wrong_participant = create(:assignment_participant, user: unenrolled_user, assignment: other_assignment) + it 'creates a new CourseTeam with copied members' do + new_team = assignment_team.copy_to_course_team(course) - result = assignment_team.add_member(wrong_participant) + 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) - expect(result[:success]).to be false - expect(result[:error]).to match(/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 - describe '#copy_to_course_team' do - it 'creates a new CourseTeam with copied members' do - # Add another member to the team - member = create(:user) - participant = create(:assignment_participant, user: member, assignment: assignment) + describe '#copy_to_assignment_team' do + before do + # Add a member to the original team assignment_team.add_member(participant) - - # Copy to course team - course_team = assignment_team.copy_to_course_team(course) - - expect(course_team).to be_a(CourseTeam) - expect(course_team.name).to include('Course') - expect(course_team.parent_id).to eq(course.id) - # Members should be copied (note: copying creates CourseParticipants) - expect(course_team.participants.count).to eq(assignment_team.participants.count) end - end - describe '#copy_to_assignment_team' do it 'creates a new AssignmentTeam with copied members' do - other_assignment = Assignment.create!(name: "Assignment 2", instructor_id: instructor.id, max_team_size: 3) - - # Copy to new assignment team new_team = assignment_team.copy_to_assignment_team(other_assignment) - expect(new_team).to be_a(AssignmentTeam) - expect(new_team.name).to include('Copy') + 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(new_team.participants.count).to eq(assignment_team.participants.count) + + # 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 diff --git a/spec/models/course_team_spec.rb b/spec/models/course_team_spec.rb index 87072aca6..60f4a4853 100644 --- a/spec/models/course_team_spec.rb +++ b/spec/models/course_team_spec.rb @@ -3,96 +3,39 @@ 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', - ) - end - - before do - # Create participant for team_owner and add them to the team - @owner_participant = create(:course_participant, user: team_owner, course: course) - course_team.add_member(@owner_participant) - 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 'AssignmentTeam', 'CourseTeam', or 'MentoredTeam'") - end + # --- 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) @@ -102,7 +45,7 @@ def create_student(suffix) expect(course_team.participant_class).to eq(CourseParticipant) end - it 'returns course as context_label' do + it 'returns "course" as context_label' do expect(course_team.context_label).to eq('course') end @@ -111,171 +54,91 @@ def create_student(suffix) 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") - # Create participant for different course - other_course = Course.create!( - name: "Other Course", - instructor_id: instructor.id, - institution_id: institution.id, - directory_path: "/other" - ) - other_participant = CourseParticipant.create!( - parent_id: other_course.id, - user: unenrolled_user, - handle: unenrolled_user.name - ) - - expect { - course_team.add_member(other_participant) - }.not_to change(TeamsParticipant, :count) - end - - it 'returns error hash when participant not registered' do - unenrolled_user = create_student("add_user_2") - # Create participant for different course - other_course = Course.create!( - name: "Different Course", - instructor_id: instructor.id, - institution_id: institution.id, - directory_path: "/different" - ) - other_participant = CourseParticipant.create!( - parent_id: other_course.id, - user: unenrolled_user, - handle: unenrolled_user.name - ) - - result = course_team.add_member(other_participant) - expect(result[:success]).to be false - expect(result[:error]).to match(/not a participant in this course/) - end - end - - context 'when user is properly enrolled' do + context 'when participant is in the same course' do it 'adds the member successfully' do - enrolled_user = create_student("enrolled_user") - participant = CourseParticipant.create!( - parent_id: course.id, - user: enrolled_user, - handle: enrolled_user.name - ) - 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 - - it 'returns success hash' do - enrolled_user = create_student("enrolled_user_2") - participant = CourseParticipant.create!( - parent_id: course.id, - user: enrolled_user, - handle: enrolled_user.name - ) - - result = course_team.add_member(participant) - expect(result[:success]).to be true - expect(result[:error]).to be_nil - end - end - end - - describe 'associations' do - it { should belong_to(:course) } - 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, role: create(:role)) } - let(:unenrolled_user) { create(:user, role: create(:role)) } - - before do - @participant = create(:course_participant, user: enrolled_user, course: course) end - it 'can add enrolled user via participant' do - result = course_team.add_member(@participant) - - expect(result[:success]).to be true - expect(course_team.has_member?(enrolled_user)).to be true + context 'when participant is from a different course' do + it 'does not add the member' do + expect { + 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 - it 'cannot add unenrolled user' do - # Create participant for different course - other_course = Course.create!( - name: "Another Course", - instructor_id: instructor.id, - institution_id: institution.id, - directory_path: "/another" - ) - wrong_participant = create(:course_participant, user: unenrolled_user, course: other_course) - - result = course_team.add_member(wrong_participant) - - expect(result[:success]).to be false - expect(result[:error]).to match(/not a participant in this course/) + 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 '#full?' do - it 'always returns false (no capacity limit for course teams)' do + it 'always returns false, even with many members' do expect(course_team.full?).to be false # Add multiple members - 5.times do |i| - user = create_student("member_#{i}") - participant = CourseParticipant.create!( - parent_id: course.id, - user: user, - handle: user.name - ) - course_team.add_member(participant) + 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 - it 'creates a new AssignmentTeam with copied members' do - # Add another member to the team - member = create(:user) - participant = create(:course_participant, user: member, course: course) + before do + # 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) - # Copy to assignment team - assignment_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) - expect(assignment_team).to be_a(AssignmentTeam) - expect(assignment_team.name).to include('Assignment') - expect(assignment_team.parent_id).to eq(assignment.id) - # Members should be copied (note: copying creates AssignmentParticipants) - expect(assignment_team.participants.count).to eq(course_team.participants.count) + # 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_course_team' do + before do + # Add a member to the original team + course_team.add_member(participant) + end + it 'creates a new CourseTeam with copied members' do - other_course = Course.create!( - name: "Course 2", - instructor_id: instructor.id, - institution_id: institution.id, - directory_path: "/course2" - ) - - # Copy to new course team new_team = course_team.copy_to_course_team(other_course) expect(new_team).to be_a(CourseTeam) - expect(new_team.name).to include('Copy') + 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(new_team.participants.count).to eq(course_team.participants.count) + + # 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 diff --git a/spec/models/mentored_team_spec.rb b/spec/models/mentored_team_spec.rb index 245e3d9ad..d89287f5b 100644 --- a/spec/models/mentored_team_spec.rb +++ b/spec/models/mentored_team_spec.rb @@ -3,285 +3,170 @@ require 'rails_helper' RSpec.describe MentoredTeam, type: :model do - include RolesHelper - - # Create the full roles hierarchy once, to be shared by all examples. - let!(:roles) { create_roles_hierarchy } - - let(:institution) do - Institution.create!(name: "NC State") - end - - let(:instructor) do - # This user will serve as the instructor for the assignment and duties - User.create!( - name: "instructor", - full_name: "Instructor User", - email: "instructor@example.com", - password_digest: "password", - role_id: roles[:instructor].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 - 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(: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 - ) - end - - let(:mentored_team) do - # Create the team with validation disabled, so we can test validation separately - t = MentoredTeam.new(name: "MentoredTeam #{SecureRandom.hex(4)}", parent_id: assignment.id, assignment: assignment) - t.save!(validate: false) - t + # A regular participant without any duty + let!(:regular_participant) do + create(:assignment_participant, user: regular_user, assignment: assignment) end + # --- Validation Tests --- describe 'validations' do - it 'requires type to be MentoredTeam' do - mt = MentoredTeam.new(name: 'mt', parent_id: assignment.id, assignment: assignment) - expect(mt.type).to eq('MentoredTeam') - end - - it 'requires a mentor participant (duty present) on the team' do - # Build an unsaved MentoredTeam with no participants - mt = MentoredTeam.new(name: 'mt_no_mentor', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') - expect(mt).not_to be_valid - expect(mt.errors[:base]).to include('a mentor must be present') - end - it 'is valid on :create without a mentor' do - # This test assumes the validation is ONLY on: :update - mt = MentoredTeam.new(name: 'mt_no_mentor', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') - # Note: This might fail if the *base* Team validation for `type` is running, - # but the logic for the `on: :update` test below is the critical part. - # If this must be valid, you might need to adjust the base validation. - # For now, let's focus on the :update test. + # 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. Create a valid team WITH a mentor - mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) - assignment.duties << mentor_duty - mentor_user = create(:user, name: 'valid_mentor', email: 'vm@e.com') - - # Use the let block that saves with validate: false - mentored_team.assign_mentor(mentor_user) # Now it has a mentor - mentored_team.save! # Should be valid - - # 2. Manually remove the mentor's duty, making the team invalid - mentor_participant = mentored_team.mentor_participant - mentor_participant.update!(duty: nil) - mentored_team.reload - - # 3. Try to update the team. This should trigger the :update validation - expect(mentored_team.update(name: 'A New Name')).to be false - expect(mentored_team.errors[:base]).to include('a mentor must be present') - end - - it 'rejects a mentor participant who does not have a mentor duty' do - # Build an in-memory team and attach a participant with a non-mentor duty - mt = MentoredTeam.new(name: 'mt_bad_duty', parent_id: assignment.id, assignment: assignment, type: 'MentoredTeam') + # 1. Assign a mentor to make the team valid + team.assign_mentor(mentor_user) + expect(team.update(name: 'A New Name')).to be true - # Create a Duty and associate it with the assignment through the join table - non_mentor_duty = Duty.create!(name: 'helper', instructor: instructor) - assignment.duties << non_mentor_duty # This creates the AssignmentsDuty record + # 2. Remove the mentor + team.remove_mentor + expect(team.mentor).to be_nil - participant = build(:assignment_participant, assignment: assignment) - participant.duty = non_mentor_duty - - # Attach participant via teams_participants in-memory - mt.teams_participants.build(participant: participant, user: participant.user) - expect(mt).not_to be_valid - expect(mt.errors[:base]).to include('a mentor must be present') - end - end - - describe 'polymorphic methods' do - it 'inherits parent_entity from AssignmentTeam' do - expect(mentored_team.parent_entity).to eq(assignment) - end - - it 'inherits participant_class from AssignmentTeam' do - expect(mentored_team.participant_class).to eq(AssignmentParticipant) - end - - it 'inherits context_label from AssignmentTeam' do - expect(mentored_team.context_label).to eq('assignment') + # 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 - - it 'has max_team_size from assignment' do - expect(mentored_team.max_team_size).to eq(3) - end - end - - describe 'associations' do - it { should belong_to(:assignment) } end - describe 'team management' do - let(:enrolled_user) { create(:user) } - let(:mentor_user) { create(:user, name: 'mentor_user', email: "mentor_#{SecureRandom.hex(3)}@example.com") } + # --- Mentor Management API Tests --- + describe '#assign_mentor' do + it 'successfully assigns a user as a mentor' do + result = team.assign_mentor(mentor_user) - before do - # Ensure an assignment participant exists for the enrolled_user - @participant = create(:assignment_participant, user: enrolled_user, assignment: assignment) - end - - it 'can add enrolled user via participant' do - result = mentored_team.add_member(@participant) expect(result[:success]).to be true - expect(mentored_team.participants.map(&:user_id)).to include(enrolled_user.id) - end - it 'cannot add mentor as regular member' do - # Create a mentor duty and associate it with the instructor - mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) - # Link the duty to the assignment - assignment.duties << mentor_duty - - mentor_participant = create(:assignment_participant, user: mentor_user, assignment: assignment) - mentor_participant.update!(duty: mentor_duty) - - # Attempting to add a mentor as a normal member should fail - result = mentored_team.add_member(mentor_participant) - expect(result[:success]).to be false - expect(result[:error]).to match(/Use assign_mentor/) - expect(mentored_team.participants.map(&:user_id)).not_to include(mentor_user.id) + expect(team.reload.mentor).to eq(mentor_user) end - it 'can assign new mentor' do - # Create a mentor duty and associate it with the assignment - mentor_duty = Duty.create!(name: 'mentor role', instructor: instructor) - assignment.duties << mentor_duty + 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 - # Call assign_mentor on the team for a user not previously a participant - result = mentored_team.assign_mentor(mentor_user) + # Now, assign them as mentor + result = team.assign_mentor(regular_user) expect(result[:success]).to be true - - # Verify the participant was created and has a duty that includes 'mentor' - mp = assignment.participants.find_by(user_id: mentor_user.id) - expect(mp).not_to be_nil - expect(mp.duty).not_to be_nil - expect(mp.duty.name.downcase).to include('mentor') - - # Ensure the user was also added to the team (TeamsParticipant created) - expect(mentored_team.participants.map(&:user_id)).to include(mentor_user.id) + 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 - it 'cannot assign mentor when no mentor duty is available' do - # Destroy all duties to ensure no mentor duty exists - assignment.duties.destroy_all if assignment.duties.any? - - # assign_mentor should return failure hash when no mentor duty exists - result = mentored_team.assign_mentor(mentor_user) + 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 describe '#remove_mentor' do - let(:mentor_user) { create(:user, name: 'mentor_to_remove', email: "remove_mentor_#{SecureRandom.hex(3)}@example.com") } - before do - # Set up a mentor duty and assign a mentor - @mentor_duty = Duty.create!(name: 'Lead Mentor', instructor: instructor) - assignment.duties << @mentor_duty - mentored_team.assign_mentor(mentor_user) + team.assign_mentor(mentor_user) end - it 'removes the mentor duty from participant' do - result = mentored_team.remove_mentor - expect(result[:success]).to be true + 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 - # Check that the participant's duty is now nil - participant = assignment.participants.find_by(user_id: mentor_user.id) - expect(participant.duty).to be_nil + 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 'returns error when no mentor exists' do + it 'returns an error if no mentor is on the team' do # Remove the mentor first - mentored_team.remove_mentor - + team.remove_mentor + # Try to remove again - result = mentored_team.remove_mentor + result = team.remove_mentor expect(result[:success]).to be false expect(result[:error]).to match(/No mentor found/) end end - describe '#full?' do - it 'does not count mentor toward team capacity' do - # Create and assign a mentor - mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) - assignment.duties << mentor_duty - mentor_user = create(:user, name: 'capacity_mentor', email: "cap_mentor_#{SecureRandom.hex(3)}@example.com") - mentored_team.assign_mentor(mentor_user) - - # Add regular members up to max_team_size (3) - 3.times do |i| - user = create(:user, name: "member_#{i}", email: "member_#{i}_#{SecureRandom.hex(2)}@example.com") - participant = create(:assignment_participant, user: user, assignment: assignment) - mentored_team.add_member(participant) - end - - # Team should be full (3 regular members + 1 mentor, but mentor doesn't count) - expect(mentored_team.full?).to be true - expect(mentored_team.participants.count).to eq(4) # 3 members + 1 mentor + # --- 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 - it 'returns false when under capacity' do - # Create and assign a mentor - mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) - assignment.duties << mentor_duty - mentor_user = create(:user, name: 'capacity_mentor_2', email: "cap_mentor2_#{SecureRandom.hex(3)}@example.com") - mentored_team.assign_mentor(mentor_user) - - # Add only 1 regular member (capacity is 3) - user = create(:user, name: "member_solo", email: "solo_#{SecureRandom.hex(2)}@example.com") - participant = create(:assignment_participant, user: user, assignment: assignment) - mentored_team.add_member(participant) - - expect(mentored_team.full?).to be false + 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 - describe '#mentor' do - it 'returns the mentor user' do - mentor_duty = Duty.create!(name: 'Mentor', instructor: instructor) - assignment.duties << mentor_duty - mentor_user = create(:user, name: 'mentor_getter', email: "get_mentor_#{SecureRandom.hex(3)}@example.com") + describe '#full?' do + it 'does not count the mentor toward team capacity' do + # Assignment max_team_size is 2 - mentored_team.assign_mentor(mentor_user) + # 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 '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(mentored_team.mentor).to eq(mentor_user) + expect(team.size).to eq(2) + expect(team.full?).to be true end + end - it 'returns nil when no mentor assigned' do - # Team without mentor (using validation bypass) - team_no_mentor = MentoredTeam.new(name: "No Mentor Team", parent_id: assignment.id, assignment: assignment) - team_no_mentor.save!(validate: false) - - expect(team_no_mentor.mentor).to be_nil + # --- 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 - end - describe '#add_non_mentor_member' do - it 'is called internally by add_member for non-mentor participants' do - user = create(:user) - participant = create(:assignment_participant, user: user, assignment: assignment) - - # add_member should delegate to add_non_mentor_member for regular participants - expect(mentored_team).to receive(:add_non_mentor_member).and_call_original - mentored_team.add_member(participant) + it 'returns nil when no mentor is assigned' do + expect(team.mentor).to be_nil end end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index b076dbbab..4fad330c8 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -2,126 +2,46 @@ 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 - - # ------------------------------------------------------------------------ - # 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!(:course) { create(:course) } + let!(:assignment) { create(:assignment, max_team_size: 3) } - # 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', - ) - end - - let(:course_team) do - CourseTeam.create!( - parent_id: course.id, - name: 'team 2', - ) - 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 '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 @@ -129,11 +49,6 @@ def create_student(suffix) # Tests for polymorphic abstract methods # ------------------------------------------------------------------------ describe 'abstract methods' do - it 'raises NotImplementedError for parent_entity on base Team class' do - team = Team.new(parent_id: assignment.id, type: 'AssignmentTeam') - # Since we're using STI, we can't instantiate base Team, but we can test the behavior - expect { Team.new.parent_entity }.to raise_error(NotImplementedError) - end it 'implements parent_entity for AssignmentTeam' do expect(assignment_team.parent_entity).to eq(assignment) @@ -162,141 +77,77 @@ def create_student(suffix) # ------------------------------------------------------------------------ # 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 { result = assignment_team.add_member(participant) expect(result[:success]).to be true @@ -305,66 +156,60 @@ def create_student(suffix) 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("full capacity") + expect(result[:error]).to include("Team is at full capacity") end it 'validates participant type matches team type' do - user = create_student("wrong_type") - # Create a CourseParticipant instead of AssignmentParticipant - participant = CourseParticipant.create!(parent_id: course.id, user: user, handle: user.name) + # Create a CourseParticipant + wrong_participant = create(:course_participant, course: course) - result = assignment_team.add_member(participant) + result = assignment_team.add_member(wrong_participant) expect(result[:success]).to be false - expect(result[:error]).to match(/Cannot add CourseParticipant to AssignmentTeam/) + + 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 { 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 - user = create_student("wrong_type_course") - # Create an AssignmentParticipant instead of CourseParticipant - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + # Create an AssignmentParticipant + wrong_participant = create(:assignment_participant, assignment: assignment) - result = course_team.add_member(participant) + result = course_team.add_member(wrong_participant) expect(result[:success]).to be false - expect(result[:error]).to match(/Cannot add AssignmentParticipant to CourseTeam/) + + expect(result[:error]).to match(/Participant belongs to.*Assignment.*but this team belongs to.*Course/) end end end @@ -375,11 +220,10 @@ def create_student(suffix) describe '#size' do it 'returns the number of participants' do expect(assignment_team.size).to eq(0) - - user = create_student("size_test") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + + participant = create(:assignment_participant, assignment: assignment) assignment_team.add_member(participant) - + expect(assignment_team.size).to eq(1) end end @@ -390,10 +234,9 @@ def create_student(suffix) end it 'returns false when participants exist' do - user = create_student("empty_test") - participant = AssignmentParticipant.create!(parent_id: assignment.id, user: user, handle: user.name) + participant = create(:assignment_participant, assignment: assignment) assignment_team.add_member(participant) - + expect(assignment_team.empty?).to be false end end diff --git a/spec/requests/api/v1/teams_spec.rb b/spec/requests/api/v1/teams_spec.rb index e1e5d25aa..6d4c25dda 100644 --- a/spec/requests/api/v1/teams_spec.rb +++ b/spec/requests/api/v1/teams_spec.rb @@ -28,142 +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', - ) - end - - let(:team_with_assignment) do - AssignmentTeam.create!( - parent_id: assignment.id, - name: 'team 1', - ) - 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 - 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) - end - - it 'returns multiple teams of different types' do - team_with_course - team_with_assignment + 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(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 - expect(response).to have_http_status(:success) - expect(json_response['id']).to eq(team_with_course.id) - end - - it 'returns team with correct parent_type using polymorphic method' 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['parent_type']).to eq('course') + expect(json_response['id']).to eq(course_team.id) + expect(json_response['parent_type']).to eq('course') # Check polymorphic method end - it 'returns assignment team with correct parent_type' do - get "/teams/#{team_with_assignment.id}", headers: auth_headers + 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['parent_type']).to eq('assignment') + 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 @@ -173,12 +73,6 @@ end describe 'POST /teams' do - it 'returns error for invalid 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') - end - it 'creates an AssignmentTeam with valid params' do valid_params = { team: { @@ -187,11 +81,11 @@ 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 @@ -204,215 +98,135 @@ 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 - } + 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') + end 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 it 'returns empty array for team with no members' do - get "/teams/#{team_with_course.id}/members", headers: auth_headers + get "/teams/#{course_team.id}/members", headers: auth_headers expect(response).to have_http_status(:success) expect(json_response).to be_empty end end describe 'POST /teams/:id/members' do - let(:new_user) { create(:user) } + let(:participant_params) { { team_participant: { user_id: new_user.id } } } context 'for CourseTeam' do - 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 - } - } - end + # 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 using polymorphic participant_class' do + it 'adds a new team member' do expect { - post "/teams/#{team_with_course.id}/members", params: valid_participant_params, headers: auth_headers + 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 not a participant in course' do + 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/#{team_with_course.id}/members", params: params, headers: auth_headers + 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 context 'for AssignmentTeam' do - let!(:new_assignment_participant) { create(:assignment_participant, user: new_user, parent_id: assignment.id) } - - let(:assignment_params) do - { - team_participant: { - user_id: new_user.id - } - } - end + # Create the participant record + let!(:new_assignment_participant) { create(:assignment_participant, user: new_user, assignment: assignment) } - it 'adds a new team member using polymorphic participant_class' do + it 'adds a new team member' do expect { - post "/teams/#{team_with_assignment.id}/members", params: assignment_params, headers: auth_headers + 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 - # Set max_team_size to 1 and add first member - assignment.update(max_team_size: 1) - teams_participant_assignment # This creates the first member - - # Try to add a second member when team is full - post "/teams/#{team_with_assignment.id}/members", params: assignment_params, headers: auth_headers + 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/#{team_with_assignment.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 + params = { team_participant: { user_id: non_participant_user.id } } - context 'type validation' do - it 'prevents adding CourseParticipant to AssignmentTeam' do - # Create a course participant - course_user = create(:user) - course_participant = create(:course_participant, user: course_user, parent_id: course.id) - - # Try to add to assignment team (this should fail in the model layer) - # The controller will find no participant because it looks for AssignmentParticipant - params = { - team_participant: { - user_id: course_user.id - } - } - - post "/teams/#{team_with_assignment.id}/members", params: params, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) - end - - it 'prevents adding AssignmentParticipant to CourseTeam' do - # Create an assignment participant - assignment_user = create(:user) - assignment_participant = create(:assignment_participant, user: assignment_user, parent_id: assignment.id) - - # Try to add to course team (this should fail in the model layer) - # The controller will find no participant because it looks for CourseParticipant - params = { - team_participant: { - user_id: assignment_user.id - } - } - - post "/teams/#{team_with_course.id}/members", params: params, headers: auth_headers + 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/#{team_with_course.id}/members/#{other_user.id}", headers: auth_headers + 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 'returns 404 for non-existent member' do - delete "/teams/#{team_with_course.id}/members/0", headers: auth_headers - expect(response).to have_http_status(:not_found) - end + it 'removes a team member from an assignment team' do + participant = create(:assignment_participant, assignment: assignment) + assignment_team.add_member(participant) - it 'removes member from assignment team' do - teams_participant_assignment expect { - delete "/teams/#{team_with_assignment.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 - end - end - - describe 'Polymorphic behavior' do - it 'CourseTeam uses CourseParticipant class' do - expect(team_with_course.participant_class).to eq(CourseParticipant) - end - - it 'AssignmentTeam uses AssignmentParticipant class' do - expect(team_with_assignment.participant_class).to eq(AssignmentParticipant) - end - - it 'CourseTeam has correct parent_entity' do - expect(team_with_course.parent_entity).to eq(course) - end - - it 'AssignmentTeam has correct parent_entity' do - expect(team_with_assignment.parent_entity).to eq(assignment) - end - it 'CourseTeam has correct context_label' do - expect(team_with_course.context_label).to eq('course') - end - - it 'AssignmentTeam has correct context_label' do - expect(team_with_assignment.context_label).to eq('assignment') + it 'returns 404 for non-existent member' do + 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