diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..1f9f58b50 Binary files /dev/null and b/.DS_Store differ diff --git a/.rubocop.yml b/.rubocop.yml index 2fac0607b..504fdeb37 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,19 +13,19 @@ Style/FrozenStringLiteralComment: Metrics/BlockLength: Max: 120 Exclude: - - 'db/**/*.rb' + - "db/**/*.rb" Metrics/MethodLength: - Max: 20 - Exclude: - - 'db/**/*.rb' + Max: 20 + Exclude: + - "db/**/*.rb" Metrics/AbcSize: Max: 20 Exclude: - - 'db/**/*.rb' + - "db/**/*.rb" Style/StringLiterals: Enabled: true EnforcedStyle: single_quotes Exclude: - - 'db/**/*.rb' \ No newline at end of file + - "db/**/*.rb" diff --git a/Dangerfile b/Dangerfile index 6fd640064..bcf08c109 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,6 +1,14 @@ # Helper to safely read files in UTF-8 and avoid "invalid byte sequence" errors def safe_read(path) - File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) + return "" unless File.exist?(path) + + File.open(path, "rb") do |f| + f.read + .force_encoding("UTF-8") + .encode("UTF-8", invalid: :replace, undef: :replace, replace: "") + end +rescue + "" end # --- PR Size Checks --- diff --git a/README.md b/README.md index 2c94a747a..cc9158d87 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,18 @@ application up and running. Things you may want to cover: -* Ruby version - 3.4.5 +- Ruby version - 3.4.5 ## Development Environment ### Prerequisites + - Verify that [Docker Desktop](https://www.docker.com/products/docker-desktop/) is installed and running. - [Download](https://www.jetbrains.com/ruby/download/) RubyMine - Make sure that the Docker plugin [is enabled](https://www.jetbrains.com/help/ruby/docker.html#enable_docker). - ### Instructions + Tutorial: [Docker Compose as a remote interpreter](https://www.jetbrains.com/help/ruby/using-docker-compose-as-a-remote-interpreter.html) ### Video Tutorial @@ -25,5 +26,6 @@ Tutorial: [Docker Compose as a remote interpreter](https://www.jetbrains.com/hel alt="IMAGE ALT TEXT HERE" width="560" height="315" border="10" /> ### Database Credentials + - username: root - password: expertiza diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 000000000..707d64caa Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 74dfb9ecf..4051e9252 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,7 +9,9 @@ class ApplicationController < ActionController::API include Authorization include JwtToken - + prepend_before_action :set_response, only: %i[show update] + before_action :find_and_authorize_map_for_create, only: %i[create] # changed from prepend_before_action + before_action :authorize end diff --git a/app/controllers/responses_controller.rb b/app/controllers/responses_controller.rb new file mode 100644 index 000000000..b83b71eea --- /dev/null +++ b/app/controllers/responses_controller.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +class ResponsesController < ApplicationController + prepend_before_action :set_response, only: %i[show update] + before_action :find_and_authorize_map_for_create, only: %i[create] + + def action_allowed? + case action_name + when "create" + true # auth already handled by prepend_before_action above + when "show", "update" + @response && @response.map.reviewer.user_id == current_user.id + else + true + end + end + + def show + render json: { + response_id: @response.id, + map_id: @response.map_id, + task_type: @response.map.type, + submitted: @response.is_submitted, + additional_comment: @response.additional_comment + } + end + + def create + return unless enforce_task_order!(@map) + + round = (params[:round].presence || 1).to_i + response = Response.where(map_id: @map.id, round: round) + .order(:created_at) + .last || Response.new(map_id: @map.id, round: round) + + if params[:content].present? || params[:additional_comment].present? + response.additional_comment = params[:content].presence || params[:additional_comment] + end + + if response.save + render json: { response_id: response.id, map_id: @map.id, round: response.round }, status: :created + else + render json: { errors: response.errors.full_messages }, status: :unprocessable_entity + end + end + + def update + return unless enforce_task_order!(@response.map) + + if @response.update(response_update_params) + render json: { + response_id: @response.id, + map_id: @response.map_id, + submitted: @response.is_submitted, + additional_comment: @response.additional_comment + }, status: :ok + else + render json: { errors: @response.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_response + @response = Response.find(params[:id]) + end + + # Runs before action_allowed? — handles both existence and authorization for create + def find_and_authorize_map_for_create + @map = ResponseMap.find_by(id: params[:response_map_id]) + unless @map + render json: { error: "ResponseMap not found" }, status: :not_found + return + end + + unless @map.reviewer.user_id == current_user.id + render json: { error: "You are not authorized to create this responses" }, status: :forbidden + end + end + + + def response_update_params + p = params.permit(:is_submitted, :additional_comment, :content, :round) + p[:additional_comment] = p[:content] if p[:content].present? + p.delete(:content) + p + end + + def enforce_task_order!(map) + participant = map.reviewer + unless participant.user_id == current_user.id + render json: { error: "Unauthorized" }, status: :forbidden + return false + end + + team_participant = TeamsParticipant.find_by(participant_id: participant.id) + unless team_participant + render json: { error: "TeamsParticipant not found for reviewer" }, status: :forbidden + return false + end + + queue = TaskOrdering::TaskQueue.new(participant.assignment, team_participant) + unless queue.map_in_queue?(map.id) + render json: { error: "Response map is not a respondable task for this participant" }, status: :forbidden + return false + end + + unless queue.prior_tasks_complete_for?(map.id) + render json: { error: "You must complete prior tasks before responding to this one" }, status: :forbidden + return false + end + + true + end +end \ No newline at end of file diff --git a/app/controllers/student_tasks_controller.rb b/app/controllers/student_tasks_controller.rb index ffb6097a5..400406aaf 100644 --- a/app/controllers/student_tasks_controller.rb +++ b/app/controllers/student_tasks_controller.rb @@ -1,13 +1,11 @@ class StudentTasksController < ApplicationController - # List retrieves all student tasks associated with the current logged-in user. def action_allowed? current_user_has_student_privileges? end + def list - # Retrieves all tasks that belong to the current user. @student_tasks = StudentTask.from_user(current_user) - # Render the list of student tasks as JSON. render json: @student_tasks, status: :ok end @@ -15,14 +13,188 @@ def show render json: @student_task, status: :ok end - # The view function retrieves a student task based on a participant's ID. - # It is meant to provide an endpoint where tasks can be queried based on participant ID. def view - # Retrieves the student task where the participant's ID matches the provided parameter. - # This function will be used for clicking on a specific student task to "view" its details. @student_task = StudentTask.from_participant_id(params[:id]) - # Render the found student task as JSON. render json: @student_task, status: :ok end -end + def queue + queue = build_queue_for_user(params[:assignment_id]) + return render json: { error: "Not authorized or not found" }, status: :not_found unless queue + queue.ensure_response_objects! + render json: queue.tasks.map(&:to_task_hash), status: :ok + end + + def next_task + queue = build_queue_for_user(params[:assignment_id]) + return render json: { error: "Not authorized or not found" }, status: :not_found unless queue + queue.ensure_response_objects! + next_task = queue.tasks.find { |t| !t.completed? } + if next_task + render json: next_task.to_task_hash, status: :ok + else + render json: { message: "All tasks completed" }, status: :ok + end + end + + def start_task + map = ResponseMap.find_by(id: params[:response_map_id]) + return render json: { error: "ResponseMap not found" }, status: :not_found unless map + + participant = map.reviewer + if participant.user_id != current_user.id + return render json: { error: "Unauthorized" }, status: :forbidden + end + + team_participant = TeamsParticipant.find_by(participant_id: participant.id) + assignment = participant.assignment + queue = TaskOrdering::TaskQueue.new(assignment, team_participant) + tasks = queue.tasks + current_task = tasks.find { |t| (rm = t.response_map) && rm.id == map.id } + return render json: { error: "Task not in respondable queue" }, status: :not_found unless current_task + + previous_tasks = tasks.take_while { |t| t != current_task } + if previous_tasks.any? { |t| !t.completed? } + return render json: { error: "Complete previous task first" }, status: :forbidden + end + + current_task.ensure_response! + render json: { message: "Task started", task: current_task.to_task_hash }, status: :ok + end + + # =========================================================================== + # Inner classes + # =========================================================================== + + class BaseTaskItem + attr_reader :assignment, :team_participant, :review_map + + def initialize(assignment:, team_participant:, review_map:) + @assignment = assignment + @team_participant = team_participant + @review_map = review_map + end + + def participant + team_participant.participant + end + + def ensure_response! + map = response_map + return nil unless map + Response.find_or_create_by!(map_id: map.id, round: 1) do |r| + r.is_submitted = false + end + end + + def completed? + map = response_map + return false unless map + Response.exists?(map_id: map.id, round: 1, is_submitted: true) + end + + def to_h + map = response_map + { + task_type: task_type, + assignment_id: assignment.id, + response_map_id: map&.id, + response_map_type: map&.class&.name, + reviewee_id: map&.reviewee_id, + team_participant_id: team_participant.id + } + end + + # Alias so existing code using to_task_hash still works + alias to_task_hash to_h + end + + class ReviewTaskItem < BaseTaskItem + def task_type = :review + def response_map = review_map + end + + class QuizTaskItem < BaseTaskItem + def task_type = :quiz + + def response_map + existing = QuizResponseMap.find_by( + reviewer_id: participant.id, + reviewee_id: review_map.reviewee_id, + reviewed_object_id: assignment.id + ) + return existing if existing + + questionnaire = assignment.quiz_questionnaire_for_review_flow + return nil unless questionnaire + + map = QuizResponseMap.new( + reviewer_id: participant.id, + reviewee_id: review_map.reviewee_id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + end + + private + + def build_queue_for_user(assignment_id) + participant = Participant.find_by(user_id: current_user.id, parent_id: assignment_id) + return nil unless participant + team_participant = TeamsParticipant.find_by(participant_id: participant.id) + return nil unless team_participant + TaskOrdering::TaskQueue.new(participant.assignment, team_participant) + end + + def build_tasks(context) + assignment = context[:assignment] + participant = context[:participant] + team_participant = context[:team_participant] + duty = context[:duty] + tasks = [] + + review_maps = ReviewResponseMap.where(reviewer_id: participant.id) + + review_maps.each do |rm| + if duty.nil? || duty_allows_quiz?(duty) + tasks << QuizTaskItem.new(assignment: assignment, team_participant: team_participant, review_map: rm) if assignment.quiz_questionnaire_for_review_flow + end + if duty.nil? || duty_allows_review?(duty) + tasks << ReviewTaskItem.new(assignment: assignment, team_participant: team_participant, review_map: rm) + end + end + + if review_maps.empty? && (duty.nil? || duty_allows_quiz?(duty)) + if assignment.quiz_questionnaire_for_review_flow + tasks << QuizTaskItem.new(assignment: assignment, team_participant: team_participant, review_map: ReviewResponseMap.new) + end + end + + tasks + end + + def prior_tasks_complete?(tasks, current_task) + tasks.take_while { |t| t != current_task }.all?(&:completed?) + end + + def find_task_for_map(tasks, map_id) + tasks.find { |t| t.response_map&.id.to_s == map_id.to_s } + end + + def duty_allows_review?(duty) + return false if duty.nil? + %w[reviewer participant reader mentor].include?(duty.name) + end + + def duty_allows_quiz?(duty) + return false if duty.nil? + %w[participant reader mentor].include?(duty.name) + end + + def duty_allows_submit?(duty) + return false if duty.nil? + %w[submitter participant mentor].include?(duty.name) + end +end \ No newline at end of file diff --git a/app/models/.DS_Store b/app/models/.DS_Store new file mode 100644 index 000000000..fe0e53e53 Binary files /dev/null and b/app/models/.DS_Store differ diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 130fa6837..87852a039 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -20,10 +20,27 @@ class Assignment < ApplicationRecord #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 + # Returns the assignment-linked review questionnaire record. + # The assignment can be linked to many questionnaires via AssignmentQuestionnaire. + def review_questionnaire_for_review_flow + questionnaires.find_by(questionnaire_type: 'ReviewQuestionnaire') + end + def review_questionnaire_id Questionnaire.find_by_assignment_id id end + # Builds a TaskQueue for a TeamsParticipant row (duty + structural task list). + def respondable_task_queue(team_participant) + TaskOrdering::TaskQueue.new(self, team_participant) + end + + # Returns the quiz questionnaire used by the reviewer pre-check flow. + # If no quiz questionnaire is attached, the caller can skip quiz task creation. + def quiz_questionnaire_for_review_flow + questionnaires.find_by(questionnaire_type: 'QuizQuestionnaire') + end + def teams? @has_teams ||= teams.any? end diff --git a/app/models/student_task.rb b/app/models/student_task.rb index c5bb9332b..459ef7ca9 100644 --- a/app/models/student_task.rb +++ b/app/models/student_task.rb @@ -1,50 +1,66 @@ # frozen_string_literal: true class StudentTask - attr_accessor :assignment, :current_stage, :participant, :stage_deadline, :topic, :permission_granted - - # Initializes a new instance of the StudentTask class - def initialize(args) - @assignment = args[:assignment] - @current_stage = args[:current_stage] - @participant = args[:participant] - @stage_deadline = args[:stage_deadline] - @topic = args[:topic] - @permission_granted = args[:permission_granted] - end + attr_accessor :assignment, :assignment_id, :current_stage, :participant, :stage_deadline, :topic, :permission_granted - # create a new StudentTask instance from a Participant object.cccccccc - def self.create_from_participant(participant) - new( - assignment: participant.assignment.name, # Name of the assignment associated with the student task - topic: participant.topic, # Current stage of the assignment process - current_stage: participant.current_stage, # Participant object - stage_deadline: parse_stage_deadline(participant.stage_deadline), # Deadline for the current stage of the assignment - permission_granted: participant.permission_granted, # Topic of the assignment - participant: participant # Boolean indicating if Publishing Rights is enabled - ) - end + def initialize(args) + @assignment = args[:assignment] + @assignment_id = args[:assignment_id] + @current_stage = args[:current_stage] + @participant = args[:participant] + @stage_deadline = args[:stage_deadline] + @topic = args[:topic] + @permission_granted = args[:permission_granted] + end + def self.create_from_participant(participant) + new( + assignment: participant.assignment&.name, + assignment_id: participant.parent_id, + topic: participant.topic, + current_stage: participant.current_stage, + stage_deadline: send(:parse_stage_deadline, participant.stage_deadline), + permission_granted: participant.permission_granted, + participant: participant + ) + end - # create an array of StudentTask instances for all participants linked to a user, sorted by deadline. - def self.from_user(user) - Participant.where(user_id: user.id) - .map { |participant| StudentTask.create_from_participant(participant) } - .sort_by(&:stage_deadline) - end + def self.from_user(user) + Participant.where(user_id: user.id) + .map { |p| create_from_participant(p) } + .sort_by(&:stage_deadline) + end - # create a StudentTask instance from a participant of the provided id - def self.from_participant_id(id) - create_from_participant(Participant.find_by(id: id)) - end - + def self.from_participant_id(id) + part = Participant.find_by(id: id) + return nil unless part + + create_from_participant(part) + end + + def as_json(*) + { + assignment_id: assignment_id, + participant_id: participant&.id, + assignment: assignment, + topic: topic, + current_stage: current_stage, + stage_deadline: stage_deadline, + permission_granted: permission_granted + } + end + + class << self private - # Parses a date string to a Time object, if parsing fails, set the time to be one year after current - def self.parse_stage_deadline(date_string) - Time.parse(date_string) + def parse_stage_deadline(value) + return Time.current + 1.year if value.nil? + + return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) + + Time.zone.parse(value.to_s) rescue StandardError - Time.now + 1.year + Time.current + 1.year end - + end end diff --git a/app/models/task_ordering/base_task.rb b/app/models/task_ordering/base_task.rb new file mode 100644 index 000000000..84bafb6d2 --- /dev/null +++ b/app/models/task_ordering/base_task.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module TaskOrdering + class BaseTask + attr_reader :assignment, :team_participant, :review_map + + def initialize(assignment:, team_participant:, review_map: nil) + @assignment = assignment + @team_participant = team_participant + @review_map = review_map + end + + def participant + team_participant.participant + end + + def response_map + raise NotImplementedError + end + + # Ensures the ResponseMap exists. + # Implementations of response_map may lazily create maps. + def ensure_response_map! + response_map + end + + # Ensures a Response record exists for this map. + # Creates an unsubmitted response if none exists. + def ensure_response! + map = response_map + return if map.nil? + + Response.find_or_create_by!( + map_id: map.id, + round: 1 + ) do |resp| + resp.is_submitted = false + end + end + + # A task is considered completed when a submitted Response exists. + def completed? + map = response_map + return false if map.nil? + + Response.where(map_id: map.id, is_submitted: true).exists? + end + + # Converts task into a serializable hash used by controllers responses. + def to_task_hash + map = response_map + { + task_type: task_type, + assignment_id: assignment.id, + response_map_id: map&.id, + response_map_type: map&.type, + reviewee_id: map&.reviewee_id, + team_participant_id: team_participant.id + } + end + end +end diff --git a/app/models/task_ordering/quiz_task.rb b/app/models/task_ordering/quiz_task.rb new file mode 100644 index 000000000..95d83d7ec --- /dev/null +++ b/app/models/task_ordering/quiz_task.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module TaskOrdering + class QuizTask < BaseTask + def task_type + :quiz + end + + def questionnaire + assignment.quiz_questionnaire_for_review_flow + end + + def response_map + return @response_map if @response_map + + # First: check if a QuizResponseMap already exists for this reviewer/reviewee + existing = QuizResponseMap.find_by( + reviewer_id: team_participant.participant_id, + reviewee_id: review_map&.reviewee_id || 0 + ) + return @response_map = existing if existing + + # Second: if no existing map, create one — but only if a questionnaire exists + return nil if questionnaire.nil? + + attrs = { + reviewer_id: team_participant.participant_id, + reviewee_id: review_map&.reviewee_id || 0, + reviewed_object_id: questionnaire.id, + type: "QuizResponseMap" + } + + @response_map = QuizResponseMap.new(attrs).tap { |m| m.save!(validate: false) } + end + end +end \ No newline at end of file diff --git a/app/models/task_ordering/review_task.rb b/app/models/task_ordering/review_task.rb new file mode 100644 index 000000000..a20874e76 --- /dev/null +++ b/app/models/task_ordering/review_task.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# ReviewTask represents a review response tied directly to an existing ReviewResponseMap. +module TaskOrdering + class ReviewTask < BaseTask + def task_type + :review + end + + def response_map + review_map + end + end +end diff --git a/app/models/task_ordering/task_factory.rb b/app/models/task_ordering/task_factory.rb new file mode 100644 index 000000000..c9c7c9804 --- /dev/null +++ b/app/models/task_ordering/task_factory.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true +module TaskOrdering + class TaskFactory + def self.build(assignment:, team_participant:) + tasks = [] + participant = team_participant.participant + duty = Duty.find_by(id: team_participant.duty_id) || Duty.find_by(id: participant.duty_id) + + review_maps = ReviewResponseMap.where( + reviewer_id: team_participant.participant_id, + reviewed_object_id: assignment.id + ) + + quiz_questionnaire = assignment.quiz_questionnaire_for_review_flow + + # Check if any QuizResponseMaps exist for this participant + has_existing_quiz_maps = QuizResponseMap.where( + reviewer_id: team_participant.participant_id + ).exists? + + if review_maps.any? + review_maps.each do |review_map| + # Add QuizTask if duty allows quizzes AND (questionnaire exists OR quiz maps already exist) + if (duty.nil? || allows_quiz?(duty)) && (quiz_questionnaire || has_existing_quiz_maps) + tasks << QuizTask.new( + assignment: assignment, + team_participant: team_participant, + review_map: review_map + ) + end + + if duty.nil? || allows_review?(duty) + tasks << ReviewTask.new( + assignment: assignment, + team_participant: team_participant, + review_map: review_map + ) + end + end + elsif allows_quiz?(duty) && quiz_questionnaire + tasks << QuizTask.new( + assignment: assignment, + team_participant: team_participant, + review_map: nil + ) + end + + tasks + end + + def self.allows_review?(duty) + return false if duty.nil? + duty.name.in?(%w[participant reader reviewer mentor]) + end + + def self.allows_quiz?(duty) + return false if duty.nil? + duty.name.in?(%w[participant reader mentor]) + end + + def self.allows_submit?(duty) + return false if duty.nil? + duty.name.in?(%w[participant submitter mentor]) + end + end +end \ No newline at end of file diff --git a/app/models/task_ordering/task_queue.rb b/app/models/task_ordering/task_queue.rb new file mode 100644 index 000000000..f02a1dbbf --- /dev/null +++ b/app/models/task_ordering/task_queue.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module TaskOrdering + class TaskQueue + def initialize(assignment, team_participant) + @assignment = assignment + @team_participant = team_participant + end + + def tasks + TaskFactory.build( + assignment: @assignment, + team_participant: @team_participant + ) + end + + # Returns ordered list of response map ids (quiz maps first, then review maps) + def map_ids + tasks.filter_map do |t| + m = t.response_map + m&.id + end + end + + def ensure_response_objects! + tasks.each do |task| + task.ensure_response_map! + task.ensure_response! + end + end + + def task_for_map_id(map_id, from_tasks = nil) + list = from_tasks || tasks + list.find do |t| + m = t.response_map + m && m.id.to_i == map_id.to_i # normalize both sides + end + end + + def map_in_queue?(map_id) + task_for_map_id(map_id).present? + end + + def prior_tasks_complete_for?(map_id) + list = tasks + task = task_for_map_id(map_id, list) + return false unless task + + list.take_while { |t| t != task }.all?(&:completed?) + end + end +end \ No newline at end of file diff --git a/app/views/.DS_Store b/app/views/.DS_Store new file mode 100644 index 000000000..e9f5b0b2c Binary files /dev/null and b/app/views/.DS_Store differ diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 000000000..afbb766db Binary files /dev/null and b/config/.DS_Store differ diff --git a/config/database.yml b/config/database.yml index b9f5aa055..ca981d6ec 100644 --- a/config/database.yml +++ b/config/database.yml @@ -11,8 +11,4 @@ development: test: <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_test?') %> - -production: - <<: *default - url: <%= ENV['DATABASE_URL'].gsub('?', '_production?') %> \ No newline at end of file + database: reimplementation_back_end_test diff --git a/config/routes.rb b/config/routes.rb index 57559d007..b98912293 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,217 +1,222 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - - mount Rswag::Api::Engine => 'api-docs' - mount Rswag::Ui::Engine => 'api-docs' - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html - - # Defines the root path route ("/") - # root "articles#index" - post '/login', to: 'authentication#login' - resources :institutions - resources :roles do - collection do - # Get all roles that are subordinate to a role of a logged in user - get 'subordinate_roles', action: :subordinate_roles - end - end - resources :users do - collection do - get 'institution/:id', action: :institution_users - get ':id/managed', action: :managed_users - get 'role/:name', action: :role_users - end - end - resources :assignments do - collection do - post '/:assignment_id/add_participant/:user_id',action: :add_participant - delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant - patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course - patch '/:assignment_id/assign_course/:course_id',action: :assign_course - post '/:assignment_id/copy_assignment', action: :copy_assignment - get '/:assignment_id/has_topics',action: :has_topics - get '/:assignment_id/show_assignment_details',action: :show_assignment_details - get '/:assignment_id/team_assignment', action: :team_assignment - get '/:assignment_id/has_teams', action: :has_teams - get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review - get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? - post '/:assignment_id/create_node',action: :create_node - end - end - - resources :bookmarks, except: [:new, :edit] do - member do - get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' - post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' - end - end - resources :student_tasks do - collection do - get :list, action: :list - get :view - end - end - - resources :courses do - collection do - get ':id/add_ta/:ta_id', action: :add_ta - get ':id/tas', action: :view_tas - get ':id/remove_ta/:ta_id', action: :remove_ta - get ':id/copy', action: :copy - end - end - - resources :questionnaires do - collection do - post 'copy/:id', to: 'questionnaires#copy', as: 'copy' - get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' - end - end - - resources :questions do - collection do - get :types - get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' - delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' - end - end - - - resources :review_mappings, only: [] do - collection do - post :assign_round_robin - post :assign_random - post :assign_from_csv - post :request_review_fewest - post :assign_calibration - post :assign_quiz - delete :delete_all_for_reviewer - end - - member do - patch :submit_review - patch :unsubmit_review - patch :grade_review - delete :delete_mapping - end - end - - resources :signed_up_teams do - collection do - post '/sign_up', to: 'signed_up_teams#sign_up' - post '/sign_up_student', to: 'signed_up_teams#sign_up_student' - end - member do - post :create_advertisement - patch :update_advertisement - delete :remove_advertisement - end - end - - resources :submitted_content do - collection do - get :download - get :list_files - delete :remove_hyperlink - post :submit_file - post :submit_hyperlink - post :folder_action - end - end - - resources :join_team_requests do - member do - patch 'accept', to: 'join_team_requests#accept' - patch 'decline', to: 'join_team_requests#decline' - end - collection do - get 'for_team/:team_id', to: 'join_team_requests#for_team' - get 'by_user/:user_id', to: 'join_team_requests#by_user' - get 'pending', to: 'join_team_requests#pending' - end - end - - resources :project_topics do - collection do - get :filter - delete '/', to: 'project_topics#destroy' - end - end - - resources :invitations do - collection do - get '/sent_by/team/:team_id', to: 'invitations_sent_by_team' - get '/sent_by/participant/:participant_id', to: 'invitations_sent_by_participant' - get '/sent_to/:participant_id', to: 'invitations_sent_to_participant' - end - end - - resources :account_requests do - collection do - get :pending, action: :pending_requests - get :processed, action: :processed_requests - end - end - - resources :participants do - collection do - get '/user/:user_id', to: 'participants#list_user_participants' - get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' - get '/:id', to: 'participants#show' - post '/:authorization', to: 'participants#add' - patch '/:id/:authorization', to: 'participants#update_authorization' - delete '/:id', to: 'participants#destroy' - end - end - - resources :student_teams, only: %i[create update] do - collection do - get :view - get :mentor - get :remove_participant - put '/leave', to: 'student_teams#leave_team' - end - end - - resources :teams do - member do - get 'members' - post 'members', to: 'teams#add_member' - delete 'members/:user_id', to: 'teams#remove_member' - - get 'join_requests' - post 'join_requests', to: 'teams#create_join_request' - put 'join_requests/:join_request_id', to: 'teams#update_join_request' - end - end - resources :teams_participants, only: [] do - collection do - put :update_duty - end - member do - get :list_participants - post :add_participant - delete :delete_participants - end - end - resources :grades do - collection do - get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' - patch '/:participant_id/assign_grade', to: 'grades#assign_grade' - get '/:participant_id/edit', to: 'grades#edit' - get '/:assignment_id/:participant_id/get_review_tableau_data', to: 'grades#get_review_tableau_data' - get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' - get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' - get '/:participant_id/instructor_review', to: 'grades#instructor_review' - end - end - resources :duties do - collection do - get :accessible_duties - end - end - resources :assignments do - resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] - end -end +# frozen_string_literal: true + +Rails.application.routes.draw do + + mount Rswag::Api::Engine => 'api-docs' + mount Rswag::Ui::Engine => 'api-docs' + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Defines the root path route ("/") + # root "articles#index" + post '/login', to: 'authentication#login' + resources :institutions + resources :roles do + collection do + # Get all roles that are subordinate to a role of a logged in user + get 'subordinate_roles', action: :subordinate_roles + end + end + resources :users do + collection do + get 'institution/:id', action: :institution_users + get ':id/managed', action: :managed_users + get 'role/:name', action: :role_users + end + end + resources :assignments do + collection do + post '/:assignment_id/add_participant/:user_id',action: :add_participant + delete '/:assignment_id/remove_participant/:user_id',action: :remove_participant + patch '/:assignment_id/remove_assignment_from_course',action: :remove_assignment_from_course + patch '/:assignment_id/assign_course/:course_id',action: :assign_course + post '/:assignment_id/copy_assignment', action: :copy_assignment + get '/:assignment_id/has_topics',action: :has_topics + get '/:assignment_id/show_assignment_details',action: :show_assignment_details + get '/:assignment_id/team_assignment', action: :team_assignment + get '/:assignment_id/has_teams', action: :has_teams + get '/:assignment_id/valid_num_review/:review_type', action: :valid_num_review + get '/:assignment_id/varying_rubrics_by_round', action: :varying_rubrics_by_round? + post '/:assignment_id/create_node',action: :create_node + end + end + + resources :bookmarks, except: [:new, :edit] do + member do + get 'bookmarkratings', to: 'bookmarks#get_bookmark_rating_score' + post 'bookmarkratings', to: 'bookmarks#save_bookmark_rating_score' + end + end + resources :student_tasks do + collection do + get :list, action: :list + get :view + get :queue + get :next_task + post :start_task + end + end + + resources :responses, only: %i[show create update] + + resources :courses do + collection do + get ':id/add_ta/:ta_id', action: :add_ta + get ':id/tas', action: :view_tas + get ':id/remove_ta/:ta_id', action: :remove_ta + get ':id/copy', action: :copy + end + end + + resources :questionnaires do + collection do + post 'copy/:id', to: 'questionnaires#copy', as: 'copy' + get 'toggle_access/:id', to: 'questionnaires#toggle_access', as: 'toggle_access' + end + end + + resources :questions do + collection do + get :types + get 'show_all/questionnaire/:id', to:'questions#show_all#questionnaire', as: 'show_all' + delete 'delete_all/questionnaire/:id', to:'questions#delete_all#questionnaire', as: 'delete_all' + end + end + + + resources :review_mappings, only: [] do + collection do + post :assign_round_robin + post :assign_random + post :assign_from_csv + post :request_review_fewest + post :assign_calibration + post :assign_quiz + delete :delete_all_for_reviewer + end + + member do + patch :submit_review + patch :unsubmit_review + patch :grade_review + delete :delete_mapping + end + end + + resources :signed_up_teams do + collection do + post '/sign_up', to: 'signed_up_teams#sign_up' + post '/sign_up_student', to: 'signed_up_teams#sign_up_student' + end + member do + post :create_advertisement + patch :update_advertisement + delete :remove_advertisement + end + end + + resources :submitted_content do + collection do + get :download + get :list_files + delete :remove_hyperlink + post :submit_file + post :submit_hyperlink + post :folder_action + end + end + + resources :join_team_requests do + member do + patch 'accept', to: 'join_team_requests#accept' + patch 'decline', to: 'join_team_requests#decline' + end + collection do + get 'for_team/:team_id', to: 'join_team_requests#for_team' + get 'by_user/:user_id', to: 'join_team_requests#by_user' + get 'pending', to: 'join_team_requests#pending' + end + end + + resources :project_topics do + collection do + get :filter + delete '/', to: 'project_topics#destroy' + end + end + + resources :invitations do + collection do + get '/sent_by/team/:team_id', to: 'invitations_sent_by_team' + get '/sent_by/participant/:participant_id', to: 'invitations_sent_by_participant' + get '/sent_to/:participant_id', to: 'invitations_sent_to_participant' + end + end + + resources :account_requests do + collection do + get :pending, action: :pending_requests + get :processed, action: :processed_requests + end + end + + resources :participants do + collection do + get '/user/:user_id', to: 'participants#list_user_participants' + get '/assignment/:assignment_id', to: 'participants#list_assignment_participants' + get '/:id', to: 'participants#show' + post '/:authorization', to: 'participants#add' + patch '/:id/:authorization', to: 'participants#update_authorization' + delete '/:id', to: 'participants#destroy' + end + end + + resources :student_teams, only: %i[create update] do + collection do + get :view + get :mentor + get :remove_participant + put '/leave', to: 'student_teams#leave_team' + end + end + + resources :teams do + member do + get 'members' + post 'members', to: 'teams#add_member' + delete 'members/:user_id', to: 'teams#remove_member' + + get 'join_requests' + post 'join_requests', to: 'teams#create_join_request' + put 'join_requests/:join_request_id', to: 'teams#update_join_request' + end + end + resources :teams_participants, only: [] do + collection do + put :update_duty + end + member do + get :list_participants + post :add_participant + delete :delete_participants + end + end + resources :grades do + collection do + get '/:assignment_id/view_all_scores', to: 'grades#view_all_scores' + patch '/:participant_id/assign_grade', to: 'grades#assign_grade' + get '/:participant_id/edit', to: 'grades#edit' + get '/:assignment_id/:participant_id/get_review_tableau_data', to: 'grades#get_review_tableau_data' + get '/:assignment_id/view_our_scores', to: 'grades#view_our_scores' + get '/:assignment_id/view_my_scores', to: 'grades#view_my_scores' + get '/:participant_id/instructor_review', to: 'grades#instructor_review' + end + end + resources :duties do + collection do + get :accessible_duties + end + end + resources :assignments do + resources :duties, controller: 'assignments_duties', only: [:index, :create, :destroy] + end +end \ No newline at end of file diff --git a/spec/.DS_Store b/spec/.DS_Store new file mode 100644 index 000000000..e58c3cf1a Binary files /dev/null and b/spec/.DS_Store differ diff --git a/spec/models/task_ordering_spec.rb b/spec/models/task_ordering_spec.rb new file mode 100644 index 000000000..f84321939 --- /dev/null +++ b/spec/models/task_ordering_spec.rb @@ -0,0 +1,643 @@ +# frozen_string_literal: true +# +# Replaces: +# spec/models/task_ordering/task_queue_spec.rb +# spec/models/task_ordering/task_factory_spec.rb +# spec/models/task_ordering/base_task_spec.rb +# spec/models/task_ordering/quiz_task_spec.rb +# spec/models/task_ordering/review_task_spec.rb +# +# The TaskOrdering namespace has been removed. Sequencing logic now lives in +# StudentTasksController private methods and inner classes QuizTaskItem / +# ReviewTaskItem. This file covers equivalent behavior at the correct layer. + +require 'rails_helper' + +RSpec.describe StudentTasksController, type: :controller do + before(:all) do + @roles = create_roles_hierarchy + end + + let!(:instructor) do + User.create!( + name: "instructor_tc", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor TC", + email: "instructor_tc@example.com" + ) + end + + let!(:student) do + User.create!( + name: "student_tc", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student TC", + email: "student_tc@example.com" + ) + end + + let!(:assignment) { Assignment.create!(name: "TC Assignment", instructor: instructor) } + + let!(:participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let!(:team) { AssignmentTeam.create!(name: "TC Team", parent_id: assignment.id) } + + let!(:teams_participant) do + TeamsParticipant.create!(team: team, participant: participant, user: student) + end + + let!(:review_map) do + ReviewResponseMap.new( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ).tap { |m| m.save!(validate: false) } + end + + # =========================================================================== + # ReviewTaskItem — replaces review_task_spec.rb + base_task_spec.rb + # =========================================================================== + describe StudentTasksController::ReviewTaskItem do + subject(:task) do + described_class.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + end + + # --- task_type --- + + describe '#task_type' do + it 'returns :review' do + expect(task.task_type).to eq(:review) + end + end + + # --- response_map --- + + describe '#response_map' do + it 'returns the review map passed in' do + expect(task.response_map).to eq(review_map) + end + end + + # --- participant (BaseTaskItem contract) --- + + describe '#participant' do + it 'returns the participant via teams_participant' do + expect(task.participant).to eq(participant) + end + end + + # --- completed? --- + + describe '#completed?' do + it 'returns false when no submitted response exists' do + expect(task.completed?).to be false + end + + it 'returns true when a submitted response exists' do + Response.create!(map_id: review_map.id, round: 1, is_submitted: true) + expect(task.completed?).to be true + end + + it 'returns false when response exists but is not submitted' do + Response.create!(map_id: review_map.id, round: 1, is_submitted: false) + expect(task.completed?).to be false + end + end + + # --- ensure_response! --- + + describe '#ensure_response!' do + it 'creates a response if none exists' do + expect { task.ensure_response! }.to change(Response, :count).by(1) + end + + it 'does not create duplicate responses' do + task.ensure_response! + expect { task.ensure_response! }.not_to change(Response, :count) + end + + it 'creates response with is_submitted: false' do + task.ensure_response! + expect(Response.last.is_submitted).to be false + end + + it 'creates response with round: 1' do + task.ensure_response! + expect(Response.last.round).to eq(1) + end + end + + # --- to_h (replaces to_task_hash in old base_task_spec) --- + + describe '#to_h' do + it 'includes all required contract keys' do + expect(task.to_h.keys).to include( + :task_type, :assignment_id, :response_map_id, + :response_map_type, :reviewee_id, :team_participant_id + ) + end + + it 'sets task_type correctly' do + expect(task.to_h[:task_type]).to eq(:review) + end + + it 'sets assignment_id correctly' do + expect(task.to_h[:assignment_id]).to eq(assignment.id) + end + + it 'sets response_map_id correctly' do + expect(task.to_h[:response_map_id]).to eq(review_map.id) + end + + it 'sets reviewee_id correctly' do + expect(task.to_h[:reviewee_id]).to eq(review_map.reviewee_id) + end + + it 'sets team_participant_id correctly' do + expect(task.to_h[:team_participant_id]).to eq(teams_participant.id) + end + end + end + + # =========================================================================== + # QuizTaskItem — replaces quiz_task_spec.rb + # =========================================================================== + describe StudentTasksController::QuizTaskItem do + subject(:task) do + described_class.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + end + + # --- task_type --- + + describe '#task_type' do + it 'returns :quiz' do + expect(task.task_type).to eq(:quiz) + end + end + + # --- response_map --- + + describe '#response_map' do + context 'when no questionnaire and no existing quiz map' do + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + + it 'returns nil' do + expect(task.response_map).to be_nil + end + end + + context 'when an existing QuizResponseMap exists for reviewer/reviewee' do + let!(:existing_quiz_map) do + QuizResponseMap.new( + reviewer_id: participant.id, + reviewee_id: review_map.reviewee_id, + reviewed_object_id: assignment.id + ).tap { |m| m.save!(validate: false) } + end + + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + + it 'returns the existing map' do + expect(task.response_map).to eq(existing_quiz_map) + end + + it 'does not create a duplicate map' do + expect { task.response_map }.not_to change(QuizResponseMap, :count) + end + end + + context 'when questionnaire exists and no quiz map yet' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) } + + it 'creates and returns a QuizResponseMap' do + expect(task.response_map).to be_a(QuizResponseMap) + end + + it 'creates exactly one map' do + expect { task.response_map }.to change(QuizResponseMap, :count).by(1) + end + + it 'does not create duplicate maps on repeated calls' do + task.response_map + expect { task.response_map }.not_to change(QuizResponseMap, :count) + end + end + end + + # --- completed? --- + + describe '#completed?' do + context 'when response_map is nil' do + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + + it 'returns false' do + expect(task.completed?).to be false + end + end + + context 'when quiz map exists with submitted response' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz Done", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + quiz_map = task.response_map + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + it 'returns true' do + expect(task.completed?).to be true + end + end + + context 'when quiz map exists with unsubmitted response' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz Pending", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + quiz_map = task.response_map + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: false) + end + + it 'returns false' do + expect(task.completed?).to be false + end + end + end + + # --- ensure_response! --- + + describe '#ensure_response!' do + context 'when response_map is nil' do + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) } + + it 'returns nil without creating a response' do + expect(task.ensure_response!).to be_nil + expect(Response.count).to eq(0) + end + end + + context 'when response_map exists' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz Ens", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) } + + it 'creates a response if none exists' do + task.response_map + expect { task.ensure_response! }.to change(Response, :count).by(1) + end + + it 'does not duplicate responses' do + task.ensure_response! + expect { task.ensure_response! }.not_to change(Response, :count) + end + + it 'creates response with is_submitted: false' do + task.ensure_response! + expect(Response.last.is_submitted).to be false + end + + it 'creates response with round: 1' do + task.ensure_response! + expect(Response.last.round).to eq(1) + end + end + end + + # --- to_h --- + + describe '#to_h' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz Hash", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + + before { allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) } + + it 'includes all required contract keys' do + expect(task.to_h.keys).to include( + :task_type, :assignment_id, :response_map_id, + :response_map_type, :reviewee_id, :team_participant_id + ) + end + + it 'sets task_type to :quiz' do + expect(task.to_h[:task_type]).to eq(:quiz) + end + + it 'sets assignment_id correctly' do + expect(task.to_h[:assignment_id]).to eq(assignment.id) + end + + it 'sets team_participant_id correctly' do + expect(task.to_h[:team_participant_id]).to eq(teams_participant.id) + end + end + end + + # =========================================================================== + # Task ordering / queue logic — replaces task_queue_spec.rb + # Exercises prior_tasks_complete? and build_tasks via private controller methods + # =========================================================================== + describe 'task queue ordering (private controller logic)' do + let(:context) do + { + assignment: assignment, + participant: participant, + team_participant: teams_participant, + duty: nil + } + end + + describe '#prior_tasks_complete? (private)' do + it 'returns true when the map is the only task in the queue' do + tasks = [ + StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + ] + result = controller.send(:prior_tasks_complete?, tasks, tasks.first) + expect(result).to be true + end + + it 'returns false when a prior quiz task is not submitted' do + quiz_map = QuizResponseMap.new( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return( + QuizQuestionnaire.new(id: quiz_map.id) + ) + + quiz_task = StudentTasksController::QuizTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + allow(quiz_task).to receive(:response_map).and_return(quiz_map) + allow(quiz_task).to receive(:completed?).and_return(false) + + review_task = StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + + tasks = [quiz_task, review_task] + result = controller.send(:prior_tasks_complete?, tasks, review_task) + expect(result).to be false + end + + it 'returns true when the prior quiz task is submitted' do + quiz_map = QuizResponseMap.new( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + + quiz_task = StudentTasksController::QuizTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + allow(quiz_task).to receive(:response_map).and_return(quiz_map) + allow(quiz_task).to receive(:completed?).and_return(true) + + review_task = StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + + tasks = [quiz_task, review_task] + result = controller.send(:prior_tasks_complete?, tasks, review_task) + expect(result).to be true + end + end + + describe '#find_task_for_map (private)' do + it 'returns the task matching the given map id' do + review_task = StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + tasks = [review_task] + found = controller.send(:find_task_for_map, tasks, review_map.id) + expect(found).to eq(review_task) + end + + it 'returns nil for an unknown map id' do + review_task = StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + tasks = [review_task] + found = controller.send(:find_task_for_map, tasks, 99999) + expect(found).to be_nil + end + + it 'handles string map ids' do + review_task = StudentTasksController::ReviewTaskItem.new( + assignment: assignment, + team_participant: teams_participant, + review_map: review_map + ) + tasks = [review_task] + found = controller.send(:find_task_for_map, tasks, review_map.id.to_s) + expect(found).to eq(review_task) + end + end + end + + # =========================================================================== + # Duty permission helpers — replaces task_factory_spec duty checks + # =========================================================================== + describe 'duty_allows_review? (private)' do + { 'reviewer' => true, 'participant' => true, 'reader' => true, 'mentor' => true, + 'submitter' => false }.each do |name, expected| + it "returns #{expected} for #{name}" do + expect(controller.send(:duty_allows_review?, Duty.new(name: name))).to be expected + end + end + + it 'returns false for nil' do + expect(controller.send(:duty_allows_review?, nil)).to be false + end + end + + describe 'duty_allows_quiz? (private)' do + { 'participant' => true, 'reader' => true, 'mentor' => true, + 'reviewer' => false, 'submitter' => false }.each do |name, expected| + it "returns #{expected} for #{name}" do + expect(controller.send(:duty_allows_quiz?, Duty.new(name: name))).to be expected + end + end + + it 'returns false for nil' do + expect(controller.send(:duty_allows_quiz?, nil)).to be false + end + end + + describe 'duty_allows_submit? (private)' do + { 'submitter' => true, 'participant' => true, 'mentor' => true, + 'reviewer' => false, 'reader' => false }.each do |name, expected| + it "returns #{expected} for #{name}" do + expect(controller.send(:duty_allows_submit?, Duty.new(name: name))).to be expected + end + end + + it 'returns false for nil' do + expect(controller.send(:duty_allows_submit?, nil)).to be false + end + end + + # =========================================================================== + # build_tasks (private) — replaces TaskFactory.build scenarios + # =========================================================================== + describe '#build_tasks (private)' do + let(:base_context) do + { assignment: assignment, participant: participant, team_participant: teams_participant, duty: nil } + end + + context 'with no review maps and no quiz questionnaire' do + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) + ReviewResponseMap.where(reviewer_id: participant.id).destroy_all + end + + it 'returns an empty array' do + tasks = controller.send(:build_tasks, base_context) + expect(tasks).to be_an(Array) + expect(tasks).to be_empty + end + end + + context 'with a review map and reviewer duty' do + let!(:duty) { Duty.create!(name: 'reviewer', instructor_id: instructor.id) } + let(:context_with_duty) { base_context.merge(duty: duty) } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(nil) + end + + it 'returns a ReviewTaskItem' do + tasks = controller.send(:build_tasks, context_with_duty) + expect(tasks.map(&:task_type)).to include(:review) + end + + it 'does not include a quiz task when quiz is not allowed for reviewer duty' do + tasks = controller.send(:build_tasks, context_with_duty) + expect(tasks.map(&:task_type)).not_to include(:quiz) + end + end + + context 'with a review map, participant duty, and quiz questionnaire' do + let!(:duty) { Duty.create!(name: 'participant', instructor_id: instructor.id) } + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Build Quiz", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:context_with_duty) { base_context.merge(duty: duty) } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + end + + it 'places quiz task before review task' do + tasks = controller.send(:build_tasks, context_with_duty) + types = tasks.map(&:task_type) + expect(types.index(:quiz)).to be < types.index(:review) + end + + it 'returns both quiz and review tasks' do + tasks = controller.send(:build_tasks, context_with_duty) + expect(tasks.map(&:task_type)).to include(:quiz, :review) + end + end + + context 'with no review maps but quiz questionnaire exists and duty allows quiz' do + let!(:duty) { Duty.create!(name: 'participant', instructor_id: instructor.id) } + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "TC Quiz Only", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:context_with_duty) { base_context.merge(duty: duty) } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + ReviewResponseMap.where(reviewer_id: participant.id).destroy_all + end + + it 'returns a quiz-only task list' do + tasks = controller.send(:build_tasks, context_with_duty) + expect(tasks.map(&:task_type)).to eq([:quiz]) + end + end + end +end \ No newline at end of file diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index a0794fd1b..b7ef40015 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -16,8 +16,12 @@ ENV['DATABASE_URL'] = 'mysql2://root:expertiza@127.0.0.1/reimplementation_test' end +# Load support files BEFORE RSpec.configure so helpers are available +Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods + config.include RolesHelper config.before(:suite) do FactoryBot.factories.clear FactoryBot.find_definitions @@ -48,26 +52,6 @@ end end -# Load support files -Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } - -# Add additional requires below this line. Rails is not loaded until this point! - -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# 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 } - # Checks for pending migrations and applies them before tests are run. # If you are not using ActiveRecord, you can remove these lines. begin @@ -75,43 +59,16 @@ rescue ActiveRecord::PendingMigrationError => e abort e.to_s.strip 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') if config.respond_to?(:fixture_paths=) config.fixture_paths = [Rails.root.join('spec/fixtures').to_s] else - # fallback for older Rails / rspec-rails config.fixture_path = Rails.root.join('spec/fixtures') end - # Since we're using Factory Bot instead of fixtures, we don't need fixture_path - # config.fixture_path is deprecated in newer RSpec versions anyway - - # We're using DatabaseCleaner instead of transactional fixtures - # config.use_transactional_fixtures = false - - # 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.describe UsersController, type: :controller do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://rspec.info/features/6-0/rspec-rails config.infer_spec_type_from_file_location! - - # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") end Shoulda::Matchers.configure do |config| @@ -119,4 +76,4 @@ with.test_framework :rspec with.library :rails end -end +end \ No newline at end of file diff --git a/spec/requests/.DS_Store b/spec/requests/.DS_Store new file mode 100644 index 000000000..a388dec71 Binary files /dev/null and b/spec/requests/.DS_Store differ diff --git a/spec/requests/api/v1/responses_controller_spec.rb b/spec/requests/api/v1/responses_controller_spec.rb new file mode 100644 index 000000000..e6c8d9d6f --- /dev/null +++ b/spec/requests/api/v1/responses_controller_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require 'swagger_helper' +require 'json_web_token' + +RSpec.describe 'Responses API', type: :request do + before(:all) do + @roles = create_roles_hierarchy + end + + let!(:instructor) do + User.create!( + name: "instructor_resp", + password_digest: "password", + role_id: @roles[:instructor].id, + full_name: "Instructor Resp", + email: "instructor_resp@example.com" + ) + end + + let!(:student) do + User.create!( + name: "student_resp", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Student Resp", + email: "student_resp@example.com" + ) + end + + let(:token) { JsonWebToken.encode({ id: student.id }) } + let(:Authorization) { "Bearer #{token}" } + + let!(:assignment) do + Assignment.create!( + name: "Resp Assignment", + instructor: instructor + ) + end + + let!(:reviewer_participant) do + AssignmentParticipant.create!( + user_id: student.id, + parent_id: assignment.id, + handle: student.name + ) + end + + let!(:reviewee_participant) do + AssignmentParticipant.create!( + user_id: instructor.id, + parent_id: assignment.id, + handle: instructor.name + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "Resp Team", + parent_id: assignment.id + ) + end + + let!(:teams_participant) do + TeamsParticipant.create!( + team: team, + participant: reviewer_participant, + user: student + ) + end + + let!(:review_map) do + map = ReviewResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + map.save!(validate: false) + map + end + + let!(:response_record) do + Response.create!( + map_id: review_map.id, + round: 1, + is_submitted: false, + additional_comment: "Initial comment" + ) + end + + # ------------------------------------------------------------------------- + # POST /responses + # ------------------------------------------------------------------------- + path '/responses' do + post 'Create a response' do + tags 'Responses' + consumes 'application/json' + produces 'application/json' + parameter name: 'Authorization', in: :header, type: :string + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + response_map_id: { type: :integer }, + round: { type: :integer }, + content: { type: :string } + }, + required: ['response_map_id'] + } + + response '201', 'response created successfully' do + let(:body) do + { + response_map_id: review_map.id, + round: 1, + content: '{}' + } + end + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['map_id']).to eq(review_map.id) + expect(data['round']).to eq(1) + end + end + + response '201', 'allows create when all prior tasks are complete' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Resp Quiz Prior", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:body) { { response_map_id: review_map.id, round: 1, content: '{}' } } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + quiz_map = QuizResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + run_test! do |response| + expect([201, 200]).to include(response.status) + end + end + + response '403', 'blocks create when prior quiz task is not complete' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Resp Quiz Block", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:body) { { response_map_id: review_map.id, round: 1, content: '{}' } } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + # Quiz map exists but response is NOT submitted + quiz_map = QuizResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: false) + end + + run_test! do |response| + expect([403]).to include(response.status) + end + end + + response '404', 'response map not found' do + let(:body) { { response_map_id: 99999, round: 1 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '403', 'unauthorized reviewer' do + let!(:other_student) do + User.create!( + name: "other_resp", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Other Resp", + email: "other_resp@example.com" + ) + end + let(:token) { JsonWebToken.encode({ id: other_student.id }) } + let(:Authorization) { "Bearer #{token}" } + let(:body) { { response_map_id: review_map.id, round: 1 } } + + run_test! do |response| + expect([403, 404]).to include(response.status) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:body) { { response_map_id: review_map.id, round: 1 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # GET /responses/:id + # ------------------------------------------------------------------------- + path '/responses/{id}' do + parameter name: 'id', in: :path, type: :integer, description: 'ID of the response' + parameter name: 'Authorization', in: :header, type: :string + + get 'Show a response' do + tags 'Responses' + produces 'application/json' + + response '200', 'response found' do + let(:id) { response_record.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['response_id']).to eq(response_record.id) + expect(data['map_id']).to eq(review_map.id) + expect(data['submitted']).to be false + end + end + + response '404', 'response not found' do + let(:id) { 99999 } + + run_test! do |response| + expect(response.status).to eq(404) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:id) { response_record.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + + # ------------------------------------------------------------------------- + # PATCH /responses/:id + # ------------------------------------------------------------------------- + patch 'Update a response' do + tags 'Responses' + consumes 'application/json' + produces 'application/json' + + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + is_submitted: { type: :boolean }, + additional_comment: { type: :string } + } + } + + response '200', 'response updated successfully' do + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['submitted']).to be true + end + end + + response '200', 'allows submit/update when prior tasks are complete' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Resp Quiz Upd", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + quiz_map = QuizResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: true) + end + + run_test! do |response| + expect([200]).to include(response.status) + data = JSON.parse(response.body) + expect(data['submitted']).to be true + end + end + + response '403', 'blocks submit/update when prior quiz task is incomplete' do + let!(:questionnaire) do + QuizQuestionnaire.create!( + name: "Resp Quiz BlkUpd", + instructor_id: instructor.id, + min_question_score: 0, + max_question_score: 5 + ) + end + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + before do + allow(assignment).to receive(:quiz_questionnaire_for_review_flow).and_return(questionnaire) + quiz_map = QuizResponseMap.new( + reviewer_id: reviewer_participant.id, + reviewee_id: reviewee_participant.id, + reviewed_object_id: assignment.id + ) + quiz_map.save!(validate: false) + # Quiz response exists but not submitted + Response.create!(map_id: quiz_map.id, round: 1, is_submitted: false) + end + + run_test! do |response| + expect([403]).to include(response.status) + end + end + + response '403', 'not authorized to update response' do + let!(:other_student) do + User.create!( + name: "other_upd", + password_digest: "password", + role_id: @roles[:student].id, + full_name: "Other Upd", + email: "other_upd@example.com" + ) + end + let(:token) { JsonWebToken.encode({ id: other_student.id }) } + let(:Authorization) { "Bearer #{token}" } + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + expect([403, 404]).to include(response.status) + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:id) { response_record.id } + let(:body) { { is_submitted: true } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end +end \ No newline at end of file diff --git a/spec/requests/api/v1/student_tasks_controller_spec.rb b/spec/requests/api/v1/student_tasks_controller_spec.rb index 481f7b5d1..17317cc73 100644 --- a/spec/requests/api/v1/student_tasks_controller_spec.rb +++ b/spec/requests/api/v1/student_tasks_controller_spec.rb @@ -10,88 +10,91 @@ let!(:instructor) do User.create!( - name: "Instructor", + name: "instructor_st", password_digest: "password", role_id: @roles[:instructor].id, full_name: "Instructor Name", - email: "instructor@example.com" + email: "instructor_st@example.com" ) end let(:studenta) do User.create!( - name: "studenta", + name: "studenta_st", password_digest: "password", role_id: @roles[:student].id, full_name: "Student A", - email: "testuser@example.com" + email: "studenta_st@example.com" ) end - let(:token) { JsonWebToken.encode({id: studenta.id}) } + let(:token) { JsonWebToken.encode({ id: studenta.id }) } let(:Authorization) { "Bearer #{token}" } + let!(:assignment) do + Assignment.create!( + name: "ST Sample Assignment", + instructor: instructor + ) + end + + let!(:participant) do + AssignmentParticipant.create!( + user_id: studenta.id, + parent_id: assignment.id, + handle: studenta.name, + current_stage: "Review", + stage_deadline: (Time.now + 7.days).to_s, + topic: "Topic XYZ", + permission_granted: true + ) + end + + let!(:team) do + AssignmentTeam.create!( + name: "ST Team", + parent_id: assignment.id + ) + end + + let!(:teams_participant) do + TeamsParticipant.create!( + team: team, + participant: participant, + user: studenta + ) + end + + let!(:review_map) do + ReviewResponseMap.create!( + reviewer_id: participant.id, + reviewee_id: participant.id, + reviewed_object_id: assignment.id + ) + end + # ------------------------------------------------------------------------- # /student_tasks/list # ------------------------------------------------------------------------- path '/student_tasks/list' do - get 'student tasks list' do + get 'List student tasks for current user' do tags 'StudentTasks' produces 'application/json' parameter name: 'Authorization', in: :header, type: :string - # Just a basic "200" test - response '200', 'authorized request has success response' do - run_test! - end - - # The "proper JSON schema" test - response '200', 'authorized request has proper JSON schema' do - before do - # 1) Create an Assignment - assignment = Assignment.create!( - name: "Sample Assignment", - instructor: instructor - ) - - # 2) Create N Participants for our student, each with different data - 5.times do |i| - AssignmentParticipant.create!( - user_id: studenta.id, - parent_id: assignment.id, - handle: studenta.name, - permission_granted: [true, false].sample, - # store “stage” and “deadline” fields as your Participant model expects - # e.g. might be: - topic: "Topic #{i}", - stage_deadline: (Time.now + (i + 1).days).to_s, - # and if it has “current_stage” or something: - current_stage: "Stage #{i}" - ) - end - end - + response '200', 'authorized request returns list of tasks' do run_test! do |response| data = JSON.parse(response.body) expect(data).to be_an(Array) - expect(data.size).to eq(5) - - data.each do |task| - # Because StudentTask is just a plain Ruby object, - # we expect the controller to have built it from the Participant - expect(task['assignment']).to be_a(String) - expect(task['current_stage']).to be_a(String) - expect(task['stage_deadline']).to be_a(String) - expect(task['topic']).to be_a(String) - expect(task['permission_granted']).to be_in([true, false]) - end end end - # Unauthorized test - response '401', 'unauthorized request has error response' do - let(:'Authorization') { "Bearer " } - run_test! + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end end end end @@ -100,59 +103,163 @@ # /student_tasks/view # ------------------------------------------------------------------------- path '/student_tasks/view' do - get 'Retrieve a specific student task by ID' do + get 'Retrieve a specific student task by participant ID' do tags 'StudentTasks' produces 'application/json' - parameter name: 'id', in: :query, type: :Integer, required: true + parameter name: 'id', in: :query, type: :integer, required: true parameter name: 'Authorization', in: :header, type: :string - # 200 test response '200', 'successful retrieval of a student task' do - let!(:assignment) do - Assignment.create!(name: "Test Assignment", instructor: instructor) + let(:id) { participant.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['assignment']).to eq("ST Sample Assignment") + expect(data['current_stage']).to eq("Review") + expect(data['topic']).to eq("Topic XYZ") + expect(data['permission_granted']).to be true end + end - # Create *one* participant for the student - let!(:participant) do - AssignmentParticipant.create!( - user_id: studenta.id, - parent_id: assignment.id, - handle: studenta.name, - current_stage: "Review", - stage_deadline: (Time.now + 7.days).to_s, - topic: "Topic XYZ", - permission_granted: true - ) + response '200', 'participant not found returns null' do + let(:id) { -1 } + run_test! do |response| + expect(response.status).to eq(200) end + end - # This “id” is the participant’s ID to be looked up + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } let(:id) { participant.id } + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/queue + # ------------------------------------------------------------------------- + path '/student_tasks/queue' do + get 'Get task queue for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns queue of response maps' do + let(:assignment_id) { assignment.id } run_test! do |response| data = JSON.parse(response.body) - expect(data['assignment']).to eq("Test Assignment") - expect(data['current_stage']).to eq("Review") - expect(data['stage_deadline']).to be_a(String) # e.g. "YYYY-MM-DD..." - expect(data['topic']).to eq("Topic XYZ") - expect(data['permission_granted']).to be true + expect(data).to be_an(Array) end end - response '500', 'participant not found' do - let(:id) { -1 } + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + run_test! do |response| - expect(response.status).to eq(500) + data = JSON.parse(response.body) + expect(data['error']).to include('not found') end end - response '401', 'unauthorized request has error response' do - let(:'Authorization') { "Bearer " } - let(:id) { 'any_id' } + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/next_task + # ------------------------------------------------------------------------- + path '/student_tasks/next_task' do + get 'Get the next incomplete task for an assignment' do + tags 'StudentTasks' + produces 'application/json' + parameter name: 'assignment_id', in: :query, type: :integer, required: true + parameter name: 'Authorization', in: :header, type: :string + + response '200', 'returns next task or all complete message' do + let(:assignment_id) { assignment.id } + + run_test! do |response| + expect([200]).to include(response.status) + end + end + + response '404', 'assignment not found' do + let(:assignment_id) { 99999 } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:assignment_id) { assignment.id } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data["error"]).to eq("Not Authorized") + end + end + end + end + + # ------------------------------------------------------------------------- + # /student_tasks/start_task + # ------------------------------------------------------------------------- + path '/student_tasks/start_task' do + post 'Start a task by response map ID' do + tags 'StudentTasks' + consumes 'application/json' + produces 'application/json' + parameter name: 'Authorization', in: :header, type: :string + parameter name: :body, in: :body, schema: { + type: :object, + properties: { + response_map_id: { type: :integer } + }, + required: ['response_map_id'] + } + + response '200', 'task started or blocked by queue ordering' do + let(:body) { { response_map_id: review_map.id } } + run_test! do |response| + expect([200, 403]).to include(response.status) + end + end + + response '404', 'response map not found' do + let(:body) { { response_map_id: 99999 } } + + run_test! do |response| + data = JSON.parse(response.body) + expect(data['error']).to include('not found') + end + end + + response '401', 'unauthorized request returns error' do + let(:Authorization) { "Bearer " } + let(:body) { { response_map_id: review_map.id } } + run_test! do |response| data = JSON.parse(response.body) - expect(data["error"]).to eql("Not Authorized") + expect(data["error"]).to eq("Not Authorized") end end end end -end +end \ No newline at end of file diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 000000000..a0be824d4 Binary files /dev/null and b/test/.DS_Store differ