CSC/ECE 517 Spring 2026 - E2617. Testing Questionnaire and Course Models

From Expertiza_Wiki
Jump to navigation Jump to search

Project Description

This project is oriented around the testing of three models representing the individual reviews that each student will be conducting for an assignment. The Questionnaire model represents a review rubric used in assignments. It holds a set of scored questions with configurable min/max score bounds, belongs to an instructor, and supports being copied (along with all its questions and advice) for reuse. It also provides scoring utilities used during assignment evaluation. The QuestionAdvice model represents the score-specific feedback hints attached to a question in a questionnaire (e.g., "if you give a score of 3, here's what that means"). It provides class-level methods to export advice records to CSV and to fetch advice for a given question as JSON. The Course model represents a course in the system. It manages the course's filesystem submission path, handles adding and removing Teaching Assistants (updating their role accordingly via TaMapping), and supports duplicating a course via copy_course. The emphasis is on making sure that the methods associated with the model classes for Questionnaires, QuestionAdvice, and Course are tested using RSpec unit testing and resolving any bugs that are present in the current implementation.

Project Info
Course CSC/ECE 517 Spring 2026
Project E2617 — Testing Questionnaire and Course Models
Instructor Ed Gehringer
Mentor Aanand Sreekumaran Nair Jayakumari
Collaborators DJ Hansen, Zack Brooks, Matt Nguyen
Platform Expertiza (Ruby on Rails)
Test Framework RSpec
Root Class ApplicationRecord (STI superclass)
Subclasses Questionnaire, QuestionAdvice, Course

Model Documentation

  • Questionnaire Model: [1]
  • QuestionAdvice Model: [2]
  • Course Model: [3]


Question Model: [4]

Questions are referred to as "Items" in some of the test cases. While they aren't being directly covered by test cases, factories for the Question Model are needed so that the relationship between Questionnaire and QuestionAdvice are properly connected. The Questionnaire model has a one-many relationship with the Questions model, where a Questionnaire can have many Questions while each Question belongs to a single Questionnaire. The QuestionAdvice model has a one-one relationship with the Question model and a QuestionAdvice entry belongs to a single Question.

Method Descriptions

The next section outlines the existing implementation of the methods to be unit tested.

Questionnaire Model Methods

1. validate_questionnaire

Validates a questionnaire by ensuring:

  • Maximum score is a positive integer
  • Minimum score is a non-negative integer
  • Minimum score is less than the maximum score
  • Questionnaire name is unique per instructor
def validate_questionnaire
  errors.add(:max_question_score, 'The maximum item score must be a positive integer.') if max_question_score < 1
  errors.add(:min_question_score, 'The minimum item score must be a positive integer.') if min_question_score < 0
  errors.add(:min_question_score, 'The minimum item score must be less than the maximum.') if min_question_score >= max_question_score
  results = Questionnaire.where('id <> ? and name = ? and instructor_id = ?', id, name, instructor_id)
  errors.add(:name, 'Questionnaire names must be unique.') if results.present?
end

2. self.copy_questionnaire_details

Clones a questionnaire, including its items and associated advice.

def self.copy_questionnaire_details(params)
  orig_questionnaire = Questionnaire.find(params[:id])
  items = Item.where(questionnaire_id: params[:id])
  questionnaire = orig_questionnaire.dup
  questionnaire.instructor_id = params[:instructor_id]
  questionnaire.name = 'Copy of ' + orig_questionnaire.name
  questionnaire.created_at = Time.zone.now
  questionnaire.save!

  items.each do |question|
    new_question = question.dup
    new_question.questionnaire_id = questionnaire.id
    new_question.size = '50,3' if (new_question.is_a?(Criterion) || new_question.is_a?(TextResponse)) && new_question.size.nil?
    new_question.save!

    advice = QuestionAdvice.where(question_id: question.id)
    next if advice.empty?

    advice.each do |advice|
      new_advice = advice.dup
      new_advice.question_id = new_question.id
      new_advice.save!
    end
  end

  questionnaire
