CSC/ECE 517 Spring 2026 - E2610. Teams hierarchy testing
About This Project
| Project Info | |
|---|---|
| Course | CSC/ECE 517 Spring 2026 |
| Project | E2610 — Teams Hierarchy Testing |
| Instructor | Ed Gehringer |
| Mentor | Vihar Manojkumar Shah |
| Collaborators | Atharva Waingankar, Krisha Darji, Saladin Al-Bataineh |
| Platform | Expertiza (Ruby on Rails) |
| Test Framework | RSpec |
| Root Class | Team (STI superclass)
|
| Subclasses | CourseTeam, AssignmentTeam, MentoredTeam
|
| Membership Join Model | TeamsParticipant
|
The Expertiza platform organises student collaboration through a structured class hierarchy dedicated to managing teams. Teams are the fundamental unit of participation: they allow students to reserve topics, collaborate on coursework, and submit assignments for evaluation.
This project focuses on ensuring the correctness of the Team hierarchy and strengthening automated test coverage. The work emphasises membership validity (enrolment-based participation), preventing duplicate membership across teams, enforcing capacity limits when configured, ensuring MentoredTeam uses duty-based mentor identification, and validating controller authorisation and HTTP response behaviour.
Background
The Team Hierarchy
The hierarchy is composed of exactly four classes:
Team— STI superclass providing shared associations and common behavioursCourseTeam— course-scoped teams that persist across a courseAssignmentTeam— assignment-scoped teams created for a single assignmentMentoredTeam— subclass ofAssignmentTeamwith a designated mentor
Two broad kinds of team exist:
- Course teams (
CourseTeam) persist throughout a course. In some instructional designs (e.g., Team-Based Learning), these teams are reused for multiple assignments. - Assignment teams (
AssignmentTeam) exist only for a single assignment. When mentors are used, assignment teams are instantiated asMentoredTeam.
Class Hierarchy Diagram
┌─────────────────┐
│ Team │
│ (STI parent) │
└────────┬────────┘
│
┌─────────────────┴──────────────────┐
│ │
┌────────┴────────┐ ┌──────────┴──────────┐
│ CourseTeam │ │ AssignmentTeam │
│ (course-scoped) │ │ (assignment-scoped) │
└─────────────────┘ └──────────┬──────────┘
│
┌──────────┴──────────┐
│ MentoredTeam │
│ (mentor via duty) │
└─────────────────────┘
Subclass Responsibilities
| Class | Scope | Key Constraint | Notes |
|---|---|---|---|
Team |
— | Shared membership/associations | Parent class for all team types (STI) |
CourseTeam |
Course | Member must be a course participant | Can be converted to/from assignment teams |
AssignmentTeam |
Assignment | Member must be an assignment participant | Capacity comes from Assignment#max_team_size
|
MentoredTeam |
Assignment | Mentor identified by participant duty | Uses Duty + participant duty_id
|
Duty vs. Role Distinction
A critical design point (and a core source of bugs in this area) is the separation between a user's role and a participant's duty.
| Concept | Definition | Examples |
|---|---|---|
| Role | System-level permission level assigned to a user account | Instructor, Teaching Assistant, Student
|
| Duty | A function assigned to a participant within a specific team | Submitter, Reviewer, Mentor
|
In Expertiza, mentors are not privileged system accounts; they are normal users assigned the duty of Mentor on one team. Therefore, mentor logic must use participant duty rather than user role.
Previous Implementation
1) MentoredTeam used role-based assumptions
The incorrect approach was to treat “mentor” as a system-level role check. This is incompatible with Expertiza’s domain model, where mentor is a team-level duty assigned to a participant.
What we changed: MentoredTeam mentor assignment and mentor discovery use Duty and the participant’s duty_id.
2) Capacity enforcement was inconsistent / bypassable
Membership could be created through multiple paths (domain method, controllers, join-request acceptance). If enforcement only happened in one place, direct creation of TeamsParticipant could bypass capacity checks.
What we changed: Capacity is enforced at both:
- the domain method level (
Team#add_member) - the join model level (
TeamsParticipantvalidation backstop)
Join-request acceptance also checks capacity inside a lock for race-safety.
3) TeamsController authorisation was not explicit
The authorisation framework existed (global before_action), but TeamsController did not implement an explicit access policy. As a result, student actions could succeed where they should be forbidden.
What we changed: TeamsController now defines action_allowed?, and request specs verify correct 403 Forbidden behaviour.
4) CourseTeam capacity was assumed without schema support
The current schema does not provide a max_team_size attribute for courses. Attempting to enforce CourseTeam capacity via course.max_team_size results in runtime errors and breaks previously passing tests.
What we changed: Capacity enforcement is based on assignment configuration (Assignment#max_team_size). CourseTeams remain uncapped by default unless the schema is extended in the future.
Implementation Summary
1) Enrollment-based membership
Membership is grounded in valid participation. A user can only be added if the appropriate participant record exists in the correct parent scope.
# app/models/team.rb
participant_type = is_a?(AssignmentTeam) ? AssignmentParticipant : CourseParticipant
participant_type.find_by(user_id: participant_or_user.id, parent_id: parent_id)
2) Prevent duplicate membership across teams
The join model prevents a participant from appearing on multiple teams:
# app/models/teams_participant.rb
validates :participant_id, uniqueness: true
A unique index migration reinforces this at the database level.
3) Capacity enforcement (configured via Assignment)
Capacity is enforced via the assignment’s max_team_size. When at capacity, attempts to add are rejected with an error.
# app/models/team.rb
def full?
max = max_size
return false if max.blank?
participants.count >= max
end
# app/models/team.rb
return { success: false, error: "Unable to add participant: team is at full capacity." } if full?
4) Join-model capacity backstop
Even direct join-table creation is blocked when a team is at capacity.
# app/models/teams_participant.rb
validate :team_not_full, on: :create
def team_not_full
return unless team
max = team.max_size
return if max.blank?
if team.participants.count >= max
errors.add(:base, "Team is at full capacity (max #{max}).")
end
end
5) Race-safe join request acceptance
Acceptance checks capacity while holding a lock to prevent two concurrent acceptances from overfilling a team.
# app/controllers/join_team_requests_controller.rb (accept)
ActiveRecord::Base.transaction do
team.with_lock do
if team.full?
# reject accept
end
result = team.add_member(participant)
# reject if result failed
end
@join_team_request.update!(reply_status: ACCEPTED)
end
6) TeamsController authorisation and HTTP codes
TeamsController now declares an explicit policy:
- Teaching staff (TA and above): allowed
- Students: cannot list all teams, cannot manage membership, can only view teams they belong to
Request specs assert that restricted actions return 403 with an error payload.
RSpec Test Coverage
The test suite is organised into model specs (domain rules) and request specs (API behaviour + HTTP status codes).
Model Specs
| Area | Spec File(s) | What is validated |
|---|---|---|
| Team membership + validations | spec/models/team_spec.rb |
Type/parent validation, membership checks, full?, add_member
|
| Capacity behaviour | spec/models/team_capacity_spec.rb |
AssignmentTeam capacity and error rejection |
| Association integrity | spec/models/team_association_spec.rb |
Parent linkage and membership scoping |
| Conversion behaviour | spec/models/team_conversion_spec.rb |
CourseTeam ⇄ AssignmentTeam conversions and member copying |
| MentoredTeam duty behaviour | spec/models/mentored_team_spec.rb |
Mentor duty assignment/removal and mentor lookup by duty |
| Join model constraints | spec/models/teams_participant_spec.rb |
Uniqueness, presence, and capacity backstop (no bypass) |
Request Specs
| Endpoint Area | Spec File(s) | What is validated |
|---|---|---|
| Teams API | spec/requests/api/v1/teams_controller_spec.rb |
Index/show/members/add/remove + authorisation status codes |
| JoinTeamRequests API | spec/requests/api/v1/join_team_requests_controller_spec.rb |
Authorisation, create/accept/decline, capacity rejection and rollback behaviour |
| TeamsParticipants API | spec/requests/api/v1/teams_participants_controller_spec.rb |
Duty update authorisation, list/add/delete flows |
Important Example Tests
Capacity and size limits
# spec/models/team_capacity_spec.rb
it 'rejects member when team is full' do
2.times { |i| team.add_member(make_participant("full#{i}")) }
extra = make_participant('extra')
result = team.add_member(extra)
expect(result[:success]).to be false
expect(result[:error]).to match(/capacity/i)
end
Purpose: verifies domain-level capacity enforcement returns a structured failure and a meaningful error.
# spec/models/teams_participant_spec.rb
describe 'capacity validation (team_not_full)' do
it 'raises when creating beyond capacity' do
assignment.update!(max_team_size: 1)
team = AssignmentTeam.create!(name: 'Cap Team2', parent_id: assignment.id)
u1 = make_user('cap2_u1')
p1 = AssignmentParticipant.create!(user: u1, parent_id: assignment.id, handle: u1.name)
TeamsParticipant.create!(team: team, participant: p1, user: u1)
u2 = make_user('cap2_u2')
p2 = AssignmentParticipant.create!(user: u2, parent_id: assignment.id, handle: u2.name)
expect {
TeamsParticipant.create!(team: team, participant: p2, user: u2)
}.to raise_error(ActiveRecord::RecordInvalid)
end
end
Purpose: ensures capacity cannot be bypassed by direct join-table creation.
Join request acceptance rollback correctness
# spec/requests/api/v1/join_team_requests_controller_spec.rb
it 'does not change reply_status or add participant when team is full' do
assignment.update!(max_team_size: 1)
patch "/join_team_requests/#{join_team_request.id}/accept", headers: team_member_headers
expect(response).to have_http_status(:unprocessable_entity)
expect(join_team_request.reload.reply_status).to eq('PENDING')
expect(team1.participants.reload).not_to include(participant2)
end
Purpose: verifies the accept endpoint rejects at capacity and does not mutate state.
TeamsController authorisation / HTTP status codes
# spec/requests/api/v1/teams_controller_spec.rb
it 'returns 403 for student on GET /teams' do
student_token = JsonWebToken.encode(id: other_user.id)
student_headers = { Authorization: "Bearer #{student_token}" }
get '/teams', headers: student_headers
expect(response).to have_http_status(:forbidden)
expect(JSON.parse(response.body)).to have_key('error')
end
Purpose: ensures a student cannot list teams and receives 403 with an error payload.
MentoredTeam duty-based mentor behaviour
# spec/models/mentored_team_spec.rb
it 'identifies mentor by duty not by role' do
mentor_duty
participant = make_participant('duty_mentor')
team.add_member(participant)
team.assign_mentor(participant.user)
expect(team.send(:mentor)).to eq(participant.user)
expect(participant.reload.duty).to eq(mentor_duty)
end
Purpose: proves mentor identification is duty-based, matching Expertiza’s domain model.
Demo Video
https://www.youtube.com/watch?v=KZh-Kwhduok
Summary
This project strengthens the Teams hierarchy by ensuring membership is grounded in enrolment, preventing duplicate membership, enforcing assignment team capacity with both domain-level checks and a join-table backstop, making join-request acceptance race-safe with locking, and implementing explicit TeamsController authorisation rules with request specs that validate correct HTTP response codes. MentoredTeam is tested to rely on participant duty (Mentor) rather than user role.
References
- Expertiza project specification — Teams hierarchy testing (Mentor: Vihar Manojkumar Shah)
- Rails Guides — Active Record Associations
- RSpec Documentation