Quiz Submission Questions API - Update

This patch provides support for answering Quiz Questions via the API.

closes CNVS-9844, CNVS-10225

TEST PLAN
---- ----

Testing this will be a bit rough because there are many variations and
validations to cover. I'll spare the validations that are covered by
specs from the test plan.

Create a quiz with a question of *each* type except "Text" and "File
Upload". There's a script that creates a quiz with its questions
automatically for you if you don't want to keep doing this manually. See
references.

> Answering Questions

Now you need to answer each question via the API. Most of them vary in
formats, but they are fully specified in the API documentation page
(along with examples). See DOCUMENTATION for more info.

> Flagging Questions

Flagging, and unflagging, a question is the same regardless of its type,
see the "EXAMPLE REQUESTS" section.

> Access Validations

Here are some generic, non-question based validations to verify. You
should NOT be able to answer a question if:

  - the quiz submission has been turned in
  - the quiz submission is overdue
  - the Access Code for the quiz is invalid
  - the IP filter of the Quiz prohibits you from taking the quiz
  - the quiz submission :validation_token is incorrectly specified (ie,
    other students shouldn't be able to answer your questions)
  - you don't specify the latest :attempt, so if the Quiz has multiple
    attempts, and this is your 2nd take, you specify an :attempt of 1,
    3, or anything but 2 should fail
  - NEW: turn quiz into an OQAAT quiz with the "Can't go back" flag on;
    the API should not reject all requests to modify any of the
    questions with a 501 error saying that type of quizzes is not
    supported yet (support will come in CNVS-10224)

> Grading

Also, when you're done answering the questions, take a look at the
grades and make sure everything gets graded just like it does when using
the UI directly.

> Verifying results in the browser

While taking a quiz in the canvas UI, the scripts perform backups in the
background that would overwrite any changes you do via the API. If you
want to verify the changes you make via the API from the UI, you must
append "?backup=false" to the take quiz page URL, something like this:

http://localhost:3000/courses/1/quizzes/1/take?backup=false

Setting that flag will (for now) disable the backup behaviour and should
make things tick.

EXAMPLE REQUESTS
------- --------

Don't forget to set the 'Content-Type' header to 'application/json'!

> Answering a Multiple-Choice question

[PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id

{
  "attempt": 1,
  "validation_token": "1babd0...",
  "answer": 10
}

> Flagging a question

[PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id/flag

{
  "attempt": 1,
  "validation_token": "1babd0..."
}

> Unflagging a question

[PUT] /api/v1/quiz_submissions/:quiz_submission_id/questions/:id/unflag

{
  "attempt": 1,
  "validation_token": "1babd0..."
}

DOCUMENTATION
-------------

Run `bundle exec rake doc:api` and check out the Quiz Submission
Questions page. There's an Appendix that contains example requests for
each question type, as well as the errors produced by each handler.

LINKS
-----

  - bootstrap script:
    https://gist.github.com/amireh/e7e8f835ffbf1d053e4c
  - direct link to the API documentation page:
    http://canvas.docs.kodoware.com/quiz_submission_questions.html

Change-Id: I9a958323ece8854bc21a24c2affd8dc3972e46d5
Reviewed-on: https://gerrit.instructure.com/27206
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Derek DeVries <ddevries@instructure.com>
QA-Review: Myller de Araujo <myller@instructure.com>
Product-Review: Ahmad Amireh <ahmad@instructure.com>
This commit is contained in:
Ahmad Amireh 2013-12-10 12:15:01 +03:00
parent 28234f234f
commit 20a3562779
38 changed files with 2460 additions and 107 deletions

View File

@ -0,0 +1,197 @@
#
# Copyright (C) 2011 - 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# @API Quiz Submission Questions
# @beta
#
class QuizSubmissionQuestionsController < ApplicationController
include Api::V1::QuizSubmissionQuestion
include Api::V1::Helpers::QuizzesApiHelper
include Api::V1::Helpers::QuizSubmissionsApiHelper
before_filter :require_user,
:require_quiz_submission,
:export_scopes,
:prepare_service,
:validate_ldb_status!,
:require_question
# @API Answering a question.
# @beta
#
# Provide or modify an answer to a QuizQuestion.
#
# @argument attempt [Integer]
# The attempt number of the quiz submission being taken. Note that this
# must be the latest attempt index, as questions for earlier attempts can
# not be modified.
#
# @argument validation_token [String]
# The unique validation token you received when the Quiz Submission was
# created.
#
# @argument access_code [Optional, String]
# Access code for the Quiz, if any.
#
# @argument answer [Optional, Mixed]
# The answer to the question. The type and format of this argument depend
# on the question type.
#
# See {Appendix: Question Answer Formats} for the accepted answer formats
# for each question type.
#
# @example_request
# {
# "attempt": 1,
# "validation_token": "YOUR_VALIDATION_TOKEN",
# "access_code": null,
# "answer": "Hello World!"
# }
def answer
unless params.has_key?(:answer)
reject! 400, 'missing required parameter :answer'
end
serializer = QuizQuestion::AnswerSerializers.serializer_for @question
serialization_rc = serializer.serialize(params[:answer])
unless serialization_rc.valid?
reject! 400, serialization_rc.error
end
submission_data = @service.update_question(serialization_rc.answer,
@quiz_submission,
params[:attempt])
render json: quiz_submission_questions_json([ @question ], submission_data)
end
# @API Flagging a question.
# @beta
#
# Set a flag on a quiz question to indicate that you want to return to it
# later.
#
# @argument attempt [Integer]
# The attempt number of the quiz submission being taken. Note that this
# must be the latest attempt index, as questions for earlier attempts can
# not be modified.
#
# @argument validation_token [String]
# The unique validation token you received when the Quiz Submission was
# created.
#
# @argument access_code [Optional, String]
# Access code for the Quiz, if any.
#
# @example_request
# {
# "attempt": 1,
# "validation_token": "YOUR_VALIDATION_TOKEN",
# "access_code": null
# }
def flag
render json: quiz_submission_questions_json([ @question ],
flag_current_question(true))
end
# @API Unflagging a question.
# @beta
#
# Remove the flag that you previously set on a quiz question after you've
# returned to it.
#
# @argument attempt [Integer]
# The attempt number of the quiz submission being taken. Note that this
# must be the latest attempt index, as questions for earlier attempts can
# not be modified.
#
# @argument validation_token [String]
# The unique validation token you received when the Quiz Submission was
# created.
#
# @argument access_code [Optional, String]
# Access code for the Quiz, if any.
#
# @example_request
# {
# "attempt": 1,
# "validation_token": "YOUR_VALIDATION_TOKEN",
# "access_code": null
# }
def unflag
render json: quiz_submission_questions_json([ @question ],
flag_current_question(false))
end
private
def require_question
@question = @quiz.quiz_questions.find(params[:id].to_i)
end
# Export the Quiz and Course from the resolved QS.
def export_scopes
@quiz = @quiz_submission.quiz
require_overridden_quiz
@context = @quiz.context
end
# This is duplicated from QuizSubmissionsApiController and will be moved into
# a Controller Filter once CNVS-10071 is in.
#
# [Transient:CNVS-10071]
def validate_ldb_status!(quiz = @quiz)
if quiz.require_lockdown_browser?
unless ldb_plugin.authorized?(self)
reject! 403, 'this quiz requires the lockdown browser'
end
end
end
# This is duplicated from QuizSubmissionsApiController and will be moved into
# a Controller Filter once CNVS-10071 is in.
#
# [Transient:CNVS-10071]
def ldb_plugin
Canvas::LockdownBrowser.plugin.base
end
# Toggle a question's "flagged" status.
#
# @param [Boolean] flagged_unflagged
#
# @return [Hash] the QS's submission_data.
def flag_current_question(flagged_unflagged)
question_record = {}.with_indifferent_access
question_record["question_#{@question.id}_marked"] = flagged_unflagged
@service.update_question(question_record,
@quiz_submission,
params[:attempt],
# we don't want a snapshot generated for each flagging action
false)
end
# @!appendix Question Answer Formats
#
# {include:file:doc/examples/quiz_question_answers.md}
end

View File

@ -393,7 +393,7 @@ module QuizzesHelper
if answer_list && !answer_list.empty?
# Replace the {{question_BLAH}} template text with the user's answer text.
match = match.sub(/\{\{question_.*?\}\}/, a).
match = match.sub(/\{\{question_.*?\}\}/, a.to_s).
# Match on "/>" but only when at the end of the string and insert "readonly" if set to be readonly
sub(/\/\>\Z/, readonly_markup)
end
@ -404,7 +404,7 @@ module QuizzesHelper
unless answer_list && !answer_list.empty?
answers.delete_if { |k, v| !k.match /^question_#{hash_get(question, :id)}/ }
answers.each { |k, v| res.sub! /\{\{#{k}\}\}/, v }
answers.each { |k, v| res.sub! /\{\{#{k}\}\}/, v.to_s }
res.gsub! /\{\{question_[^}]+\}\}/, ""
end

View File

@ -0,0 +1,133 @@
module QuizQuestion::AnswerSerializers
class AnswerSerializer
attr_accessor :question
def initialize(question)
self.question = question
end
# Serialize the user-supplied answer into a format compatible with
# QuizSubmission#submission_data.
#
# @param [Mixed] answer
# The user-supplied answer. The type of this argument may change between
# serializers. See related AnswerSerializer documentation for the answer
# format they accept.
#
# @return [SerializedAnswer]
def serialize(answer)
raise NotImplementedError
end
# Convert serialized answers from QuizSubmission#submission_data to something
# presentable to the user.
#
# @note
# The format of the output of this method must match the format of the
# user-supplied answer. See #serialize.
#
# @return [Any]
# The output is similar to the user-supplied answer, which may vary between
# serializers.
def deserialize(submission_data)
raise NotImplementedError
end
# Automatically register answer serializers. Each serializer will be mapped
# to the QuizQuestion of a type that is deduced from the serializer's class
# name.
#
# See #question_type
def self.inherited(klass)
QuizQuestion::AnswerSerializers.register_serializer klass.question_type, klass
end
# Override this to explicitly specify the question_type the serializer works
# for.
def self.question_type
self.name.demodulize.underscore
end
protected
# The hash-key of the question answer record in the submission_data.
#
# This varies between question types, so some serializers will override this.
def question_key
@question_key ||= [ 'question', self.question.id ].join('_')
end
# Locate the question data that is usable by *students* when they take the
# quiz, which might be different than the data of @question because the
# teacher might be editing the question and has not yet published the
# changes, in which case the students should use the "frozen" version of
# the question and not the not-yet-published one.
#
# The data set is retrieved from Quiz#quiz_data, if that is not yet generated,
# it falls back to Quiz#stored_questions, and if that still doesn't contain
# our question, it falls back to QuizQuestion#question_data.
#
# Please make sure that you use this data set for any lookups of answer IDs,
# matches, or whatever.
#
# @return [Hash] The question data.
def frozen_question_data
@frozen_question_data ||= begin
question_id = self.question.id
quiz = self.question.quiz
quiz_data = quiz.quiz_data || quiz.stored_questions
quiz_data.detect { |question| question[:id].to_i == question_id } ||
self.question.question_data
end
end
# @return [Array<Hash>] Set of answer records for the frozen question.
def answers
@answers ||= frozen_question_data[:answers]
end
# @return [Array<Integer>] Set of IDs for all the question answers.
def answer_ids
@answer_ids ||= answers.map { |answer| answer[:id].to_i }
end
# @return [Boolean] True if the answer_id identifies a known answer
def answer_available?(answer_id)
answer_ids.include?(answer_id.to_i)
end
end
class << self
attr_accessor :serializers
def register_serializer(question_type, serializer)
self.serializers ||= {}
self.serializers[question_type] = serializer
end
# Get an instance of an AnswerSerializer appropriate for the given question.
#
# @param [QuizQuestion] question
# The question to locate the serializer for.
#
# @return [AnswerSerializer]
# The serializer.
#
# @throw RuntimeError if no serializer was found for the given question
def serializer_for(question)
self.serializers ||= {}
question_type = question.respond_to?(:data) ?
question.data[:question_type] :
question[:question_type]
question_type.gsub! /_question$/, ''
unless serializer_klass = self.serializers[question_type]
raise "No known serializer for questions of type #{question_type}"
end
serializer_klass.new(question)
end
end
end

View File

@ -0,0 +1,4 @@
module QuizQuestion::AnswerSerializers
class Calculated < Numerical
end
end

View File

@ -0,0 +1,34 @@
module QuizQuestion::AnswerSerializers
class Essay < AnswerSerializer
# @param answer_html [String]
# The textual/HTML answer. Will be HTML escaped.
#
# @example output for an answer for QuizQuestion#1
# {
# :question_1 => "&lt;p&gt;Hello World!&lt;/p&gt;"
# }
def serialize(answer_html)
rc = SerializedAnswer.new
unless answer_html.is_a?(String)
return rc.reject :invalid_type, 'answer', String
end
answer_html = Util.sanitize_html answer_html
if Util.text_too_long?(answer_html)
return rc.reject :text_too_long
end
rc.answer[question_key] = answer_html
rc
end
# @return [String]
# The HTML-escaped textual answer.
def deserialize(submission_data)
submission_data[question_key]
end
end
end

View File

@ -0,0 +1,123 @@
module QuizQuestion::AnswerSerializers
# @internal
# :nodoc:
#
# A note on "blanks" to help clear the confusion around their uses, especially
# in the context of this serializer:
#
# There are three distinct versions of an answer's blank used at various
# stages:
#
# 1. the "blank", which is a simple string defined by the teacher when they
# created the quiz question, and this is what the API clients are expected
# to use for sending answers to that blank
#
# 2. the "blank_id": which is a normalized version of the "blank", used
# internally to identify the blank (it's a digest), see Util#blank_id
# and AssessmentQuestion#variable_id for generating this version
#
# 3. the "blank key", which is the key to store the _answer_ to that blank in
# a quiz submission's submission_data construct, nobody has to know about
# this except for the parsers/serializers
#
# Unfortunately, the question_data records refer to the vanilla "blank" as
# "blank_id", so if you do something like:
#
# qq = quiz.quiz_questions.first # say this is a FIMB question
# qq.question_data[:answers][0]
#
# You will get a construct with:
#
# { "id"=>"9711", "text"=>"Red", "weight"=>100, "blank_id"=>"color" }
#
# Which gave me motive to write this note to help clear up the confusion.
class FillInMultipleBlanks < AnswerSerializer
# Accept textual answers for answer blanks.
#
# @example input for two blanks, "color1" and "color2":
# {
# color1: 'red',
# color2: 'blue'
# }
#
# @param [String] answer_hash[:blank]
# The textual answer for the given blank. Will be sanitized
# (stripped and lowercased).
#
# @example output for an answer for QuizQuestion#1 with blank "color":
# {
# "color" => "red"
# }
def serialize(answer_hash)
rc = SerializedAnswer.new
unless answer_hash.is_a?(Hash)
return rc.reject :invalid_type, 'answer', Hash
end
answer_hash.stringify_keys.each_pair do |blank, answer_text|
unless blank_available?(blank)
return rc.reject :unknown_blank, blank
end
validate_blank_answer(blank, answer_text, rc)
unless rc.valid?
break
end
rc.answer[answer_blank_key(blank)] = serialize_blank_answer(answer_text)
end
rc
end
# @note blanks that were not answered are not included in the output
def deserialize(submission_data)
answers.each_with_object({}) do |answer_record, out|
blank = answer_record[:blank_id]
blank_key = answer_blank_key(blank)
blank_answer = submission_data[blank_key]
if blank_answer.present?
out[blank] = deserialize_blank_answer blank_answer
end
end
end
protected
# Tests that the answer is a string and isn't too long.
#
# Override this to provide support for non-textual answers.
def validate_blank_answer(blank, answer_text, rc)
if !answer_text.is_a?(String)
rc.reject :invalid_type, "#{blank}.answer", String
elsif Util.text_too_long?(answer_text)
rc.reject :text_too_long
end
end
# Override this to provide support for non-textual answers.
def serialize_blank_answer(answer_text)
Util.sanitize_text(answer_text)
end
def deserialize_blank_answer(answer_text)
answer_text
end
private
# @return [Boolean] True if the blank_id is recognized
def blank_available?(blank_id)
answers.any? { |answer| answer[:blank_id] == blank_id }
end
# something like: "question_5_1813d2a7223184cf43e19db6622df40b"
def answer_blank_key(blank)
[ question_key, Util.blank_id(blank) ].join('_')
end
end
end

View File

@ -0,0 +1,106 @@
module QuizQuestion::AnswerSerializers
class Matching < AnswerSerializer
# Accept a set of pairings between answer and match IDs.
#
# Serialization request is rejected if:
#
# - the answer isn't an Array
# - an answer entry (pairing) isn't a Hash
# - an answer entry is missing either id
# - either answer_id or match_id isn't a valid number
# - either answer_id or match_id can't be resolved
#
# @example input
# [{
# answer_id: 123,
# match_id: 456
# }]
#
# @example output
# {
# question_5_answer_123: "456"
# }
def serialize(pairings)
rc = SerializedAnswer.new
unless pairings.is_a?(Array)
return rc.reject :invalid_type, 'answer', Array
end
pairings.each_with_index do |entry, index|
answer_id, match_id = nil, nil
unless entry.is_a?(Hash)
return rc.reject :invalid_type, "answer[#{index}]", Hash
end
entry = entry.with_indifferent_access
%w[ answer_id match_id ].each do |required_param|
unless entry.has_key?(required_param)
return rc.reject 'Matching pair is missing parameter "%s"' % [
required_param
]
end
end
answer_id = Util.to_integer(entry[:answer_id])
if answer_id.nil?
return rc.reject :invalid_type, 'answer_id', Integer
end
unless answer_available? answer_id
return rc.reject :unknown_answer, answer_id
end
match_id = Util.to_integer(entry[:match_id])
if match_id.nil?
return rc.reject :invalid_type, 'match_id', Integer
end
unless match_available? match_id
return rc.reject :unknown_match, match_id
end
rc.answer[build_answer_key(answer_id)] = match_id.to_s
end
rc
end
# @note answers that were not matched will _not_ be present in the output
def deserialize(submission_data)
answers.each_with_object([]) do |answer_record, out|
answer_id = answer_record[:id]
answer_key = build_answer_key(answer_id)
match_id = submission_data[answer_key]
if match_id.present?
out << {
answer_id: answer_id,
match_id: match_id.to_i
}.with_indifferent_access
end
end
end
private
def build_answer_key(answer_id)
[ question_key, 'answer', answer_id ].join('_')
end
def match_ids
@match_ids ||= frozen_question_data[:matches].map do |match_record|
match_record[:match_id].to_i
end
end
def match_available?(match_id)
match_ids.include?(match_id)
end
end
end

View File

@ -0,0 +1,77 @@
module QuizQuestion::AnswerSerializers
class MultipleAnswers < AnswerSerializer
# Serialize a selection, a set of answer IDs.
#
# Serialization request will be rejected if:
#
# - the selection is not an Array
# - the selection contains a bad or unknown answer id
#
# @example selection for two answers with id 5 and 8:
# [ 5, 8 ]
#
# @param answer_ids [Array<Integer>]
# The selected answer IDs.
#
# @example Answers 5 and 8 are selected, answer#9 isn't in QuizQuestion#1:
# {
# question_1_answer_5: "1",
# question_1_answer_8: "1",
# question_1_answer_9: "0"
# }
def serialize(selection)
rc = SerializedAnswer.new
unless selection.is_a?(Array)
return rc.reject :invalid_type, 'answer', Array
end
selection.each_with_index do |answer_id, index|
answer_id = Util.to_integer(answer_id)
if answer_id.nil?
return rc.reject :invalid_type, "answer[#{index}]", Integer
elsif !answer_available?(answer_id)
return rc.reject :unknown_answer, answer_id
end
end
selection = selection.map(&:to_i)
answer_ids.each_with_object(rc.answer) do |answer_id, out|
is_selected = selection.include?(answer_id)
out[answer_key(answer_id)] = answer_value(is_selected)
end
rc
end
# @return [Array<Integer>] IDs of the selected answers.
# @example output for answers 5 and 8 selected:
# [ 5, 8 ]
def deserialize(submission_data)
answers.each_with_object([]) do |answer_record, out|
answer_id = answer_record[:id].to_i
is_selected = submission_data[answer_key(answer_id)]
is_selected = Util.to_boolean(is_selected)
if is_selected
out << answer_id
end
end
end
private
def answer_key(answer_id)
[ question_key, 'answer', answer_id ].join('_')
end
# Using anything other than "1" and "0" to indicate whether the answer is
# selected won't work with the current UI.
def answer_value(is_on)
is_on ? "1" : "0"
end
end
end

View File

@ -0,0 +1,37 @@
module QuizQuestion::AnswerSerializers
class MultipleChoice < AnswerSerializer
# Select an answer from the set of available answers.
#
# Serialization request will be rejected if:
#
# - the answer id is bad or unknown
#
# @example input where the answer ID is 123
# {
# answer: 123
# }
#
# @example output where the question ID is 5
# {
# question_5_answer: "123"
# }
def serialize(answer_id)
rc = SerializedAnswer.new
answer_id = Util.to_integer answer_id
if answer_id.nil?
return rc.reject :invalid_type, 'answer', Integer
elsif !answer_available? answer_id
return rc.reject :unknown_answer, answer_id
end
rc.answer[question_key] = answer_id.to_s
rc
end
def deserialize(submission_data)
submission_data[question_key].to_i
end
end
end

View File

@ -0,0 +1,24 @@
module QuizQuestion::AnswerSerializers
class MultipleDropdowns < FillInMultipleBlanks
protected
# Rejects if the answer id is bad or doesn't identify a known answer
def validate_blank_answer(blank, answer_id, rc)
answer_id = Util.to_integer answer_id
if answer_id.nil?
rc.reject :invalid_type, "answer.#{blank}", Integer
elsif !answer_available? answer_id
rc.reject :unknown_answer, answer_id
end
end
def serialize_blank_answer(answer_id)
answer_id.to_i.to_s
end
def deserialize_blank_answer(answer_id)
answer_id.to_i
end
end
end

View File

@ -0,0 +1,35 @@
module QuizQuestion::AnswerSerializers
class Numerical < AnswerSerializer
# Serialize a decimal answer.
#
# @param [BigDecimal|String] answer
#
# @note
# This serializer does not reject any input but instead coerces everything
# to a BigDecimal, even if the input is not a number.
#
# @example acceptable inputs
# { answer: 1 }
# { answer: 2.3e-6 }
# { answer: "8.4" }
#
# @example outputs, respectively from above
# { question_5: 1 }
# { question_5: 2.3e-6 }
# { question_5: 8.4 }
def serialize(answer)
rc = SerializedAnswer.new
rc.answer[question_key] = Util.to_decimal(answer).to_s
rc
end
# @return [BigDecimal|NilClass]
def deserialize(submission_data)
answer = submission_data[question_key]
if answer.present?
Util.to_decimal(answer.to_s)
end
end
end
end

View File

@ -0,0 +1,50 @@
module QuizQuestion::AnswerSerializers
class SerializedAnswer
# @property [Hash] answer
#
# The output of the serializer which is compatible for merging with
# QuizSubmission#submission_data.
attr_accessor :answer
# @property [String|NilClass] error
#
# Will contain a descriptive error message if the serialization fails, nil
# otherwise.
attr_accessor :error
def initialize
self.answer = {}.with_indifferent_access
end
# @return [Boolean] Whether the answer has been serialized successfully.
def valid?
error.blank?
end
def reject(reason, *args)
self.error = reason.to_s
if reason.is_a?(Symbol) && ERROR_CODES.has_key?(reason)
actual_reason = ERROR_CODES[reason]
actual_reason = actual_reason.call(*args) if actual_reason.is_a?(Proc)
self.error = actual_reason
end
self
end
private
ERROR_CODES = {
invalid_type: lambda { |param_name, expected_type|
'%s must be of type %s' % [ param_name, expected_type.to_s ]
},
unknown_answer: lambda { |id| "Unknown answer '#{id}'" },
unknown_match: lambda { |id| "Unknown match '#{id}'" },
unknown_blank: lambda { |id| "Unknown blank '#{id}'" },
text_too_long: 'Text is too long.'
}
end
end

View File

@ -0,0 +1,34 @@
module QuizQuestion::AnswerSerializers
class ShortAnswer < AnswerSerializer
# Serialize a written, textual answer.
#
# Serialization request will be rejected if the answer isn't a string or is
# too long. See Util#text_too_long?
#
# @param answer_hash[:text] String
# The textual/HTML answer. Will be text-escaped.
#
# @example output for an answer for QuizQuestion#1
# {
# :question_1 => "sanitized_answer"
# }
def serialize(answer_text)
rc = SerializedAnswer.new
if !answer_text.is_a?(String)
return rc.reject :invalid_type, 'answer', String
elsif Util.text_too_long? answer_text
return rc.reject :text_too_long
end
rc.answer[question_key] = Util.sanitize_text(answer_text)
rc
end
# @return [String|NilClass] The textual answer, if any.
def deserialize(submission_data)
submission_data[question_key]
end
end
end

View File

@ -0,0 +1,4 @@
module QuizQuestion::AnswerSerializers
class TrueFalse < MultipleChoice
end
end

View File

@ -0,0 +1,47 @@
module QuizQuestion::AnswerSerializers
module Util
MaxTextualAnswerLength = 16.kilobyte
class << self
def blank_id(blank)
AssessmentQuestion.variable_id(blank)
end
# Cast a numerical value to an Integer.
#
# @return [Integer|NilClass]
# nil if the parameter isn't really an integer.
def to_integer(number)
begin
Integer(number)
rescue
nil
end
end
# Convert a value to a BigDecimal.
#
# @return [BigDecimal]
def to_decimal(value)
BigDecimal(value.to_s)
end
def to_boolean(flag)
Canvas::Plugin.value_to_boolean(flag)
end
# See Util.MaxTextualAnswerLength for the threshold.
def text_too_long?(text)
text.to_s.length >= MaxTextualAnswerLength
end
def sanitize_html(html)
Sanitize.clean((html || '').to_s, Instructure::SanitizeField::SANITIZE)
end
def sanitize_text(text)
(text || '').to_s.strip.downcase
end
end
end
end

View File

@ -280,6 +280,8 @@ class QuizSubmission < ActiveRecord::Base
new_params[:cnt] = (new_params[:cnt].to_i + 1) % 5
snapshot!(params) if new_params[:cnt] == 1
conn.execute("UPDATE quiz_submissions SET user_id=#{self.user_id || 'NULL'}, submission_data=#{conn.quote(new_params.to_yaml)} WHERE workflow_state NOT IN ('complete', 'pending_review') AND id=#{self.id}")
new_params
end
def sanitize_params(params)

View File

@ -26,8 +26,8 @@ class QuizSubmissionService
# @param [Quiz] quiz
# The Quiz to take.
#
# @throw ServiceError(403) if the student isn't allowed to take the quiz
# @throw ServiceError(403) if the user is not logged in and the Quiz isn't public
# @throw ApiError(403) if the student isn't allowed to take the quiz
# @throw ApiError(403) if the user is not logged in and the Quiz isn't public
#
# See #assert_takeability! for further errors that might be thrown.
# See #assert_retriability! for further errors that might be thrown.
@ -39,7 +39,7 @@ class QuizSubmissionService
reject! 403, 'you are not allowed to participate in this quiz'
end
assert_takeability! quiz, participant.access_code, participant.ip_address
assert_takeability! quiz
# Look up an existing QS, and if one exists, make sure it is retriable.
assert_retriability! participant.find_quiz_submission(quiz.quiz_submissions, {
@ -59,7 +59,7 @@ class QuizSubmissionService
# @param [Hash] session
# The Rails session. Used for testing access permissions.
#
# @throw ServiceError(403) if the user isn't privileged to update the Quiz
# @throw ApiError(403) if the user isn't privileged to update the Quiz
#
# @return [QuizSubmission]
# The newly created preview QS.
@ -82,8 +82,8 @@ class QuizSubmissionService
# The QuizSubmission#attempt that is requested to be completed. This must
# match the quiz_submission's current attempt index.
#
# @throw ServiceError(403) if the participant can't take the quiz
# @throw ServiceError(400) if the QS is already complete
# @throw ApiError(403) if the participant can't take the quiz
# @throw ApiError(400) if the QS is already complete
#
# Further errors might be thrown from the following methods:
#
@ -102,7 +102,7 @@ class QuizSubmissionService
end
# Participant must be able to take the quiz...
assert_takeability! quiz, participant.access_code, participant.ip_address
assert_takeability! quiz
# And be the owner of the quiz submission:
validate_token! quiz_submission, participant.validation_token
@ -197,6 +197,54 @@ class QuizSubmissionService
end
end
# Provide an answer to a question, or flag it, while taking a quiz. A snapshot
# of the QS will be made with the new answer state.
#
# @param [Hash] question_record
# The "answer record" for the question in the QS's submission_data. This
# can be obtained using the QuizQuestion::AnswerSerializers for a given QQ.
#
# @param [QuizSubmission] quiz_submission
# The QS we're manipulating (answering/flagging.)
#
# @param [Integer] attempt
# The attempt index this answer/modification applies to. This must match
# the quiz_submission's current attempt index.
#
# @throw ApiError(403) if the participant can't update the QS (ie, not the owner)
# @throw ApiError(400) if the QS is complete or overdue
#
# Further errors might be thrown from the following methods:
#
# - #assert_takeability!
# - #assert_retriability!
# - #validate_token!
# - #ensure_latest_attempt!
#
# @return [Hash] the recently-adjusted submission_data set
def update_question(question_record, quiz_submission, attempt, snapshot=true)
unless quiz_submission.grants_right?(participant.user, :update)
reject! 403, 'you are not allowed to update questions for this quiz submission'
end
if quiz_submission.completed?
reject! 400, 'quiz submission is already complete'
elsif quiz_submission.overdue?
reject! 400, 'quiz submission is overdue'
end
assert_takeability! quiz_submission.quiz
validate_token! quiz_submission, participant.validation_token
ensure_latest_attempt! quiz_submission, attempt
quiz_submission.backup_submission_data question_record.merge({
validation_token: participant.validation_token,
cnt: snapshot ? 5 : 1 # force generation of snapshot
})
end
protected
# Abort the current service request with an error similar to an API error.
@ -215,26 +263,42 @@ class QuizSubmissionService
#
# @param [Quiz] quiz
# The Quiz we're attempting to take.
# @param [String] access_code
# The Access Code provided by the client.
# @param [String] ip_address
# The IP address of the client.
#
# @throw ServiceError(400) if the Quiz is locked
# @throw ServiceError(403) if the access code is invalid
# @throw ServiceError(403) if the IP address isn't covered
def assert_takeability!(quiz, access_code = nil, ip_address = nil)
# @param [QuizParticipant] participant
# The person trying to take the quiz.
#
# @param [String] participant.access_code
# The Access Code provided by the participant.
#
# @param [String] participant.ip_address
# The IP address of the participant.
#
# @throw ApiError(400) if the Quiz is locked
# @throw ApiError(501) if the Quiz has the "can't go back" flag on
# @throw ApiError(403) if the access code is invalid
# @throw ApiError(403) if the IP address isn't covered
def assert_takeability!(quiz, participant = self.participant)
if quiz.locked?
reject! 400, 'quiz is locked'
end
validate_access_code! quiz, access_code
validate_ip_address! quiz, ip_address
# [Transient:CNVS-10224] - support for CGB-OQAAT quizzes
if quiz.cant_go_back
reject! 501, 'that type of quizzes is not supported yet'
end
if quiz.access_code.present? && quiz.access_code != participant.access_code
reject! 403, 'invalid access code'
end
if quiz.ip_filter && !quiz.valid_ip?(participant.ip_address)
reject! 403, 'IP address denied'
end
end
# Verify the given QS is retriable.
#
# @throw ServiceError(409) if the QS is not new and can not be retried
# @throw ApiError(409) if the QS is not new and can not be retried
#
# See QuizSubmission#retriable?
def assert_retriability!(quiz_submission)
@ -243,34 +307,6 @@ class QuizSubmissionService
end
end
# Verify that the given access code matches the one set by the Quiz author.
#
# @param [Quiz] quiz
# The Quiz to test.
# @param [String] access_code
# The user-supplied Access Code to validate.
#
# @throw ApiError(403) if the access code is invalid
def validate_access_code!(quiz, access_code)
if quiz.access_code.present? && quiz.access_code != access_code
reject! 403, 'invalid access code'
end
end
# Verify that the given IP address is allowed to access a Quiz.
#
# @param [Quiz] quiz
# The Quiz to test.
# @param [String] ip_address
# IP address of the request originated from.
#
# @throw ApiError(403) if the IP address isn't covered
def validate_ip_address!(quiz, ip_address)
if quiz.ip_filter && !quiz.valid_ip?(ip_address)
reject! 403, 'IP address denied'
end
end
def validate_token!(quiz_submission, validation_token)
unless quiz_submission.valid_token?(validation_token)
reject! 403, 'invalid token'
@ -290,8 +326,8 @@ class QuizSubmissionService
# @param [Integer|String] attempt
# The attempt to validate.
#
# @throw ServiceError(400) if attempt isn't a valid integer
# @throw ServiceError(400) if attempt is invalid (ie, isn't the latest one)
# @throw ApiError(400) if attempt isn't a valid integer
# @throw ApiError(400) if attempt is invalid (ie, isn't the latest one)
def ensure_latest_attempt!(quiz_submission, attempt)
attempt = Integer(attempt) rescue nil

View File

@ -1352,6 +1352,12 @@ routes.draw do
post 'courses/:course_id/quizzes/:quiz_id/submissions/:id/complete', :action => :complete, :path_name => 'course_quiz_submission_complete'
end
scope(:controller => :quiz_submission_questions) do
put '/quiz_submissions/:quiz_submission_id/questions/:id', :action => :answer, :path_name => 'quiz_submission_question_answer'
put '/quiz_submissions/:quiz_submission_id/questions/:id/flag', :action => :flag, :path_name => 'quiz_submission_question_flag'
put '/quiz_submissions/:quiz_submission_id/questions/:id/unflag', :action => :unflag, :path_name => 'quiz_submission_question_unflag'
end
scope(:controller => :quiz_ip_filters) do
get 'courses/:course_id/quizzes/:quiz_id/ip_filters', :action => :index, :path_name => 'course_quiz_ip_filters'
end

View File

@ -0,0 +1,415 @@
<style>
.appendix_entry th { text-align: left; }
.appendix_entry th,
.appendix_entry td {
padding: 10px;
border: 1px solid #ccc;
}
.appendix_entry div.syntaxhighlighter {
border: none;
padding: 0;
}
.appendix_entry div.syntaxhighlighter table {
width: 100%;
}
.appendix_entry h4 {
color: green;
}
</style>
#### Essay Questions
- Question parametric type: `essay_question`
- Parameter type: **`Text`**
- Parameter synopsis: `{ "answer": "Answer text." }`
**Example request**
```javascript
{
"answer": "<h2>My essay</h2>\n\n<p>This is a long article.</p>"
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Text is too long.</code></td>
<td>The answer text is larger than the allowed limit of 16 kilobytes.</td>
</tr>
</tbody>
</table>
#### Fill In Multiple Blanks Questions
- Question parametric type: `fill_in_multiple_blanks_question`
- Parameter type: **`Hash{String => String}`**
- Parameter synopsis: `{ "answer": { "variable": "Answer string." } }`
**Example request**
Given that the question accepts answers to two variables, `color1` and `color2`:
```javascript
{
"answer": {
"color1": "red",
"color2": "green"
}
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown variable 'var'.</code></td>
<td>The answer map contains a variable that is not accepted by the question.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Text is too long.</code></td>
<td>The answer text is larger than the allowed limit of 16 kilobytes.</td>
</tr>
</tbody>
</table>
#### Fill In The Blank Questions
- Question parametric type: `short_answer_question`
- Parameter type: **`String`**
- Parameter synopsis: `{ "answer": "Some sentence." }`
**Example request**
```javascript
{
"answer": "Hello World!"
}
```
**Possible errors**
Similar to the errors produced by [Essay Questions](#essay-questions).
<a class="bookmark" id="formula-questions"></a>
#### Formula Questions
- Question parametric type: `calculated_question`
- Parameter type: **`Decimal`**
- Parameter synopsis: `{ "answer": decimal }` where `decimal` is either a rational
number, or a literal version of it (String)
**Example request**
With an exponent:
```javascript
{
"answer": 2.3e-6
}
```
With a string for a number:
```javascript
{
"answer": "13.4"
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Parameter must be a valid decimal.</code></td>
<td>The specified value could not be processed as a decimal.</td>
</tr>
</tbody>
</table>
#### Matching Questions
- Question parametric type: `matching_question`
- Parameter type: **`Array<Hash>`**
- Parameter synopsis: `{ "answer": [{ "answer_id": id, "match_id": id }] }` where
the IDs must identify answers and matches accepted by the question.
**Example request**
Given that the question accepts 3 answers with IDs `[ 3, 6, 9 ]` and 6 matches
with IDs: `[ 10, 11, 12, 13, 14, 15 ]`:
```javascript
{
"answer": [{
"answer_id": 6,
"match_id": 10
}, {
"answer_id": 3,
"match_id": 14
}]
}
```
The above request:
- pairs `answer#6` with `match#10`
- pairs `answer#3` with `match#14`
- leaves `answer#9` *un-matched*
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Answer must be of type Array.</code></td>
<td>The match-pairings set you supplied is not an array.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Answer entry must be of type Hash, got '...'.</code></td>
<td>One of the entries of the match-pairings set is not a valid hash.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Missing parameter 'answer_id'.</code></td>
<td>One of the entries of the match-pairings does not specify an <code>answer_id</code>.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Missing parameter 'match_id'.</code></td>
<td>One of the entries of the match-pairings does not specify an <code>match_id</code>.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Parameter must be of type Integer.</code></td>
<td>
One of the specified <code>answer_id</code> or <code>match_id</code>
is not an integer.
</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown answer '123'.</code></td>
<td>An <code>answer_id</code> you supplied does not identify a valid answer
for that question.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown match '123'.</code></td>
<td>A <code>match_id</code> you supplied does not identify a valid match
for that question.</td>
</tr>
</tbody>
</table>
<a class="bookmark" id="multiple-choice-questions"></a>
#### Multiple Choice Questions
- Question parametric type: `multiple_choice_question`
- Parameter type: **`Integer`**
- Parameter synopsis: `{ "answer": answer_id }` where `answer_id` is an ID of
one of the question's answers.
**Example request**
Given an answer with an ID of 5:
```javascript
{
"answer": 5
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Parameter must be of type Integer.</code></td>
<td>The specified `answer_id` is not an integer.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown answer '123'</code></td>
<td>The specified `answer_id` is not a valid answer.</td>
</tr>
</tbody>
</table>
#### Multiple Dropdowns Questions
- Question parametric type: `multiple_dropdowns_question`
- Parameter type: **`Hash{String => Integer}`**
- Parameter synopsis: `{ "answer": { "variable": answer_id } }` where the keys
are variables accepted by the question, and their values are IDs of answers
provided by the question.
**Example request**
Given that the question accepts 3 answers to a variable named `color` with the
ids `[ 3, 6, 9 ]`:
```javascript
{
"answer": {
"color": 6
}
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown variable 'var'.</code></td>
<td>The answer map you supplied contains a variable that is not accepted
by the question.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown answer '123'.</code></td>
<td>An <code>answer_id</code> you supplied does not identify a valid answer
for that question.</td>
</tr>
</tbody>
</table>
#### Multiple Answers Questions
- Question parametric type: `multiple_answers_question`
- Parameter type: **`Array<Integer>`**
- Parameter synopsis: `{ "answer": [ answer_id ] }` where the array items are
IDs of answers accepted by the question.
**Example request**
Given that the question accepts 3 answers with the ids `[ 3, 6, 9 ]` and we
want to select the answers `3` and `6`:
```javascript
{
"answer": [ 3, 6 ]
}
```
**Possible errors**
<table>
<thead>
<tr>
<th>HTTP RC</th>
<th>Error Message</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td>400 Bad Request</td>
<td><code>Selection must be of type Array.</code></td>
<td>The selection set you supplied is not an array.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Parameter must be of type Integer.</code></td>
<td>One of the answer IDs you supplied is not a valid ID.</td>
</tr>
<tr>
<td>400 Bad Request</td>
<td><code>Unknown answer '123'.</code></td>
<td>An answer ID you supplied in the selection set does not identify a
valid answer for that question.</td>
</tr>
</tbody>
</table>
#### Numerical Questions
- Question parametric type: `numerical_question`
This is similar to [Formula Questions](#formula-questions).
<a class="bookmark" id="essay-questions"></a>
#### True/False Questions
- Question parametric type: `true_false_question`
The rest is similar to [Multiple Choice questions](#multiple-choice-questions).

View File

@ -25,8 +25,9 @@ module Api::V1::Helpers::QuizSubmissionsApiHelper
def require_quiz_submission
collection = @quiz ? @quiz.quiz_submissions : QuizSubmission
id = params[:quiz_submission_id] || params[:id] || ''
unless @quiz_submission = collection.find(params[:id])
unless @quiz_submission = collection.find(id.to_i)
raise ActiveRecord::RecordNotFound
end
end

View File

@ -0,0 +1,68 @@
#
# Copyright (C) 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
module Api::V1::QuizSubmissionQuestion
def quiz_submission_questions_json(quiz_questions, submission_data)
{
quiz_submission_questions: quiz_questions.map do |qq|
quiz_submission_question_json(qq, submission_data)
end
}
end
# A renderable version of a QuizQuestion's "answer" record in a submission's
# answer set. The answer construct contains three pieces of data:
#
# - the question's id
# - its "flagged" status
# - a representation of its answer which depends on the type of question it
# is. See QuizQuestion::AnswerSerializers for possible answer formats.
#
# @param [QuizQuestion] qq
# A question of a Quiz.
#
# @param [Hash] submission_data
# The QuizSubmission#submission_data in which the question's answer record
# will be looked up and serialized.
#
# @return [Hash]
# The question's answer record. See example for what it contains.
#
# @example output for a multiple-choice quiz question
# {
# id: 5,
# flagged: true,
# answer: 123
# }
def quiz_submission_question_json(qq, submission_data)
answer_serializer = QuizQuestion::AnswerSerializers.serializer_for(qq)
data = {}
data[:id] = qq.id
data[:flagged] = to_boolean(submission_data["question_#{qq.id}_marked"])
data[:answer] = answer_serializer.deserialize(submission_data)
data
end
private
def to_boolean(v)
Canvas::Plugin.value_to_boolean(v)
end
end

View File

@ -71,7 +71,18 @@ define([
cantGoBack: $("#submit_quiz_form").hasClass("cant_go_back"),
finalSubmitButtonClicked: false,
clockInterval: 500,
backupsDisabled: document.location.search.search(/backup=false/) > -1,
updateSubmission: function(repeat, beforeLeave, autoInterval) {
/**
* Transient: CNVS-9844
* Disable auto-backups if backup=true was passed as a query parameter.
*
* This is required to test updating questions via the API.
*/
if (quizSubmission.backupsDisabled) {
return;
}
if(quizSubmission.submitting && !repeat) { return; }
var now = new Date();
if((now - quizSubmission.lastSubmissionUpdate) < 1000 && !autoInterval) {

View File

@ -0,0 +1,348 @@
#
# Copyright (C) 2013 Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
describe QuizSubmissionQuestionsController, :type => :integration do
module Helpers
def create_question(type, factory_options = {}, quiz=@quiz)
factory = method(:"#{type}_question_data")
# can't test for #arity directly since it might be an optional parameter
data = factory.parameters.include?([ :opt, :options ]) ?
factory.call(factory_options) :
factory.call
data = data.except('id', 'assessment_question_id')
qq = quiz.quiz_questions.create!({ question_data: data })
qq.assessment_question.question_data = data
qq.assessment_question.save!
qq
end
def api_update(data = {}, options = {})
data = {
validation_token: @quiz_submission.validation_token,
attempt: @quiz_submission.attempt
}.merge(data)
helper = method(options[:raw] ? :raw_api_call : :api_call)
helper.call(:put,
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}",
{ :controller => 'quiz_submission_questions',
:action => 'answer',
:format => 'json',
:quiz_submission_id => @quiz_submission.id.to_s,
:id => @question[:id].to_s
}, data)
end
def api_flag(data = {}, options = {})
data = {
validation_token: @quiz_submission.validation_token,
attempt: @quiz_submission.attempt
}.merge(data)
helper = method(options[:raw] ? :raw_api_call : :api_call)
helper.call(:put,
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}/flag",
{ :controller => 'quiz_submission_questions',
:action => 'flag',
:format => 'json',
:quiz_submission_id => @quiz_submission.id.to_s,
:id => @question[:id].to_s
}, data)
end
def api_unflag(data = {}, options = {})
data = {
validation_token: @quiz_submission.validation_token,
attempt: @quiz_submission.attempt
}.merge(data)
helper = method(options[:raw] ? :raw_api_call : :api_call)
helper.call(:put,
"/api/v1/quiz_submissions/#{@quiz_submission.id}/questions/#{@question[:id]}/unflag",
{ :controller => 'quiz_submission_questions',
:action => 'unflag',
:format => 'json',
:quiz_submission_id => @quiz_submission.id.to_s,
:id => @question[:id].to_s
}, data)
end
end
include Helpers
before :each do
course_with_student_logged_in(:active_all => true)
@quiz = quiz_model(course: @course)
@quiz_submission = @quiz.generate_submission(@student)
end
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id [update]' do
context 'answering questions' do
it 'should answer a MultipleChoice question' do
@question = create_question 'multiple_choice'
json = api_update({
answer: 1658
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 1658
end
it 'should answer a TrueFalse question' do
@question = create_question 'true_false'
json = api_update({
answer: 8403
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 8403
end
it 'should answer a ShortAnswer question' do
@question = create_question 'short_answer'
json = api_update({
answer: 'Hello World!'
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 'hello world!'
end
it 'should answer a FillInMultipleBlanks question' do
@question = create_question 'fill_in_multiple_blanks'
json = api_update({
answer: {
answer1: 'red',
answer3: 'green',
answer4: 'blue'
}
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == {
answer1: 'red',
answer3: 'green',
answer4: 'blue'
}.with_indifferent_access
end
it 'should answer a MultipleAnswers question' do
@question = create_question 'multiple_answers', {
answer_parser_compatibility: true
}
json = api_update({
answer: [ 9761, 5194 ]
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'][0]['answer'].include?(9761).should be_true
json['quiz_submission_questions'][0]['answer'].include?(5194).should be_true
end
it 'should answer an Essay question' do
@question = create_question 'essay'
json = api_update({
answer: 'Foobar'
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'][0]['answer'].should == 'Foobar'
end
it 'should answer a MultipleDropdowns question' do
@question = create_question 'multiple_dropdowns'
json = api_update({
answer: {
structure1: 4390,
event2: 599
}
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'][0]['answer'].should == {
structure1: 4390,
event2: 599
}.with_indifferent_access
end
it 'should answer a Matching question' do
@question = create_question 'matching', {
answer_parser_compatibility: true
}
json = api_update({
answer: [
{ answer_id: 7396, match_id: 6061 },
{ answer_id: 4224, match_id: 3855 }
]
})
json['quiz_submission_questions'].present?.should be_true
answer = json['quiz_submission_questions'][0]['answer']
answer
.include?({ answer_id: 7396, match_id: 6061 }.with_indifferent_access)
.should be_true
answer
.include?({ answer_id: 4224, match_id: 3855 }.with_indifferent_access)
.should be_true
end
it 'should answer a Numerical question' do
@question = create_question 'numerical'
json = api_update({
answer: 2.5e-3
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'][0]['answer'].should == 0.0025
end
it 'should answer a Calculated question' do
@question = create_question 'calculated'
json = api_update({
answer: '122.1'
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'][0]['answer'].should == 122.1
end
end
it 'should update an answer' do
@question = create_question 'multiple_choice'
json = api_update({
answer: 1658
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 1658
@question = create_question 'multiple_choice'
json = api_update({
answer: 2405
})
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 2405
end
it 'should answer according to the published state of the question' do
@question = create_question 'multiple_choice'
new_question_data = @question.question_data
new_question_data[:answers].each do |answer_record|
answer_record[:id] += 1
end
@question.question_data = new_question_data
@question.save!
api_update({ answer: 1658 }, { raw: true })
response.status.to_i.should == 400
response.body.should match(/unknown answer '1658'/i)
json = api_update({ answer: 1659 })
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['answer'].should == 1659
end
it 'should present errors' do
@question = create_question 'multiple_choice'
api_update({ answer: 'asdf' }, { raw: true })
response.status.to_i.should == 400
response.body.should match(/must be of type integer/i)
end
# This is duplicated from QuizSubmissionsApiController spec and will be
# moved into a Controller Filter spec once CNVS-10071 is in.
#
# [Transient:CNVS-10071]
it 'should respect the quiz LDB requirement' do
@question = create_question 'multiple_choice'
@quiz.require_lockdown_browser = true
@quiz.save
Quiz.stubs(:lockdown_browser_plugin_enabled?).returns true
fake_plugin = Object.new
fake_plugin.stubs(:authorized?).returns false
fake_plugin.stubs(:base).returns fake_plugin
subject.stubs(:ldb_plugin).returns fake_plugin
Canvas::LockdownBrowser.stubs(:plugin).returns fake_plugin
api_update({}, { raw: true })
response.status.to_i.should == 403
response.body.should match(/requires the lockdown browser/i)
end
end
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id/flag [flag]' do
it 'should flag the question' do
@question = create_question('multiple_choice')
json = api_flag
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['flagged'].should == true
end
end
describe 'PUT /quiz_submissions/:quiz_submission_id/questions/:id/unflag [unflag]' do
it 'should unflag the question' do
@question = create_question('multiple_choice')
json = api_unflag
json['quiz_submission_questions'].present?.should be_true
json['quiz_submission_questions'].length.should == 1
json['quiz_submission_questions'][0]['flagged'].should == false
end
end
end

View File

@ -19,30 +19,6 @@
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
shared_examples_for 'Quiz Submissions API Restricted Endpoints' do
it 'should require a valid access token' do
@quiz.access_code = 'foobar'
@quiz.save!
@request_proxy.call true, {
attempt: 1
}
response.status.to_i.should == 403
response.body.should match(/invalid access code/)
end
it 'should require a valid ip' do
@quiz.ip_filter = '10.0.0.1/24'
@quiz.save!
@request_proxy.call true, {
attempt: 1
}
response.status.to_i.should == 403
response.body.should match(/ip address denied/i)
end
it 'should require the LDB' do
@quiz.require_lockdown_browser = true
@quiz.save
@ -168,8 +144,6 @@ describe QuizSubmissionsApiController, type: :request do
@quiz.published_at = Time.now
@quiz.workflow_state = 'available'
@quiz.save!
@assignment = @quiz.assignment
end
describe 'GET /courses/:course_id/quizzes/:quiz_id/submissions [INDEX]' do
@ -339,28 +313,12 @@ describe QuizSubmissionsApiController, type: :request do
qs_api_create
end
context 'parameter, permission, and state validations' do
context 'access validations' do
it_should_behave_like 'Quiz Submissions API Restricted Endpoints'
before :each do
@request_proxy = method(:qs_api_create)
end
it 'should reject creating a QS when one already exists' do
qs_api_create
qs_api_create(true)
response.status.to_i.should == 409
end
it 'should respect the number of allowed attempts' do
json = qs_api_create
qs = QuizSubmission.find(json['quiz_submissions'][0]['id'])
qs.mark_completed
qs.save!
qs_api_create(true)
response.status.to_i.should == 409
end
end
end
@ -382,7 +340,7 @@ describe QuizSubmissionsApiController, type: :request do
json['quiz_submissions'][0]['workflow_state'].should == 'complete'
end
context 'parameter, permission, and state validations' do
context 'access validations' do
it_should_behave_like 'Quiz Submissions API Restricted Endpoints'
before do

View File

@ -113,8 +113,12 @@ def multiple_dropdowns_question_data
], "question_text"=>"[structure1] [event1] [structure2] [structure3] [structure4] [structure5] [structure6] [event2] [structure7]"}.with_indifferent_access
end
def matching_question_data
{"id" => 1, "name"=>"Question", "correct_comments"=>"", "question_type"=>"matching_question", "assessment_question_id"=>4, "neutral_comments"=>"", "incorrect_comments"=>"", "question_name"=>"Question", "points_possible"=>50.0, "matches"=>[{"match_id"=>6061, "text"=>"1"}, {"match_id"=>3855, "text"=>"2"}, {"match_id"=>1397, "text"=>"1"}, {"match_id"=>2369, "text"=>"3"}, {"match_id"=>6065, "text"=>"4"}, {"match_id"=>5779, "text"=>"5"}, {"match_id"=>3562, "text"=>"6"}, {"match_id"=>1500, "text"=>"7"}, {"match_id"=>8513, "text"=>"8"}, {"match_id" => 6067, "text" => "a2"}, {"match_id" => 6068, "text" => "a3"}, {"match_id" => 6069, "text" => "a4"}], "answers"=>[
# @param [Hash] options
# @param [Boolean] options.answer_parser_compatibility
# Set this to true if you want the fixture to be compatible with
# QuizQuestion::AnswerParsers::Matching.
def matching_question_data(options = {})
data = {"id" => 1, "name"=>"Question", "correct_comments"=>"", "question_type"=>"matching_question", "assessment_question_id"=>4, "neutral_comments"=>"", "incorrect_comments"=>"", "question_name"=>"Question", "points_possible"=>50.0, "matches"=>[{"match_id"=>6061, "text"=>"1"}, {"match_id"=>3855, "text"=>"2"}, {"match_id"=>1397, "text"=>"1"}, {"match_id"=>2369, "text"=>"3"}, {"match_id"=>6065, "text"=>"4"}, {"match_id"=>5779, "text"=>"5"}, {"match_id"=>3562, "text"=>"6"}, {"match_id"=>1500, "text"=>"7"}, {"match_id"=>8513, "text"=>"8"}, {"match_id" => 6067, "text" => "a2"}, {"match_id" => 6068, "text" => "a3"}, {"match_id" => 6069, "text" => "a4"}], "answers"=>[
{"left"=>"a", "comments"=>"", "match_id"=>6061, "text"=>"a", "id"=>7396, "right"=>"1"},
{"left"=>"b", "comments"=>"", "match_id"=>3855, "text"=>"b", "id"=>6081, "right"=>"2"},
{"left"=>"ca", "comments"=>"", "match_id"=>1397, "text"=>"ca", "id"=>4224, "right"=>"1"},
@ -122,6 +126,25 @@ def matching_question_data
{"left"=>"a3", "comments"=>"", "match_id"=>6068, "text"=>"a", "id"=>7398, "right"=>"a3"},
{"left"=>"a4", "comments"=>"", "match_id"=>6069, "text"=>"a", "id"=>7399, "right"=>"a4"},
], "question_text"=>"<p>Test Question</p>"}.with_indifferent_access
if options[:answer_parser_compatibility]
data['answers'].each do |record|
record['answer_match_left'] = record['left']
record['answer_match_text'] = record['text']
record['answer_match_right'] = record['right']
record['answer_comments'] = record['comments']
%w[ left text right comments ].each { |k| record.delete k }
end
# match#1397 has a duplicate text with #7396 that needs to be adjusted
i = data['matches'].index { |record| record['match_id'] == 1397 }
data['matches'][i]['text'] = '_1'
i = data['answers'].index { |record| record['match_id'] == 1397 }
data['answers'][i]['text'] = '_1'
end
data
end
def numerical_question_data
@ -158,8 +181,12 @@ def calculated_question_data
"id" => 1}.with_indifferent_access
end
def multiple_answers_question_data
{"name"=>"Question",
# @param [Hash] options
# @param [Boolean] options.answer_parser_compatibility
# Set this to true if you want the fixture to be compatible with
# QuizQuestion::AnswerParsers::MultipleAnswers.
def multiple_answers_question_data(options = {})
data = {"name"=>"Question",
"correct_comments"=>"",
"question_type"=>"multiple_answers_question",
"assessment_question_id"=>8197062,
@ -178,6 +205,14 @@ def multiple_answers_question_data
{"comments"=>"", "weight"=>100, "text"=>"431", "id"=>9701},
{"comments"=>"", "weight"=>0, "text"=>"schadenfreude", "id"=>7381}],
"question_text"=>"<p>which of these are numbers?</p>", "id" => 1}.with_indifferent_access
if options[:answer_parser_compatibility]
data['answers'].each do |record|
record['answer_weight'] = record['weight']
end
end
data
end
def fill_in_multiple_blanks_question_data

View File

@ -0,0 +1,21 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
describe QuizQuestion::AnswerSerializers::AnswerSerializer do
ASes = QuizQuestion::AnswerSerializers
it 'automatically registers answer serializers' do
serializer = nil
qq = {}
qq.stubs(:data).returns { { question_type: 'uber_hax_question' } }
expect { ASes.serializer_for qq }.to raise_error
class UberHax < QuizQuestion::AnswerSerializers::AnswerSerializer
end
expect { serializer = ASes.serializer_for qq }.to_not raise_error
serializer.is_a?(ASes::AnswerSerializer).should be_true
end
end

View File

@ -0,0 +1,25 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::Essay do
it_should_behave_like 'Answer Serializers'
let :input do
'Hello World!'
end
let :output do
{
question_5: 'Hello World!'
}.with_indifferent_access
end
it 'should return nil when un-answered' do
subject.deserialize({}).should == nil
end
context 'validations' do
it_should_behave_like 'Textual Answer Serializers'
end
end

View File

@ -0,0 +1,57 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::FillInMultipleBlanks do
it_should_behave_like 'Answer Serializers'
let :input do
{
answer1: 'Red',
answer3: 'Green',
answer4: 'Blue'
}.with_indifferent_access
end
let :output do
{
"question_5_#{AssessmentQuestion.variable_id 'answer1'}" => 'red',
"question_5_#{AssessmentQuestion.variable_id 'answer3'}" => 'green',
"question_5_#{AssessmentQuestion.variable_id 'answer4'}" => 'blue'
}.with_indifferent_access
end
Util = QuizQuestion::AnswerSerializers::Util
# needed for auto specs
def sanitize(answer_hash)
answer_hash.each_pair do |variable, answer_text|
answer_hash[variable] = Util.sanitize_text(answer_text)
end
answer_hash
end
# needed for auto specs
def format(answer_text)
{ answer1: answer_text }
end
context 'validations' do
it_should_behave_like 'Textual Answer Serializers'
it 'should reject unexpected types' do
[ 'asdf', nil ].each do |bad_input|
rc = subject.serialize(bad_input)
rc.error.should_not be_nil
rc.error.should match /must be of type hash/i
end
end
it 'should reject an answer to an unknown blank' do
rc = subject.serialize({ foobar: 'yeeeeeeeeee' })
rc.error.should_not be_nil
rc.error.should match /unknown blank/i
end
end
end

View File

@ -0,0 +1,92 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::Matching do
it_should_behave_like 'Answer Serializers'
let :input do
[
{ answer_id: 7396, match_id: 6061 }.with_indifferent_access,
{ answer_id: 4224, match_id: 3855 }.with_indifferent_access
]
end
let :output do
{
"question_5_answer_7396" => "6061",
"question_5_answer_4224" => "3855"
}.with_indifferent_access
end
let :factory_options do
{
answer_parser_compatibility: true
}
end
context 'validations' do
it 'should reject a bad pairing set' do
[ nil, 'asdf' ].each do |bad_input|
rc = subject.serialize(bad_input)
rc.error.should_not be_nil
rc.error.should match(/of type array/i)
end
end
it 'should reject a bad pairing entry' do
rc = subject.serialize([ 'asdf' ])
rc.error.should_not be_nil
rc.error.should match(/of type hash/i)
end
it 'should reject a pairing entry missing a required parameter' do
rc = subject.serialize([ match_id: 123 ])
rc.error.should_not be_nil
rc.error.should match(/missing parameter "answer_id"/i)
rc = subject.serialize([ answer_id: 123 ])
rc.error.should_not be_nil
rc.error.should match(/missing parameter "match_id"/i)
end
it 'should reject a match for an unknown answer' do
rc = subject.serialize([{
answer_id: 123,
match_id: 6061
}])
rc.error.should_not be_nil
rc.error.should match(/unknown answer/i)
end
it 'should reject an unknown match' do
rc = subject.serialize([{
answer_id: 7396,
match_id: 123456
}])
rc.error.should_not be_nil
rc.error.should match(/unknown match/i)
end
it 'should reject a bad match' do
rc = subject.serialize([{
answer_id: 7396,
match_id: 'adooken'
}])
rc.error.should_not be_nil
rc.error.should match(/must be of type integer/i)
end
it 'should reject a bad answer' do
rc = subject.serialize([{
answer_id: 'ping',
match_id: 6061
}])
rc.error.should_not be_nil
rc.error.should match(/must be of type integer/i)
end
end
end

View File

@ -0,0 +1,48 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::MultipleAnswers do
it_should_behave_like 'Answer Serializers'
let :factory_options do
{
answer_parser_compatibility: true
}
end
let :input do
[ 9761 ]
end
let :output do
{
"question_5_answer_9761" => "1",
"question_5_answer_3079" => "0",
"question_5_answer_5194" => "0",
"question_5_answer_166" => "0",
"question_5_answer_4739" => "0",
"question_5_answer_2196" => "0",
"question_5_answer_8982" => "0",
"question_5_answer_9701" => "0",
"question_5_answer_7381" => "0"
}.with_indifferent_access
end
# for auto specs
def format(value)
[ value ]
end
context 'validations' do
it_should_behave_like 'Id Answer Serializers'
it 'should reject unexpected types' do
[ nil, 'asdf' ].each do |bad_input|
rc = subject.serialize(bad_input)
rc.error.should_not be_nil
rc.error.should match(/of type array/i)
end
end
end
end

View File

@ -0,0 +1,21 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::MultipleChoice do
it_should_behave_like 'Answer Serializers'
let :input do
2405
end
let :output do
{
question_5: "2405"
}.with_indifferent_access
end
context 'validations' do
it_should_behave_like 'Id Answer Serializers'
end
end

View File

@ -0,0 +1,36 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/id_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::MultipleDropdowns do
it_should_behave_like 'Answer Serializers'
let :input do
{
structure1: 4390,
event2: 599
}.with_indifferent_access
end
let :output do
{
"question_5_#{AssessmentQuestion.variable_id 'structure1'}" => "4390",
"question_5_#{AssessmentQuestion.variable_id 'event2'}" => "599"
}.with_indifferent_access
end
# for auto specs
def format(value)
{ structure1: value }
end
context 'validations' do
it_should_behave_like 'Id Answer Serializers'
it 'should reject an answer for an unknown blank' do
rc = subject.serialize({ foobar: 123456 })
rc.error.should_not be_nil
rc.error.should match(/unknown blank/i)
end
end
end

View File

@ -0,0 +1,39 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::Numerical do
it_should_behave_like 'Answer Serializers'
let :inputs do
[ 25.3, 25e-6, '0.12', '3' ]
end
let :outputs do
[
{ question_5: "25.3" }.with_indifferent_access,
{ question_5: "0.000025" }.with_indifferent_access,
{ question_5: "0.12" }.with_indifferent_access,
{ question_5: "3.0" }.with_indifferent_access
]
end
def sanitize(value)
QuizQuestion::AnswerSerializers::Util.to_decimal value
end
it 'should return nil when un-answered' do
subject.deserialize({}).should == nil
end
context 'validations' do
it 'should turn garbage into 0.0' do
[ 'foobar', nil, { foo: 'bar' } ].each do |garbage|
rc = subject.serialize(garbage)
rc.error.should be_nil
rc.answer.should == {
question_5: "0.0"
}.with_indifferent_access
end
end
end
end

View File

@ -0,0 +1,31 @@
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/answer_serializers_specs.rb')
require File.expand_path(File.dirname(__FILE__) + '/support/textual_answer_serializers_specs.rb')
describe QuizQuestion::AnswerSerializers::ShortAnswer do
it_should_behave_like 'Answer Serializers'
let :input do
'hello world!'
end
let :output do
{
question_5: 'hello world!'
}.with_indifferent_access
end
it 'should return nil when un-answered' do
subject.deserialize({}).should == nil
end
it 'should degracefully sanitize its text' do
subject.serialize('Hello World!').answer.should == {
question_5: 'hello world!'
}.with_indifferent_access
end
context 'validations' do
it_should_behave_like 'Textual Answer Serializers'
end
end

View File

@ -0,0 +1,78 @@
shared_examples_for 'Answer Serializers' do
# A QuizQuestion of the type the AnswerSerializer deals with.
#
# Exports 'qq'.
#
# To pass options to the factory, define a mock called `factory_options`
# somewhere in the test suite. For example:
#
# let :factory_options do
# {
# answer_parser_compatibility: true
# }
# end
let(:qq) do
question_type = self.class.described_class.question_type
factory = method(:"#{question_type}_question_data")
options = respond_to?(:factory_options) ? factory_options : {}
# can't test for #arity directly since it might be an optional parameter
data = factory.parameters.include?([ :opt, :options ]) ?
factory.call(options) :
factory.call
# we'll manually assign an ID of 5 so that we won't have to use variables
# like "#{question_id}" all over the place, a readability thing that's all
question_id = data[:id] = 5
# so, we could just build a new Quiz, but it's unnecessary and this is
# faster as we only need the #quiz_data set
quiz = Object.new
quiz.stubs(:quiz_data).returns [ data ]
qq = QuizQuestion.new
qq.id = question_id
qq.question_data = data
qq.stubs(:quiz).returns quiz
qq
end
# An AnswerSerializer for the QuizQuestion being tested.
subject { described_class.new qq }
context 'serialization' do
before :each do
if !respond_to?(:input) && !respond_to?(:inputs)
raise 'missing :input or :outputs definition'
elsif !respond_to?(:output) && !respond_to?(:outputs)
raise 'missing :output or :outputs definition'
end
@inputs = respond_to?(:inputs) ? inputs : [ input ]
@outputs = respond_to?(:outputs) ? outputs : [ output ]
end
it '[auto] should serialize' do
@inputs.each_with_index do |input, index|
rc = subject.serialize(input)
rc.error.should be_nil
rc.answer.should == @outputs[index]
end
end
it '[auto] should deserialize' do
@outputs.each_with_index do |output, index|
input = @inputs[index]
if respond_to?(:sanitize)
input = sanitize(input)
end
out = subject.deserialize(output)
out.should == input
end
end
end
end

View File

@ -0,0 +1,29 @@
shared_examples_for 'Id Answer Serializers' do
it '[auto] should reject an unknown answer ID' do
input = 12321
input = format(input) if respond_to?(:format)
rc = subject.serialize(input)
rc.error.should_not be_nil
rc.error.should match(/unknown answer/i)
end
it '[auto] should accept a string answer ID' do
input = '12321'
input = format(input) if respond_to?(:format)
rc = subject.serialize(input)
rc.error.should_not be_nil
rc.error.should match(/unknown answer/i)
end
it '[auto] should reject a bad answer ID' do
[ nil, [], {} ].each do |bad_input|
bad_input = format(bad_input) if respond_to?(:format)
rc = subject.serialize(bad_input)
rc.error.should_not be_nil
rc.error.should match(/must be of type integer/i)
end
end
end

View File

@ -0,0 +1,22 @@
shared_examples_for 'Textual Answer Serializers' do
MaxLength = QuizQuestion::AnswerSerializers::Util::MaxTextualAnswerLength
it '[auto] should reject an answer that is too long' do
input = 'a' * (MaxLength+1)
input = format(input) if respond_to?(:format)
rc = subject.serialize(input)
rc.valid?.should be_false
rc.error.should match(/too long/i)
end
it '[auto] should reject a textual answer that is not a String' do
[ nil, [], {} ].each do |bad_input|
bad_input = format(bad_input) if respond_to?(:format)
rc = subject.serialize(bad_input)
rc.valid?.should be_false
rc.error.should match(/must be of type string/i)
end
end
end

View File

@ -50,6 +50,12 @@ shared_examples_for 'Takeable Quiz Services' do
expect { service_action.call }.to_not raise_error
end
it '[transient:CNVS-10224] it should reject CGB-OQAAT quiz requests' do
quiz.stubs(:cant_go_back).returns true
expect { service_action.call }.to raise_error(ApiError, /not supported/i)
end
end
describe QuizSubmissionService do
@ -201,14 +207,6 @@ describe QuizSubmissionService do
subject.complete qs, qs.attempt
end.to raise_error(ApiError, /already complete/)
end
it 'should require the QS to be untaken' do
qs.workflow_state = 'complete'
expect do
subject.complete qs, qs.attempt
end.to raise_error(ApiError, /already complete/)
end
end
context 'as someone else' do
@ -223,6 +221,77 @@ describe QuizSubmissionService do
end
end
describe '#update_question' do
let :qs do
qs = QuizSubmission.new
qs.attempt = 1
qs.quiz = quiz
qs.stubs(:completed?).returns false
qs.stubs(:backup_submission_data)
qs
end
context 'as the participant' do
before :each do
quiz.stubs(:grants_right?).returns true
end
let :service_action do
lambda { |*_| subject.update_question({}, qs, qs.attempt) }
end
it_should_behave_like 'Takeable Quiz Services'
it 'should update a question' do
expect do
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
end.to_not raise_error
end
it 'should reject when the QS is complete' do
qs.stubs(:completed?).returns true
expect do
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
end.to raise_error(ApiError, /already complete/)
end
it 'should reject when the QS is overdue' do
qs.stubs(:overdue?).returns true
expect do
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
end.to raise_error(ApiError, /is overdue/)
end
it 'should reject an invalid attempt' do
expect do
subject.update_question({ question_5_marked: true }, qs, qs.attempt-1)
end.to raise_error(ApiError, /attempt \d can not be modified/)
end
it 'should reject an invalid validation_token' do
qs.validation_token = 'yep'
participant.validation_token = 'nope'
expect do
subject.update_question({ question_5_marked: true }, qs, qs.attempt)
end.to raise_error(ApiError, /invalid token/)
end
end
context 'as someone else' do
it 'should deny access' do
quiz.context = Course.new
participant.user = nil
expect do
subject.update_question({}, qs, qs.attempt)
end.to raise_error(ApiError, /you are not allowed to update questions/i)
end
end
end
describe '#update_scores' do
let :qs do
qs = QuizSubmission.new