end

3. check_for_question_associations

Checks whether the questionnaire has associated items before deletion.

def check_for_question_associations
  if items.any?
    raise ActiveRecord::DeleteRestrictionError.new("Cannot delete record because dependent items exist")
  end
end

4. get_weighted_score

Calculates the weighted score by generating a symbol and calling a helper method.

def get_weighted_score(assignment, scores)
  # create symbol for "varying rubrics" feature -Yang
  round = AssignmentQuestionnaire.find_by(assignment_id: assignment.id, questionnaire_id: id).used_in_round

  questionnaire_symbol = if round.nil?
                           symbol
                         else
                           (symbol.to_s + round.to_s).to_sym
                         end

  compute_weighted_score(questionnaire_symbol, assignment, scores)
end

5. compute_weighted_score

Helper method that computes the weighted score for an assignment.

def compute_weighted_score(symbol, assignment, scores)
  aq = AssignmentQuestionnaire.find_by(assignment_id: assignment.id)

  if scores[symbol][:scores][:avg].nil?
    0
  else
    scores[symbol][:scores][:avg] * aq.questionnaire_weight / 100.0
  end
end

6. true_false_items?

Checks if the questionnaire contains any true/false (checkbox) items.

def true_false_items?
  items.each { |question| return true if question.type == 'Checkbox' }
  false
end

7. max_possible_score

Calculates the maximum possible score based on item weights and max question score.

def max_possible_score
  results = Questionnaire.joins('INNER JOIN items ON items.questionnaire_id = questionnaires.id')
                         .select('SUM(items.weight) * questionnaires.max_question_score as max_score')
                         .where('questionnaires.id = ?', id)
  results[0].max_score
end

QuestionAdvice Model Methods

1. self.export_fields

Returns all column names of QuestionAdvice as strings.

def self.export_fields(_options)
  QuestionAdvice.columns.map(&:name)
end

2. self.export

Exports QuestionAdvice entries to a CSV file.

def self.export(csv, parent_id, _options)
  questionnaire = Questionnaire.find(parent_id)

  questionnaire.items.each do |item|
    QuestionAdvice.where(question_id: item.id).each do |advice|
      csv << advice.attributes.values
    end
  end
end

3. self.to_json_by_question_id

Exports QuestionAdvice entries to JSON format by question ID.

def self.to_json_by_question_id(question_id)
  question_advices = QuestionAdvice.where(question_id: question_id).order(:id)

  question_advices.map do |advice|
    { score: advice.score, advice: advice.advice }
  end
end

Course Model Methods

1. path

Returns the submission directory path for the course.

def path
  raise 'Path can not be created as the course must be associated with an instructor.' if instructor_id.nil?

  Rails.root + '/' +
    Institution.find(institution_id).name.gsub(" ", "") + '/' +
    User.find(instructor_id).name.gsub(" ", "") + '/' +
    directory_path + '/'
end

2. add_ta

Adds a Teaching Assistant (TA) to the course.

def add_ta(user)
  if user.nil?
    return { success: false, message: "The user with id #{user.id} does not exist" }

  elsif TaMapping.exists?(user_id: user.id, course_id: id)
    return { success: false, message: "The user with id #{user.id} is already a TA for this course." }

  else
    ta_mapping = TaMapping.create(user_id: user.id, course_id: id)
    ta_role = Role.find_by(name: 'Teaching Assistant')
    user.update(role: ta_role) if ta_role

    if ta_mapping.save
      return { success: true, data: ta_mapping.slice(:course_id, :user_id) }
    else
      return { success: false, message: ta_mapping.errors }
    end
  end
end

3. remove_ta

Removes a Teaching Assistant from the course.

