CSC/ECE 517 Spring 2026 - E2617. Testing Questionnaire and Course Models
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]