def remove_ta(user_id)
  ta_mapping = ta_mappings.find_by(user_id: user_id, course_id: :id)
  return { success: false, message: "No TA mapping found for the specified course and TA" } if ta_mapping.nil?

  ta = User.find(ta_mapping.user_id)
  ta_count = TaMapping.where(user_id: user_id).size - 1

  if ta_count.zero?
    ta.update(role: Role::STUDENT)
  end

  ta_mapping.destroy
  { success: true, ta_name: ta.name }
end

4. copy_course

Creates a duplicate of the course.

def copy_course
  new_course = dup
  new_course.directory_path += '_copy'
  new_course.name += '_copy'
  new_course.save
end

Test Design

For this project, we will be using factories and fixtures to generate our models for unit testing with RSpec.

Questionnaire

Currently for Questionnaire, the data for unit testing is currently being created using Factory so we will continue to write tests that ensure over 80% code coverage. We will also use factories for the Course model as the method logic is dependent on varying data. It allows us to adjust parameters and test for edge cases easier.

The following code is currently how the factory for the Questionnaire model is set up.

# spec/factories/questionnaires.rb
FactoryBot.define do
  factory :questionnaire do
    sequence(:name) { |n| "Questionnaire #{n}" }
    private { false }
    min_question_score { 0 }
    max_question_score { 10 }
    association :instructor
    association :assignment

    # Trait for questionnaire with questions
    trait :with_questions do
      after(:create) do |questionnaire|
        create(:item, questionnaire: questionnaire, weight: 1, seq: 1, txt: "que 1", question_type: "Scale")
        create(:item, questionnaire: questionnaire, weight: 10, seq: 2, txt: "que 2", question_type: "Checkbox")
      end
    end
  end
end

The following code serves as the factory for generating instances of items which are essentially questions in a questionnaire object.

# spec/factories/items.rb
FactoryBot.define do
  factory :item do
    sequence(:txt) { |n| "Question #{n}" }
    sequence(:seq) { |n| n }
    weight { 1 }
    question_type { "Scale" }
    break_before { true }
    association :questionnaire
  end
end

Course

The following code is the current version of the factory for the course models.

FactoryBot.define do
  factory :course do
    name { Faker::Educator.course_name }
    info { Faker::Lorem.paragraph }
    private { false }
    directory_path { Faker::File.dir }
    association :institution, factory: :institution
    association :instructor, factory: :user
  end
end

QuestionAdvice

For QuestionAdvice use factories to load data into database to test that it is being saved and exported properly.

# spec/factories/question_advice.rb
FactoryBot.define do
    factory :question_advice do
        score {5}
        association :item
        advice {'default advice'}
    end
end

Test Methods Overview

Questionnaire Model Test Setup

This suite provides a comprehensive setup for testing questionnaire behavior, including validation rules, scoring logic, duplication, and utility methods.

Key setup features:

  • Stubbed User defaults
  • Multiple questionnaire instances:
  let(:questionnaire) { create(:questionnaire, max_question_score: 10) }
  let(:questionnaire1) { build(:questionnaire, max_question_score: 20) }
  let(:questionnaire2) { build(:questionnaire, max_question_score: 10) }
  • Associated questions with weights:
  let(:question1) { create(:item, weight: 1) }
  let(:question2) { create(:item, weight: 10) }
  • Mocked dependencies:
 * AssignmentQuestionnaire for scoring logic
 * Participant/review objects using doubles

This setup supports testing:

  • Field validations (name, min/max scores)
  • Associations with items
  • Deep copy functionality (including nested advice records)
  • Scoring algorithms and weighted calculations
  • Serialization and helper methods

The structure ensures isolation of logic while simulating realistic relationships between models.

Questionnaire Model Test Methods

Test Method Description Code Snippet
name returns correct values Verifies that questionnaire names are correctly assigned and retrieved.
expect(questionnaire.name).to eq('abc')
expect(questionnaire1.name).to eq('xyz')
expect(questionnaire2.name).to eq('pqr')
name presence validation Ensures the questionnaire name cannot be blank.
questionnaire.name = '  '
expect(questionnaire).not_to be_valid
instructor_id retrieval Confirms the questionnaire returns the correct instructor ID.
expect(questionnaire.instructor_id).to eq(instructor.id)
maximum_score validation (type and positivity) Validates max score is an integer, positive, and greater than minimum score.
expect(questionnaire.max_question_score).to eq(10)

questionnaire.max_question_score = 'a'
expect(questionnaire).not_to be_valid

questionnaire.max_question_score = -10
expect(questionnaire).not_to be_valid

questionnaire.max_question_score = 0
expect(questionnaire).not_to be_valid

questionnaire.min_question_score = 10
expect(questionnaire).not_to be_valid

questionnaire.min_question_score = 1
expect(questionnaire).to be_valid
minimum_score validation Ensures minimum score is valid, integer, and less than maximum score.
questionnaire.min_question_score = 5
expect(questionnaire.min_question_score).to eq(5)

questionnaire.min_question_score = 10
expect(questionnaire).not_to be_valid

questionnaire.min_question_score = 'a'
expect(questionnaire).not_to be_valid
association with items Verifies that a questionnaire has many associated questions.
question1
question2
expect(questionnaire.items.reload).to include(question1, question2)
copy_questionnaire_details creates copy Ensures a questionnaire copy is created with correct attributes.
question1
question2

copied_questionnaire =
  Questionnaire.copy_questionnaire_details(
    { id: questionnaire.id, instructor_id: instructor.id }
  )

expect(copied_questionnaire.instructor_id)
  .to eq(questionnaire.instructor_id)

expect(copied_questionnaire.name)
  .to eq("Copy of #{questionnaire.name}")

expect(copied_questionnaire.created_at)
  .to be_within(1.second).of(Time.zone.now)
copy_questionnaire_details copies items Verifies all questions are duplicated in the copied questionnaire.
question1
question2

copied_questionnaire =
  described_class.copy_questionnaire_details(
    { id: questionnaire.id, instructor_id: instructor.id }
  )

expect(copied_questionnaire.items.count).to eq(2)
expect(copied_questionnaire.items.first.txt).to eq(question1.txt)
expect(copied_questionnaire.items.second.txt).to eq(question2.txt)
copy_questionnaire_details copies advice Ensures associated advice records are duplicated with questions.
question1

QuestionAdvice.insert(
  {
    question_id: question1.id,
    score: 3,
    advice: 'Good rationale',
    created_at: Time.zone.now,
    updated_at: Time.zone.now
  }
)

copied_questionnaire =
  described_class.copy_questionnaire_details(
    { id: questionnaire.id, instructor_id: instructor.id }
  )

copied_question = copied_questionnaire.items.first
copied_advice = QuestionAdvice.where(question_id: copied_question.id)

expect(copied_advice.count).to eq(1)
expect(copied_advice.first.score).to eq(3)
expect(copied_advice.first.advice).to eq('Good rationale')
symbol method Confirms the questionnaire returns the correct symbol.
expect(questionnaire.symbol).to eq(:review)
get_assessments_for Verifies retrieval of participant reviews.
reviews = [double('review1'), double('review2')]
participant = double('participant', reviews: reviews)

expect(questionnaire.get_assessments_for(participant)).to eq(reviews)
check_for_question_associations restriction Prevents deletion when associated questions exist and allows otherwise.
question1
questionnaire.items.reload

expect {
  questionnaire.check_for_question_associations
}.to raise_error(ActiveRecord::DeleteRestrictionError)

questionnaire_without_items = build(:questionnaire)

expect {
  questionnaire_without_items.check_for_question_associations
}.not_to raise_error
as_json serialization Ensures JSON output includes required fields.
json = questionnaire.as_json
expect(json).to include('id', 'name', 'instructor')
get_weighted_score behavior Uses appropriate symbol depending on round configuration.
assignment = double('assignment', id: 42)

aq = double('assignment_questionnaire', used_in_round: nil)
allow(AssignmentQuestionnaire).to receive(:find_by)
  .with(assignment_id: 42, questionnaire_id: questionnaire.id)
  .and_return(aq)

expect(questionnaire).to receive(:compute_weighted_score)
  .with(:review, assignment, {})

questionnaire.get_weighted_score(assignment, {})
compute_weighted_score Calculates weighted score or returns zero if no average exists.
assignment = double('assignment', id: 77)

allow(AssignmentQuestionnaire).to receive(:find_by)
  .with(assignment_id: 77)
  .and_return(double('aq', questionnaire_weight: 25))

scores = { review: { scores: { avg: nil } } }
expect(questionnaire.compute_weighted_score(:review, assignment, scores)).to eq(0)

scores = { review: { scores: { avg: 8 } } }
expect(questionnaire.compute_weighted_score(:review, assignment, scores)).to eq(2.0)
true_false_items? Detects presence or absence of checkbox-type questions.
allow(questionnaire).to receive(:items)
  .and_return([double('item', type: 'Checkbox')])
expect(questionnaire.true_false_items?).to be(true)

allow(questionnaire).to receive(:items)
  .and_return([double('item', type: 'Criterion')])
expect(questionnaire.true_false_items?).to be(false)
max_possible_score Computes total possible score based on weights and max score.
question1
question2

# question1.weight = 1, question2.weight = 10
# max_question_score = 10

expect(questionnaire.max_possible_score.to_i).to eq(110)

QuestionAdvice Model Test Setup

This suite focuses on relationships between questionnaires, questions (items), and advice entries. It builds a structured dataset with multiple linked records.

Key setup features:

  • Stubbed User defaults
  • Questionnaire with scoring bounds:
  let(:questionnaire) { create(:questionnaire, min_question_score: 0, max_question_score: 10) }
  • Associated questions:
  let(:question1) { create(:item, questionnaire: questionnaire) }
  let(:question2) { create(:item, questionnaire: questionnaire) }
  • QuestionAdvice entries:
 * Default values (implicit score/advice)
 * Custom values for testing overrides

This setup enables validation of:

  • Default attribute values
  • Associations
  • Export functionality
  • JSON formatting methods

QuestionAdvice Model Test Methods

Test Method Description Code Snippet
question association returns correct question_id Verifies that each QuestionAdvice correctly references its associated question.
expect(question_advice1.question_id).to eq(question1.id)
expect(question_advice2.question_id).to eq(question2.id)
score returns correct value Ensures that the score attribute of QuestionAdvice is returned correctly, including defaults.
expect(question_advice1.score).to eq(5)
expect(question_advice2.score).to eq(6)
advice returns correct value Ensures that the advice attribute is returned correctly, including default advice text.
expect(question_advice1.advice).to eq('default advice')
expect(question_advice2.advice).to eq('advice for question 2')
export_fields mapping Verifies that export_fields returns the correct column mappings.
output = QuestionAdvice.export_fields({})
expect(output).to eq(["id", "question_id", "score", "advice", "created_at", "updated_at"])
export method stores values Tests that the export method correctly appends QuestionAdvice records to a CSV array.
csv = []
QuestionAdvice.export(csv, questionnaire.id, nil)
expect(csv.length).to eq(3)
to_json_by_question_id for question1 Verifies JSON output for all QuestionAdvice entries associated with question 1.
output = QuestionAdvice.to_json_by_question_id(question1.id)
expect(output).to eq([
  { score: 5, advice: 'default advice' },
  { score: 1, advice: 'advice for question 3' }
])
to_json_by_question_id for question2 Verifies JSON output for QuestionAdvice entries associated with question 2.
output = QuestionAdvice.to_json_by_question_id(question2.id)
expect(output).to eq([
  { score: 6, advice: 'advice for question 2'}
])

Course Model Test Setup

The Course test suite establishes core domain objects using factories, including an instructor, institution, and course. User callbacks are stubbed to avoid unintended initialization side effects.

Key setup features:

  • Stubbed User defaults:
  allow_any_instance_of(User).to receive(:set_defaults)
  • Core entities:
  let(:instructor) { create(:instructor) }
  let(:institution) { create(:institution) }
  let(:course) { create(:course, instructor: instructor, institution: institution) }
  • Additional supporting objects:
 * Users with roles (student, TA, instructor)
 * CourseParticipant and TaMapping records

This setup supports testing validations, associations, and course management methods such as TA assignment and course duplication.

Course Model Test Methods

Test Method Description Code Snippet
validates presence of name Ensures that a course must have a name to be valid.
course.name = ''
expect(course).not_to be_valid
validates presence of directory_path Ensures that a course must have a directory path to be valid.
course.directory_path = ' '
expect(course).not_to be_valid
returns users through participants Verifies that users associated through course participants are accessible.
student = create(:user, :student)
create(:course_participant, course: course, user: student)
expect(course.users).to include(student)
path raises error without instructor Ensures an error is raised when generating a path without an associated instructor.
allow(course).to receive(:instructor_id).and_return(nil)
expect { course.path }.to raise_error
path returns directory with instructor Verifies a directory path is returned when instructor and institution exist.
allow(course).to receive(:instructor_id).and_return(6)
allow(User).to receive(:find).with(6).and_return(user1)
expect(course.path.directory?).to be_truthy
add_ta success Adds a TA and updates the user role accordingly.
result = course.add_ta(ta_user)
expect(result[:success]).to be(true)
expect(result[:data]).to include('course_id' => course.id, 'user_id' => ta_user.id)
expect(ta_user.reload.role.name).to eq('Teaching Assistant')
add_ta already exists Prevents adding a TA who is already assigned to the course.
TaMapping.create!(user_id: ta_user.id, course_id: course.id)
result = course.add_ta(ta_user)
expect(result[:success]).to be(false)
add_ta nil user Returns an error when attempting to add a non-existent user.
result = course.add_ta(nil)
expect(result[:success]).to be(false)
add_ta save failure Returns validation errors when TA mapping fails to save.
allow(TaMapping).to receive(:create).and_return(double(save: false))
result = course.add_ta(ta_user)
expect(result[:success]).to be(false)
remove_ta no mapping Returns an error when no TA mapping exists.
result = course.remove_ta(ta_user.id)
expect(result[:success]).to be(false)
remove_ta success Removes a TA mapping and updates role if it was the last assignment.
ta_mapping = TaMapping.create!(user_id: ta_user.id, course_id: course.id)
allow(course.ta_mappings).to receive(:find_by).and_return(ta_mapping)
allow(TaMapping).to receive(:where).with(user_id: ta_user.id).and_return([ta_mapping])
stub_const('Role::STUDENT', student_role)
result = course.remove_ta(ta_user.id)
expect(result[:success]).to be(true)
copy_course success Creates a duplicate course with modified name and directory path.
result = course.copy_course
expect(result).to be(true)

Running Tests

The following steps can be used to run only the tests relevant to this project.

In spec_helper.rb:

1. Add the following line to the require section:

require 'simplecov-html'


2. Modify the SimpleCov.formatter assignment to be the following:

SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([
  SimpleCov::Formatter::HTMLFormatter,
  SimpleCov::Formatter::JSONFormatter
])


3. Replace the if !ENV['COVERAGE_STARTED'] block with the following:

if !ENV['COVERAGE_STARTED']
  SimpleCov.start 'rails' do
    track_files 'app/models/{questionnaire,question_advice,course}.rb'
    tracked = %w[
      app/models/questionnaire.rb
      app/models/question_advice.rb
      app/models/course.rb
    ].map { |path| File.expand_path(path, SimpleCov.root) }

    add_filter do |src|
      !tracked.include?(src.filename)
    end
  end
  ENV['COVERAGE_STARTED'] = 'true'
end

Then, run the test suite using the following:

bundle exec rspec spec/models/questionnaire_spec.rb spec/models/course_spec.rb spec/models/question_advice_spec.rb

Results

Coverage

Coverage results are shown in the table below:

File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/models/course.rb 100.00% 59 39 39 0 1.31
app/models/question_advice.rb 100.00% 24 13 13 0 1.54
app/models/questionnaire.rb 100.00% 141 64 64 0 6.36

Bugs Fixed

Handling Nil User in add_ta Method (Course Model)

A bug was identified in the Course model where the add_ta method would crash when a nil user was passed during unit testing. The issue occurred because the method attempted to access user.id without first verifying that the user object was present.

To prevent this runtime error, the method was updated to safely handle nil inputs using safe navigation (&.id) and an early return pattern.

Fixed implementation:

# Add a Teaching Assistant to the course
def add_ta(user)
  if user.nil?
    return { success: false, message: "The user with id #{user&.id} does not exist" }
  elsif TaMapping.exists?(user_id: user.id, course_id: id)
    return { success: false, message: "The user with id #{user.id} is already a TA for this course." }
  else
    # remaining logic...
  end
end

This fix ensures that the method no longer raises exceptions when a nil user is provided and instead returns a controlled error response, improving robustness and test reliability.

Corrected Course–User Association Mapping

A bug in the Course model was fixed where the users association incorrectly referenced a non-existent relationship (course_participants). The model already defines the correct join association as participants, so the additional reference caused confusion and potential runtime issues.

The association structure correctly uses CourseParticipant through participants, and users should be derived from this relationship.

Before fix:

has_many :participants, class_name: 'CourseParticipant', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course
has_many :users, through: :course_participants, inverse_of: :course

Correct association structure:

has_many :participants, class_name: 'CourseParticipant', foreign_key: 'parent_id', dependent: :destroy, inverse_of: :course
has_many :users, through: :participants

This fix ensures that the users association correctly resolves through the existing participants relationship, maintaining consistency in the model and preventing invalid association lookups.

Correct Foreign Key Mapping for QuestionAdvice Association

A fix was applied to the QuestionAdvice model to ensure the association correctly references the underlying database schema.

Previously, Rails was implicitly expecting a default foreign key (item_id), which did not match the actual schema. The question_advice table stores the foreign key as question_id, meaning the association must explicitly specify this key to function correctly.

To resolve this mismatch, the association was updated as follows:

class QuestionAdvice < ApplicationRecord
  belongs_to :item, foreign_key: :question_id
  # remaining logic...
end

This change ensures that Rails correctly maps each QuestionAdvice record to its associated Item using question_id, restoring proper association behavior and preventing incorrect lookups using the default key.

Fixing Invalid ID Reference in remove_ta Method

A bug was discovered in the remove_ta implementation within the Course model where the method was failing to locate existing TaMapping records. The issue was caused by passing the symbol :id as a parameter instead of the actual local id variable.

Because ActiveRecord treats symbols in query hashes literally, it attempted to find a record where the course_id was the literal string/symbol "id", rather than the integer ID of the course instance. This bug was previously masked in the test suite by heavy mocking of find_by and where. By removing the mocks and using actual database records in the test refactor, the failure was exposed.

Before fix:

ta_mapping = ta_mappings.find_by(user_id: user_id, course_id: :id)

Fixed implementation:

ta_mapping = ta_mappings.find_by(user_id: user_id, course_id: id)

This update ensures the query correctly references the course's unique identifier, allowing remove_ta to successfully locate and remove the mapping and subsequently update the user's role.

Github

Link to Github Repository: [5]

Link to Reimplementation Pull Request: [6]