You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ode.apache.org by as...@apache.org on 2007/11/28 02:23:09 UTC

svn commit: r598840 - in /ode/sandbox/singleshot: ./ app/controllers/ app/models/ config/ db/ db/migrate/ doc/pages/ lib/tasks/ log/ spec/ spec/controllers/ spec/models/

Author: assaf
Date: Tue Nov 27 17:23:07 2007
New Revision: 598840

URL: http://svn.apache.org/viewvc?rev=598840&view=rev
Log:
Allowing all fields to be changed, added test cases for cancellation and excluded owners

Added:
    ode/sandbox/singleshot/log/   (with props)
Modified:
    ode/sandbox/singleshot/   (props changed)
    ode/sandbox/singleshot/app/controllers/application.rb
    ode/sandbox/singleshot/app/controllers/tasks_controller.rb
    ode/sandbox/singleshot/app/models/task.rb
    ode/sandbox/singleshot/config/environment.rb
    ode/sandbox/singleshot/config/routes.rb
    ode/sandbox/singleshot/db/   (props changed)
    ode/sandbox/singleshot/db/migrate/002_create_tasks.rb
    ode/sandbox/singleshot/doc/pages/managing_tasks.textile
    ode/sandbox/singleshot/lib/tasks/database.rake
    ode/sandbox/singleshot/spec/common.rb
    ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb
    ode/sandbox/singleshot/spec/models/task_spec.rb

Propchange: ode/sandbox/singleshot/
------------------------------------------------------------------------------
--- svn:ignore (added)
+++ svn:ignore Tue Nov 27 17:23:07 2007
@@ -0,0 +1 @@
+tmp

Modified: ode/sandbox/singleshot/app/controllers/application.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/controllers/application.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/controllers/application.rb (original)
+++ ode/sandbox/singleshot/app/controllers/application.rb Tue Nov 27 17:23:07 2007
@@ -14,6 +14,7 @@
 
   # Returns Person object for currently authenticated user.
   attr_reader :authenticated
+  helper_method :authenticated
 
   def authenticate
     # Always allow HTTP Basic Authentication, regardless of the format, but

Modified: ode/sandbox/singleshot/app/controllers/tasks_controller.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/controllers/tasks_controller.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/controllers/tasks_controller.rb (original)
+++ ode/sandbox/singleshot/app/controllers/tasks_controller.rb Tue Nov 27 17:23:07 2007
@@ -1,7 +1,8 @@
 class TasksController < ApplicationController
 
   before_filter :authenticate
-  before_filter :find_task, :only=>[:show]
+  verify :params=>:task, :only=>[:create, :update, :complete], :render=>{:text=>'Missing task', :status=>:bad_request}
+  before_filter :find_task, :only=>[:show, :update, :complete, :destroy]
 
   def new
     @task = Task.new(:creator=>authenticated)
@@ -9,7 +10,10 @@
   end
 
   def create
-    @task = Task.create(params[:task])
+    # Outcome MIME type depends on request content type.
+    outcome_type = Task::OUTCOME_MIME_TYPES.include?(request.content_type) ? request.content_type : Mime::XML
+    admins = (params[:task][:admins] || []) << authenticated
+    @task = Task.create!(params[:task].merge(:outcome_type=>outcome_type, :admins=>admins))
     respond_to do |format|
       format.html { redirect_to tasks_url }
       format.xml  { render :xml=>@task, :location=>task_url(@task), :status=>:created }
@@ -17,11 +21,68 @@
     end
   end
 
+  def show
+    respond_to do |format|
+      format.html
+      format.xml  { render :xml=>@task }
+      format.json { render :json=>@task }
+    end
+  end
+
+  def update
+    @task.update_attributes! filter_update(params[:task])
+    respond_to do |format|
+      format.html { redirect_to :back }
+      format.xml  { render :xml=>@task }
+      format.json { render :json=>@task }
+    end
+  end
+
+  def complete
+    if @task.complete!(authenticated, params[:task][:data])
+      respond_to do |format|
+        format.html { redirect_to tasks_url }
+        format.xml  { render :xml=>@task }
+        format.json { render :json=>@task }
+      end 
+    else
+      error = 'You are not allowed to complete this task'
+      respond_to do |format|
+        format.html do
+          flash[:error] = error
+          redirect_to :back rescue redirect_to root_url
+        end
+        format.all { render :text=>error, :status=>:forbidden }
+      end 
+    end
+  end
+
+  def destroy
+    if @task.cancel!(authenticated)
+      respond_to do |format|
+        format.html { redirect_to tasks_url }
+        format.all  { head :ok }
+      end 
+    else
+      error = 'You are not allowed to cancel this task'
+      respond_to do |format|
+        format.html do
+          flash[:error] = error
+          redirect_to :back rescue redirect_to root_url
+        end
+        format.all { render :text=>error, :status=>:forbidden }
+      end 
+    end
+  end
+
 private
 
   def find_task
     @task = Task.find(params[:id])
+    # Must be stakeholder to access the task, send 404 if the task does not 'exist' for this person.
     raise ActiveRecord::RecordNotFound unless @task.stakeholder?(authenticated)
+    # Cancelled tasks exist in their own resource.
+    raise ActiveRecord::RecordNotFound if @task.cancelled?
   end
 
 end

Modified: ode/sandbox/singleshot/app/models/task.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/app/models/task.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/app/models/task.rb (original)
+++ ode/sandbox/singleshot/app/models/task.rb Tue Nov 27 17:23:07 2007
@@ -3,18 +3,21 @@
 #
 # Table name: tasks
 #
-#  id                :integer(11)   not null, primary key
-#  title             :string(255)   default(""), not null
-#  priority          :integer(1)    default(1), not null
-#  due_on            :date          
-#  status            :integer(2)    default(0), not null
-#  completion_url    :string(255)   
-#  completion_format :string(255)   
-#  access_key        :string(32)    
-#  data              :text          default(""), not null
-#  version           :integer(11)   default(0), not null
-#  created_at        :datetime      
-#  updated_at        :datetime      
+#  id           :integer(11)   not null, primary key
+#  title        :string(255)   default(""), not null
+#  priority     :integer(1)    default(1), not null
+#  due_on       :date          
+#  status       :integer(2)    default(0), not null
+#  form_url     :string(255)   
+#  view_url     :string(255)   
+#  outcome_url  :string(255)   
+#  outcome_type :string(255)   
+#  cancellation :integer(1)    not null
+#  access_key   :string(32)    default(""), not null
+#  data         :text          default(""), not null
+#  version      :integer(11)   default(0), not null
+#  created_at   :datetime      
+#  updated_at   :datetime      
 #
 
 require 'openssl'
@@ -27,7 +30,7 @@
 
   PRIORITIES = 1..5
 
-  def initialize(*args)
+  def initialize(attributes = nil)
     super
     self.status = :active
     self.access_key = MD5.hexdigest(OpenSSL::Random.random_bytes(128))
@@ -37,6 +40,12 @@
     "#{id}-#{title.gsub(/[^\w]+/, '-').gsub(/-*$/, '')}"
   end
 
+  # Returns an ETag that can identify changes to the task state.  No two tasks will have
+  # the same ETag.  Changing the task state will also change its ETag.
+  def etag
+    MD5.hexdigest("#{id}:#{version}")
+  end
+
 
   # --- Task state ---
 
@@ -53,41 +62,55 @@
 
   # -- View and perform ---
 
-  validates_presence_of :perform_url, :message=>'Perform URL required'
-  validates_url :perform_url
+  validates_url :form_url, :if=>:form_url
   validates_url :view_url, :if=>:view_url
 
 
   # --- Completion and cancellation ---
 
-  # Supported formats for sending completion notification.
-  COMPLETION_FORMATS = [Mime::JSON, Mime::XML]
+  validates_url :outcome_url, :if=>:outcome_url
 
-  validates_inclusion_of :completion_format, :in=>COMPLETION_FORMATS,
-    :message=>"Supported completion formats are #{COMPLETION_FORMATS.to_sentence}"
+  # Supported formats for updating the outcome resource.
+  OUTCOME_MIME_TYPES = [Mime::JSON, Mime::XML]
 
-  validates_presence_of :completion_url, :message=>'Completion URL required'
-  validates_url :completion_url
-
-  # Returns the completion format as a Mime type.
-  def completion_format
-    Mime::EXTENSION_LOOKUP[self[:completion_format]] || Mime::XML
-  end
-
-  # Sets the completion format using a Mime type.
-  def completion_format=(content_type)
-    case content_type
-    when Mime::Type     # Mime::JSON
-      self[:completion_format] = content_type.to_sym.to_s
-    when /\//           # application/json
-      self[:completion_format] = Mime::LOOKUP[content_type].to_sym.to_s
-    when Symbol, String # json, JSON, :json
-      self[:completion_format] = content_type.to_s.downcase
-    else
-      raise ArgumentError, 'Expecting content type, extension or Mime::Type'
+  validates_inclusion_of :outcome_type, :in=>OUTCOME_MIME_TYPES, :if=>:outcome_url,
+    :message=>"Supported MIME types are #{OUTCOME_MIME_TYPES.to_sentence}"
+  before_validation { |record| record.outcome_type ||= Mime::XML if record.outcome_url }
+  before_save { |record| record.outcome_type = nil unless record.outcome_url }
+
+  # Returns the outcome content type.
+  def outcome_type
+    Mime::LOOKUP[self[:outcome_type]]
+  end
+
+  # Sets the outcome content type.
+  def outcome_type=(mime_type)
+    self[:outcome_type] = case mime_type
+      when Mime::Type;     mime_type.to_s
+      when /^\w+\/\w+$/;   mime_type.downcase
+      when Symbol, String; Mime::EXTENSION_LOOKUP[mime_type.to_s.downcase].to_s
+      when nil;            nil
+      else raise ArgumentError, 'Unsupported MIME type #{mime_type}'
     end
   end
 
+  def complete!(person, data = nil)
+    return false unless can_complete?(person)
+    self.status = :completed
+    self.data = data if data
+    # TODO: Update outcome, observers
+    save!
+  end
+
+
+
+  def cancel!(person)
+    return false unless can_cancel?(person)
+    self.status = :cancelled
+    # TODO: Update outcome, observers
+    save!
+  end
+
 
   # --- Task data ---
 
@@ -113,7 +136,7 @@
 
   # Stakeholders and people (as stakeholders) associated with this task.
   has_many :stakeholders, :include=>:person, :dependent=>:delete_all
-  has_many :people, :through=>:stakeholders
+  attr_protected :stakeholders
  
   class << self
 
@@ -127,6 +150,12 @@
     def admins=(admins)
       @admins = Array(admins)
     end
+
+    # Returns true if this person is an administrator on all tasks.
+    def admin?(person)
+      admins.include?(person)
+    end
+
   end
 
   # :call-seq:
@@ -151,7 +180,12 @@
   # Returns true if given person is associated with this task in a particular role.
   # You can also query against a set of roles, or any roles by passing :any.
   def in_role?(person, *roles)
-    in_role(roles).include?(person)
+    roles = roles.flatten
+    if roles.include?(:any)
+      stakeholders.any? { |sh| sh.person_id == person.id }
+    else
+      stakeholders.any? { |sh| roles.include?(sh.role) && sh.person_id == person.id }
+    end
   end
   private :in_role?
 
@@ -160,7 +194,7 @@
   #
   # Returns all the roles this person is associated with.
   def roles(person)
-    stakeholders.select { |sh| sh.person == person }.map { |sh| sh.role }.uniq
+    stakeholders.select { |sh| sh.person.id == person.id }.map { |sh| sh.role }.uniq
   end
   private :roles
 
@@ -209,8 +243,9 @@
   end
 
   # Returns true if person is a stakeholder in this task (equivalent to asking if in any role).
+  # Includes all global administrators but excludes excluded owners.
   def stakeholder?(person)
-    stakeholders.any? { |sh| sh.person == person } || Task.admins.include?(person)
+    stakeholders.any? { |sh| sh.person.id == person.id && sh.role != :excluded_owner } || Task.admin?(person)
   end
 
   # Slightly different from Person.identify.  If Person.identify does not find the person,
@@ -232,6 +267,40 @@
 
 
   # --- Access control ---
+
+  enumerable :cancellation, [:admin, :owner], :default=>:admin
+
+  # Returns true if this person can cancel this task.
+  def can_cancel?(person)
+    return false if completed?
+    return true if admin?(person) || Task.admin?(person)
+    case cancellation
+    when :owner
+      return true if owner?(person)
+    end
+    false
+  end
+
+  # Returns true if this person can complete this task.
+  def can_complete?(person)
+    active? && owner?(person)
+  end
+
+  def filter_update(person, values)
+    if admin?(person)
+      # Administrator allowed to change all unprotected attributes.
+      values
+    elsif owner?(person)
+      # Owner allowed to change task data and assign to someone else.
+      if new_owner = values[:owner]
+        return nil unless potential_owner?(new_owner) && !excluded_owner?(new_owner)
+      end
+      values.slice(:data, :owner, :version)
+    elsif potential_owner?(person) && owner.nil? && values(:owner) == person.to_param
+      # Potential owner only allowed to claim task if not already assigned.
+      values.slice(:owner, :version)
+    end
+  end
 
   attr_protected :access_key
 

Modified: ode/sandbox/singleshot/config/environment.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/config/environment.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/config/environment.rb (original)
+++ ode/sandbox/singleshot/config/environment.rb Tue Nov 27 17:23:07 2007
@@ -31,6 +31,7 @@
   # If you change this key, all old sessions will become invalid!
   config.action_controller.session = {
     :session_key => '_single_shot_session',
+    # NOTE: This is publicly accessible, change before deploying!
     :secret      => %{ 37°20'12.78"N, 121°53'41.69"W}
   }
 

Modified: ode/sandbox/singleshot/config/routes.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/config/routes.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/config/routes.rb (original)
+++ ode/sandbox/singleshot/config/routes.rb Tue Nov 27 17:23:07 2007
@@ -2,7 +2,8 @@
 
   map.resource :session
   map.resources :tasks
-  map.connect '/tasks/:id', :controller=>'tasks', :action=>'complete', :conditions=>{ :method=>:post }
+  map.connect '/tasks/:id', :controller=>'tasks', :action=>'complete', :id=>/\d+/, :conditions=>{ :method=>:post }
+  map.connect '/tasks/:id.format', :controller=>'tasks', :action=>'complete', :id=>/\d+/, :conditions=>{ :method=>:post }
   map.root :controller=>'tasks'
 
   # Authentication resource (create/destroy).

Propchange: ode/sandbox/singleshot/db/
------------------------------------------------------------------------------
--- svn:ignore (added)
+++ svn:ignore Tue Nov 27 17:23:07 2007
@@ -0,0 +1 @@
+schema.rb

Modified: ode/sandbox/singleshot/db/migrate/002_create_tasks.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/db/migrate/002_create_tasks.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/db/migrate/002_create_tasks.rb (original)
+++ ode/sandbox/singleshot/db/migrate/002_create_tasks.rb Tue Nov 27 17:23:07 2007
@@ -1,17 +1,18 @@
 class CreateTasks < ActiveRecord::Migration
   def self.up
     create_table :tasks do |t|
-      t.string    :title,             :null=>false
-      t.integer   :priority,          :null=>false, :default=>1, :limit=>1
-      t.date      :due_on,            :null=>true
-      t.integer   :status,            :null=>false, :default=>0, :limit=>2
-      t.string    :perform_url,       :null=>false
-      t.string    :view_url,          :null=>true
-      t.string    :completion_url,    :null=>true
-      t.string    :completion_format, :null=>true
-      t.string    :access_key,        :null=>true, :limit=>32
-      t.text      :data,              :null=>false
-      t.integer   :version,           :null=>false, :default=>0
+      t.string    :title,        :null=>false
+      t.integer   :priority,     :null=>false, :default=>1, :limit=>1
+      t.date      :due_on,       :null=>true
+      t.integer   :status,       :null=>false, :default=>0, :limit=>2
+      t.string    :form_url,     :null=>true
+      t.string    :view_url,     :null=>true
+      t.string    :outcome_url,  :null=>true
+      t.string    :outcome_type, :null=>true
+      t.integer   :cancellation, :null=>false, :limit=>1
+      t.string    :access_key,   :null=>false, :limit=>32
+      t.text      :data,         :null=>false
+      t.integer   :version,      :null=>false, :default=>0
       t.timestamps
     end
     add_index :tasks, [:status, :updated_at]

Modified: ode/sandbox/singleshot/doc/pages/managing_tasks.textile
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/doc/pages/managing_tasks.textile?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/doc/pages/managing_tasks.textile (original)
+++ ode/sandbox/singleshot/doc/pages/managing_tasks.textile Tue Nov 27 17:23:07 2007
@@ -2,24 +2,27 @@
 
 h2.  Creating a Task
 
-To create a task, the application authenticates and makes a POST request against the resource @/tasks@.  If successful, the task manager returns the status code 201 (Created), a representation of the task in the specified content type, and sets the @Location@ header to the URL of the new task resource.
+To create a new task, the application authenticates and sends a POST request to the resource @/tasks@.  The task manager returns one of the following responses:
 
-The application can make the request in any of the supported content types (see "Request Content Types":#Request-Content-Types), using the following fields:
+* *201 (Created)* -- Includes a representation of the task in the selected content type, and the task URL in the @Location@ header.
+* *400 (Bad Request)* -- Request missing the @<task>@ element (XML) or JSON object.
+* *422 (Unprocessable Entity)* -- Indicates an invalid request, for example, person that does not exist, priority out of range.  The response provides more details.
 
-* @title@ --The task title as it will show up in the tasks list.  The task title is assumed to be text, UTF-8 accepted.
-* @completion_url@ -- When the task completes, the task manager notifies the application by making a POST request to this URL.  You can use HTTP or HTTPS (recommended), and can include authority information for HTTP authentication.
-* @completion_format@ -- The completion notification is sent using either @application/xml@ or @application/json@ content types.  The task manager can infer the completion format from the request content type, defaulting to @application/xml@ for all other content types.  Setting this field forces a particular content type.
-* @perform_url@ -- A URL used for performing the task (see Presenting Tasks).
-* @view_url@ -- A URL used for viewing the task (see Presenting Tasks), only necessary if different form the URL used for performing the task.
-* @priority@ -- The task priority as a value from 1 to 5, 5 being the highest priority.
-* name/tag/labels
-* @due_on@ -- If the task has a due-on date, this specifies the date/time using the ISO8601 format.
+The application can make the request in any of the supported content types (see "Content Types":#Content-Types), using the following fields:
+
+* @admins@ -- List of administrators associated with the task.  You can use this to associate specific administrators on a task, beyond those allowed to administer all tasks.  The account used to create the task is automatically added as an administrator on the task.
+* @cancellation@ -- Indicates which roles are able to cancel the task.
 * @creator@ -- When creating the task on behalf of a person, use this to specify the creator's identity.  The creator of the task is separate from the account used to create the task, the later typically belonging to the application.
+* @data@ -- Each task can hold an arbitrary set of name/value pairs which are passed between the application and task manager, made available when viewing/performing the task, and passed back to the application on completion.
+* @due_on@ -- If the task has a due-on date, this specifies the date/time using the ISO8601 format.
+* @excluded_owners@ -- List of people who are not allowed to claim the task, typically owners of previously completed tasks.  People listed as both potential and excluded owners are not allowed to claim the task.
+* @form_url@ -- URL for the form represented by the task.
+* @outcome_url@ -- When the task completes, the task manager updates the outcome resource; it deletes the outcome resource if the task is cancelled.
 * @owner@ -- Specify this to immediately assign the task to a particular person.  Otherwise, the task is left available and any potential owner can claim it.
 * @potential_owners@ -- List of potential owners that can claim the task.
-* @excluded_owners@ -- List of people who are not allowed to claim the task, typically owners of previously completed tasks.  People listed as both potential and excluded owners are not allowed to claim the task.
-* @admins@ -- List of administrators associated with the task.  You can use this to associate specific administrators on a task, beyond those allowed to administer all tasks.  The account used to create the task is automatically added as an administrator on the task.
-* @data@ -- Each task can hold an arbitrary set of name/value pairs which are passed between the application and task manager, made available when viewing/performing the task, and passed back to the application on completion.
+* @priority@ -- The task priority as a value from 1 to 5, 5 being the highest priority.
+* @title@ --The task title as it will show up in the tasks list.  The task title is assumed to be text, UTF-8 accepted.
+* @view_url@ -- URL for viewing the task, alternative to @form_url@.
 
 When specifying a singular stakeholder (e.g. creator, owner), use the form of identity associated with that person, currently their nickname.  When specifying a plural stakeholder (e.g. potential owners, admins), use an array of identities.  Either way, the person referenced as a stakeholder must exist in the system with that identity.
 
@@ -42,29 +45,130 @@
   task[data][order]=123&task[data][status]=open
 
 
-h2.  Request Content Types
+h3.  Creating a Task Exactly Once
+
+1.  POST to create a new task resource.
+2.  Save the resource URL and ETag.
+3.  PUT to update the task resource using using the ETag.
+
+You can repeat the last step any number of times, for example, after recovery from failure.  By setting the @If-Match@ header to the value of the @ETag@ header returned in step 2, you are guaranteed to only change the task from its original state.  Once the task changed from its initial state, the request will return 412 (Precondition Failed).
+
+The first two steps can be repeated any number of times, in case of failure, leading to redundant tasks, but tasks that are never performed and could safely be purged at a later time.
+
+
+h2.  Controlling the Task
+
+Once the application created the task resource, it can use that URL to read and update the task state, and also to cancel the task.  The application can do so by virtue of being an administrator on the task, so it must use the same credentials it originally used to create the task.
+
+
+h3.  Reading
+
+To read the task state, the application authenticates and sends a GET request to the task resource.  The task manager returns one of the following responses:
+
+* *200 (OK)* -- A representation of the task in the requested content type.
+* *404 (Not Found)* -- The task does not exist, has been cancelled, or the authenticated account is now allowed to view this task.
+
+The task representation includes the following fields:
+
+* @id@ -- The task identifier.
+* @url@ -- URL for the task resource (this is used primarily for collections).
+* @status@ -- The task status, one of @active@, @suspended@, @completed@ or @cancelled@.
+* @title@ --The task title.
+* @priority@ -- The task priority.
+* @due_on@ -- The task due-on date, if specified.
+* @creator@ -- The task creator, if specified.
+* @owner@ -- The task owner, if the task is assigned.
+* @potential_owners@ -- List of potential owners associated with this task.
+* @admins@ -- List of administrators associated with this task.
+* @data@ -- Name/value pairs.
+* @version@ -- Version number, incremented on each update.
+* @created_at@ -- Timestamp set at creation.
+* @updated_at@ -- Timestamp of the last update (or creation).
+
+Each change to the task state increments the @version@ number and updates the @updated_at@ timestamp.  You can use these fields to detect changes in the task state.  You can also use the @ETag@ and @Last-Modified@ headers to perform conditional GET and PUT.
+
 
-Singleshot supports requests in four content types: JSON, XML, URL encoded and multi-part.
+h3. Updating
 
-*application/json*  The request is formatted as a JSON object, using arrays and primitive types where applicable.  Dates should be formatted as ISO-8601. 
+To update the task, the application authenticates and sends a PUT request to the task resource.  The task manager returns one of the following responses:
 
-*application/xml*  The request is formatted as an XML document using the applicable document element, for example, @<task>@ to represent a task.  Element names use dashes instead of underscore, for example, @due_on@ becomes @<due-on>@.  The value of each field comes from the element content, and the @type@ attribute specifies its primitive type.  The following primitive types are supported:
+* *200 (OK)* -- Indicates successful update and returns a new representation of the task in the requested content type.
+* *403 (Forbidden)* -- This account is not allowed to change the task, or change a particular field in the task (for example, owners are not allowed to change the task title).
+* *404 (Not Found)* -- The task does not exist, has been cancelled, or the authenticated account is now allowed to view this task.
+* *400 (Bad Request)* -- Request missing the @<task>@ element (XML) or JSON object.
+* *422 (Unprocessable Entity)* -- Indicates an invalid request, for example, setting the owner to a non-existent person.  The response provides more details.
+* *412 (Percondition Failed)* -- Used with conditional PUT to indicate conflicting update.
 
-* @date@ -- Date instance.
-* @datetime@ -- Date/time instance, using the XML Schema format (a restricted form of ISO-8601).
-* @integer@ -- Integer value.
+Updates can specify all the fields provided at task creation.  Updates can send additional fields provided in the task representation, such as @id@ and @url@, but these are ignored.  Which fields get updated depends on the role of the authenticated account, specifically:
+
+* Administrators can change all fields in the task state.
+* Owners can change the task data, and either assign the task to another potential owner, or release the task by setting the owner to nil.
+* Potential owners can assign the task to themselves if the task has no current owner.
+
+To change a particular field, the request must include a new value for that field.  Fields that do not appear in the request, do not change.  To remove the due date or current owner, pass an empty string for the value of that field.  To delete plural fields, such as @potential_owners@, pass an empty array.
+
+
+h3. Cancelling
+
+To cancel the task, the application authenticates and sends a DELETE request to the task resource.  The task manager returns one of the following responses:
+
+* *200 (OK)* -- Indicates the task has been successfull cancelled.
+* *403 (Forbidden)* -- This account is not allowed to cancel the task.
+* *404 (Not Found)* -- The task does not exist, has already been cancelled, or the authenticated account is now allowed to view this task.
+
+Cancelling the task depends on the cancellation policy:
+
+* @admin@ -- Only administrators can cancel the task (default).
+* @owner@ -- Task owner can cancel the task.
+
+
+h2.  Task Outcome
+
+The application can determine the task outcome in three ways.  First, by retrieving the task state and waiting for the task to tranisition into the @completed@ status, or a 404 indicating the task has been cancelled (deleted).  Second, by retriving a list of recently updated tasks and inspecting their state.  This form of update feeds, which also include dedicated feeds for completed and cancelled tasks, are described in the next section.
+
+The application can also create an *outcome resource* and wait for the task manager to update it.  Before creating the task, the application establishes a resource that represents the task outcome.  It passes the resource URL in the @outcome_url@ field, when creating the task.
+
+When the task completes, the task manager updates the outcome resource and puts the final representation of the task in there.  If the task was created using the content type @application/xml@ or @application/json@, that same content type is used to update the outcome resource.
+
+If the task is cancelled, the task manager discards the outcome resource by deleting it.  The task manager does this even if the application itself cancelled the task.
+
+To deal with network failures, the task manager repeats the request until it gets a satisfactory outcome.  It considers any 2xx status code, 404, 410 or 412 to indicate successful update.  (412 is accepted since the PUT is conditional, to allow once-only update)
+
+The @outcome_url@ field specifies an HTTP or HTTPS (recommended) URL.  If the URL includes the authority component, the request uses HTTP authentication (Basic).  For example:
+
+  https://token:3032ad6aed6c5c3cda992d241f4d28bf@app.example.com/outcome/123
+
+
+h2.  Content Types
+
+Singleshot supports requests in four content types: JSON, XML, URL-encoded and multi-part.  Responses support JSON, XML, Atom and iCal.
+
+*application/json*  The request is formatted as a JSON object, using arrays and primitive types where applicable.  Use ISO-8601 for date and date/time values.  The same rules apply to responses containing @application/json@.
+
+*application/xml*  The request is formatted as an XML document using the applicable document element, for example, @<task>@ to represent a task.  Element names use dashes instead of underscore, for example, the field @due_on@ becomes the element @<due-on>@.  Simple value use elements, carrying the value in the element contents.  The @type@ attribute specifies the content type.
+
+The following simple types are supported: 
+
+* @date@ -- Date instance, using XML Schema format.
+* @datetime@ -- Date/time instance, using the XML Schema format.
+* @integer@ -- Signed integer.
 * @float@ -- Numeric float.
 * @boolean@ -- Either @true@ or @false@.
-* @string@ -- Not using the @type@ attribute is also interpreted as a string.
+* @string@ -- Optional, elements without @type@ are interpreted as strings.
 * @base64Binary@ -- Base-64 encoded binary content.
 
-Arrays are specified using a plural element with the type @array@, containing one singular element for each item, for example:
+Arrays are specified using a plural element with the type @array@, and one singular element for each array item.  For example:
+
+  <admins type='array'>
+    <admin>assaf</admin>
+    <admin>alex</admin>
+  </admins>
+
+The same rules apply to responses containing @application/xml@.
+
+*application/x-www-form-urlencoded* and *multipart/form-data*  Control names (aka form fields) use a specific naming convention to map them into hashes and arrays.  Brackets are used to denote entry in a hash, for example, @task[priority]@ represents the priority of a task.  Empty brackets as a suffix denote an array, for example, @task[admins][]@.  Use multiple controls, one for each array item.
 
-  <potential-owners type='array'>
-    <potential-owner>assaf</potential-owner>
-    <potential-owner>alex</potential-owner>
-  </potential-owners>
+*application/atom+xml* TBD
 
-*application/x-www-form-urlencoded* and *multipart/form-data*  Field names use a particular notation that maps into hashes and array.  For example, the priority field of a task is named @task[priority]@.  For arrays, use multiple fields with square brackets as the suffic, for example, @task[stake_holders]@.
+*text/calendar* TBD
 
-UTF-8 encoding is supported for all content types.

Modified: ode/sandbox/singleshot/lib/tasks/database.rake
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/lib/tasks/database.rake?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/lib/tasks/database.rake (original)
+++ ode/sandbox/singleshot/lib/tasks/database.rake Tue Nov 27 17:23:07 2007
@@ -1,7 +1,7 @@
 namespace :db do
 
   desc 'Rebuild the database by running all migrations again'
-  task 'rebuild'=>['environment', 'drop', 'create', 'migrate', 'test:clone']
+  task 'rebuild'=>['environment', 'drop', 'create', 'migrate', 'test:clone', 'annotate_models']
 
 end
 

Propchange: ode/sandbox/singleshot/log/
------------------------------------------------------------------------------
--- svn:ignore (added)
+++ svn:ignore Tue Nov 27 17:23:07 2007
@@ -0,0 +1 @@
+*

Modified: ode/sandbox/singleshot/spec/common.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/common.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/common.rb (original)
+++ ode/sandbox/singleshot/spec/common.rb Tue Nov 27 17:23:07 2007
@@ -5,6 +5,7 @@
     def self.included(mod)
       mod.after :all do
         Person.delete_all
+        Task.admins = nil
       end
     end
 
@@ -15,7 +16,16 @@
     end
 
     def authenticate(person)
+      @authenticated = person
       session[:person_id] = person.id
+      request.headers['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(person.nickname, 'secret')
+    end
+
+    attr_reader :authenticated
+
+    def all_roles
+      singular = Task::SINGULAR_ROLES.inject({}) { |h, role| h.update(role=>person(role)) }
+      Task::PLURAL_ROLES.inject(singular) { |h, role| h.update(role.to_s.pluralize.to_sym=>person(role)) }
     end
 
   end
@@ -31,8 +41,8 @@
     def default_values(merge = nil)
       base = {
         :title=>'Test Task model',
-        :perform_url=>'http://test.host/do',
-        :completion_url=>'http://test.host/complete' }
+        :form_url=>'http://test.host/do',
+        :outcome_url=>'http://test.host/outcome' }
       merge ? base.merge(merge) : base
     end
 

Modified: ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb (original)
+++ ode/sandbox/singleshot/spec/controllers/tasks_controller_spec.rb Tue Nov 27 17:23:07 2007
@@ -7,14 +7,14 @@
 
 describe TasksController, 'authentication' do
 
-  include Specs::Authentication
+  include Specs::Authentication, Specs::Tasks
 
   before :all do
     @person = person('assaf')
   end
   
-  it 'should map to /tasks' do
-    route_for(:controller=>'tasks', :action=>'create').should eql('/tasks')
+  def session_authenticate(person)
+    session[:person_id] = person.id
   end
 
   def http_authenticate(login, password)
@@ -22,48 +22,48 @@
   end
 
   it 'should use session authentication for HTML requests' do
-    post :create
+    post :create, :task=>default_values
     response.should redirect_to(session_url)
   end
 
   it 'should accept session authentication' do
-    authenticate @person
-    Task.should_receive(:create)
-    post :create
+    session_authenticate @person
+    Task.should_receive(:create!)
+    post :create, :task=>default_values
   end
 
   it 'should use HTTP Basic authentication for XML requests' do
-    post :create, :format=>'xml'
+    post :create, :task=>default_values, :format=>'xml'
     response.should be_unauthorized
   end
 
   it 'should use HTTP Basic authentication for JSON requests' do
-    post :create, :format=>'json'
+    post :create, :task=>default_values, :format=>'json'
     response.should be_unauthorized
   end
 
   it 'should use HTTP Basic authentication if authentication header provider' do
     http_authenticate 'assaf', 'wrong'
-    post :create
+    post :create, :task=>default_values
     response.should be_unauthorized
   end
 
   it 'should accept HTTP Basic authentication' do
     http_authenticate 'assaf', 'secret'
-    Task.should_receive(:create)
-    post :create
+    Task.should_receive(:create!)
+    post :create, :task=>default_values
   end
 
   it 'should redirect to login page with return to self' do
-    post :create
+    post :create, :task=>default_values
     response.should redirect_to(session_url)
-    flash[:return_to].should eql('http://test.host/tasks')
+    flash[:return_to].should match(/^http:\/\/test.host\/tasks/)
   end
 
 end
 
 
-describe TasksController, 'create' do
+describe TasksController, 'POST /tasks' do
 
   include Specs::Authentication, Specs::Tasks
 
@@ -71,12 +71,314 @@
     authenticate person('assaf')
   end
 
-  it 'should infer XML as completion_format from accepted content type' do
-    post :create, :format=>'xml'
+  it 'should map to /tasks/' do
+    route_for(:controller=>'tasks', :action=>'create').should eql('/tasks')
+  end
+
+  it 'should err if not task specified' do
+    post :create
+    response.should be_bad_request
+    response.should have_text(/missing task/i)
+  end
+
+  it 'should infer XML as outcome MIME type from accepted content type' do
+    request.headers['CONTENT_TYPE'] = Mime::XML.to_s
+    post :create, :task=>default_values
+    Task.find(:first).outcome_type.should eql(Mime::XML)
+  end
+
+  it 'should infer JSON as outcome MIME type from accepted content type' do
+    request.headers['CONTENT_TYPE'] = Mime::JSON.to_s
+    post :create, :task=>default_values
+    Task.find(:first).outcome_type.should eql(Mime::JSON)
+  end
+
+  it 'should default to XML as outcome MIME type for all other content types' do
+    post :create, :task=>default_values
+    Task.find(:first).outcome_type.should eql(Mime::XML)
+  end
+
+  it 'should ignore outcome MIME type in request' do
+    request.headers['CONTENT_TYPE'] = Mime::JSON.to_s
+    post :create, :task=>default_values.merge(:outcome_type=>'application/xml')
+    Task.find(:first).outcome_type.should eql(Mime::JSON)
+  end
+
+  it 'should add authenticated user as task admin' do
+    Task.should_receive(:create!) do |params|
+      admins = params[:admins]
+      admins.size.should eql(1)
+      admins.should include(person('assaf'))
+    end
+    post :create, :task=>default_values
+  end
+
+  it 'should set authenticated user as task admin' do
+    Task.should_receive(:create!) do |params|
+      admins = params[:admins]
+      admins.size.should eql(2)
+      admins.should include(person('assaf'), 'alex')
+    end
+    post :create, :task=>default_values.merge(:admins=>['alex'])
+  end
+
+end
+
+
+describe TasksController, 'GET /tasks/{id}' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :each do
+    authenticate person('admin')
+    @task = Task.create(default_values.merge(:admins=>authenticated, :owner=>person('owner'),
+      :creator=>person('creator'), :potential_owners=>person('potential')))
+  end
+
+  it 'should map to /tasks/{id}' do
+    route_for(:controller=>'tasks', :action=>'show', :id=>1).should eql('/tasks/1')
+    lambda { route_for(:controller=>'tasks', :action=>'show') }.should raise_error(ActionController::RoutingError)
+  end
+
+  it 'should 404 if task not found' do
+    lambda { get :show, :id=>0 }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 if task already cancelled' do
+    @task.status = :cancelled ; @task.save!
+    lambda { get :show, :id=>@task.id }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 unless allowed to view task' do
+    authenticate person('noone')
+    lambda { get :show, :id=>@task.id }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should not be visible to excluded owners'
+
+end
+
+
+describe TasksController, 'PUT /tasks/{id}' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :each do
+    authenticate person('admin')
+    @task = Task.create(default_values.merge(:admins=>authenticated, :owner=>person('owner'),
+      :creator=>person('creator'), :potential_owners=>person('potential')))
+  end
+
+  it 'should map to /tasks/{id}' do
+    route_for(:controller=>'tasks', :action=>'update', :id=>1).should eql('/tasks/1')
+    lambda { route_for(:controller=>'tasks', :action=>'update') }.should raise_error(ActionController::RoutingError)
+  end
+
+  it 'should 400 if no input provided' do
+    put :update, :id=>0
+    response.should be_bad_request
+  end
+
+  it 'should 404 if task not found' do
+    lambda { put :update, :id=>0, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 if task already cancelled' do
+    @task.status = :cancelled ; @task.save!
+    lambda { put :update, :id=>@task.id, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 unless allowed to view task' do
+    authenticate person('noone')
+    lambda { put :update, :id=>@task.id, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+
+end
+
+
+describe TasksController, 'DELETE /tasks/{id}' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :each do
+    authenticate person('admin')
+    @task = Task.create(default_values.merge(:admins=>authenticated, :owner=>person('owner'),
+      :creator=>person('creator'), :potential_owners=>person('potential')))
+  end
+
+  it 'should map to /tasks/{id}' do
+    route_for(:controller=>'tasks', :action=>'destroy', :id=>1).should eql('/tasks/1')
+    lambda { route_for(:controller=>'tasks', :action=>'destroy') }.should raise_error(ActionController::RoutingError)
+  end
+
+  it 'should 404 if task not found' do
+    lambda { delete :destroy, :id=>0 }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 if task already cancelled' do
+    @task.status = :cancelled ; @task.save!
+    lambda { delete :destroy, :id=>@task.id }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 unless allowed to view task' do
+    authenticate person('noone')
+    lambda { delete :destroy, :id=>@task.id }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 403 if task already completed' do
+    @task.status = :completed ; @task.save!
+    delete :destroy, :id=>@task.id, :format=>'xml'
+    response.should be_forbidden
+  end
+
+  it 'should cancel task if administrator' do
+    delete :destroy, :id=>@task.id
+    Task.find(@task.id).status.should eql(:cancelled)
+  end
+
+  it 'should cancel task if owner and owner allowed to cancel' do
+    authenticate person('owner')
+    @task.cancellation = :owner ; @task.save!
+    delete :destroy, :id=>@task.id
+    Task.find(@task.id).status.should eql(:cancelled)
+  end
+
+  it 'should 403 if owner and owner not allowed to cancel' do
+    authenticate person('owner')
+    delete :destroy, :id=>@task.id
+    Task.find(@task.id).status.should eql(:active)
+  end
+
+  it 'should 403 if potential owner and not anyone allowed to cancel' do
+    authenticate person('potential')
+    delete :destroy, :id=>@task.id
+    Task.find(@task.id).status.should eql(:active)
+  end
+
+  it 'should redirect to tasks list if cancelled and responding with HTML' do
+    delete :destroy, :id=>@task.id
+    response.should redirect_to(tasks_url)
+  end
+
+  it 'should return 200 if cancelled and responding with XML' do
+    delete :destroy, :id=>@task.id, :format=>'xml'
+    response.should be_ok
+  end
+
+  it 'should return 200 if cancelled and responding with JSON' do
+    delete :destroy, :id=>@task.id, :format=>'json'
+    response.should be_ok
+  end
+
+  it 'should redirect back and flash error if cancelled and responding with HTML' do
+    authenticate person('potential')
+    request.env['HTTP_REFERER'] = '/source/page'
+    delete :destroy, :id=>@task.id
+    flash[:error].should match(/not allowed to cancel/i)
+    response.should redirect_to('/source/page')
   end
 
-  it 'should infer JSON as completion_format from accepted content type'
-  it 'should default to XML as completion_format for all other content types'
-  it 'should pass completion_format from request'
+  it 'should return 403 if cancelled and responding with XML' do
+    authenticate person('potential')
+    delete :destroy, :id=>@task.id, :format=>'xml'
+    response.should be_forbidden
+  end
+
+  it 'should return 403 if cancelled and responding with JSON' do
+    authenticate person('potential')
+    delete :destroy, :id=>@task.id, :format=>'json'
+    response.should be_forbidden
+  end
+
+end
+
+
+describe TasksController, 'POST /tasks/{id}' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :each do
+    authenticate person('owner')
+    @task = Task.create(default_values.merge(:admins=>person('admin'), :owner=>authenticated,
+      :creator=>person('creator'), :potential_owners=>person('potential')))
+  end
+
+  it 'should map to /tasks/{id}' do
+    route_for(:controller=>'tasks', :action=>'complete', :id=>1).should eql('/tasks/1')
+    lambda { route_for(:controller=>'tasks', :action=>'complete') }.should raise_error(ActionController::RoutingError)
+  end
+
+  it 'should 400 if no input provided' do
+    post :complete, :id=>0
+    response.should be_bad_request
+  end
+
+  it 'should 404 if task not found' do
+    lambda { post :complete, :id=>0, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 if task already cancelled' do
+    @task.status = :cancelled ; @task.save!
+    lambda { post :complete, :id=>@task.id, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should 404 unless allowed to view task' do
+    authenticate person('noone')
+    lambda { post :complete, :id=>@task.id, :task=>{} }.should raise_error(ActiveRecord::RecordNotFound)
+  end
+
+  it 'should redirect back and flash error if task already completed and requesting HTML' do
+    @task.status = :completed ; @task.save!
+    request.env['HTTP_REFERER'] = '/source/page'
+    post :complete, :id=>@task.id, :task=>{}
+    flash[:error].should match(/not allowed to complete/i)
+    response.should redirect_to('/source/page')
+  end
+
+  it 'should 403 if task already completed and requesting XML' do
+    @task.status = :completed ; @task.save!
+    post :complete, :id=>@task.id, :task=>{}, :format=>'xml'
+    response.should be_forbidden
+  end
+
+  it 'should 403 if task already completed and requesting JSON' do
+    @task.status = :completed ; @task.save!
+    post :complete, :id=>@task.id, :task=>{}, :format=>'json'
+    response.should be_forbidden
+  end
+
+  it 'should complete task if owner' do
+    post :complete, :id=>@task.id, :task=>{}
+    Task.find(@task.id).status.should eql(:completed)
+  end
+
+  it 'should 403 if not owner' do
+    authenticate person('admin')
+    post :complete, :id=>@task.id, :task=>{}, :format=>'xml'
+    response.should be_forbidden
+  end
+
+  it 'should 200 and redirect back to tasks list if completed and requesting HTML' do
+    post :complete, :id=>@task.id, :task=>{}
+    response.should redirect_to(tasks_url)
+  end
+
+  it 'should 200 if completed and requesting XML' do
+    post :complete, :id=>@task.id, :task=>{}, :format=>'xml'
+    response.should be_ok
+  end
+
+  it 'should 200 if completed and requesting JSON' do
+    post :complete, :id=>@task.id, :task=>{}, :format=>'json'
+    response.should be_ok
+  end
+
+  it 'should update task data if supplied' do
+    post :complete, :id=>@task.id, :task=>{ :data=>{ :foo=>:bar } }
+    Task.find(@task.id).data.should == {'foo'=>'bar'}
+  end
+
+  it 'should leave task data intact if absent' do
+    @task.data = { 'bar'=>'baz' } ; @task.save!
+    post :complete, :id=>@task.id, :task=>{}
+    Task.find(@task.id).data.should == {'bar'=>'baz'}
+  end
 
 end

Modified: ode/sandbox/singleshot/spec/models/task_spec.rb
URL: http://svn.apache.org/viewvc/ode/sandbox/singleshot/spec/models/task_spec.rb?rev=598840&r1=598839&r2=598840&view=diff
==============================================================================
--- ode/sandbox/singleshot/spec/models/task_spec.rb (original)
+++ ode/sandbox/singleshot/spec/models/task_spec.rb Tue Nov 27 17:23:07 2007
@@ -5,7 +5,7 @@
   include Specs::Tasks
 
   before :each do
-    @task = Task.create(default_values)
+    @task = Task.create!
   end
 
   def title
@@ -47,7 +47,7 @@
   include Specs::Tasks
 
   before :each do
-    @task = Task.new(default_values)
+    @task = Task.new
   end
 
   it 'should default to :active' do
@@ -72,7 +72,8 @@
     lambda { @task.save! }.should raise_error(ActiveRecord::RecordInvalid)
   end
 
-  it 'should protect status code from mass assignment' do
+  it 'should be protected attribute' do
+    Task.new(:status=>:completed).status.should eql(:active)
     lambda { @task.update_attributes! :status=>:completed }.should_not change { @task.status }
   end
 
@@ -89,7 +90,7 @@
   include Specs::Tasks
 
   before :each do
-    @task = Task.new(default_values.except(:priority))
+    @task = Task.new
   end
 
   it 'should default to 1' do
@@ -117,7 +118,7 @@
   include Specs::Tasks
 
   before :each do
-    @task = Task.new(default_values.except(:due_on))
+    @task = Task.new
   end
 
   it 'should default to nil' do
@@ -144,78 +145,58 @@
     lambda { @task.update_attributes! :due_on=>now.iso8601 ; @task.reload }.should change(@task, :due_on).to(now.to_date)
   end
 
+  it 'should accept blank string and set to nil' do
+    @task.update_attributes! :due_on=>Date.today
+    lambda { @task.update_attributes! :due_on=>'' ; @task.reload }.should change(@task, :due_on).to(nil)
+  end
+
 end
 
 
-describe Task, 'perform_url' do
-  include Specs::Tasks
+describe Task, 'url', :shared=>true do
 
   before :each do
-    @task = Task.new(default_values.except(:perform_url))
+    @task = Task.new
   end
 
-  it 'should be required to save task' do
-    @task.valid?.should be_false
-    @task.errors.on(:perform_url).to_s.should match(/URL/)
+  it 'should be optional' do
+    lambda { @task.save! }.should_not raise_error
   end
 
   it 'should be tested for validity' do
-    @task.perform_url = 'http://+++'
-    @task.valid?.should be_false
-    @task.errors.on(:perform_url).to_s.should match(/URL/)
+    lambda { @task.update_attributes! @field=>'http://+++' }.should raise_error(ActiveRecord::RecordInvalid, /URL/)
   end
 
   it 'should allow HTTP URLS' do
-    @task.perform_url = 'http://test.host/do'
-    @task.valid?.should be_true
+    lambda { @task.update_attributes! @field=>'http://test.host/do' }.should_not raise_error
   end
 
   it 'should allow HTTPS URLS' do
-    @task.perform_url = 'https://test.host/do'
-    @task.valid?.should be_true
+    lambda { @task.update_attributes! @field=>'https://test.host/do' }.should_not raise_error
   end
 
   it 'should not allow other URL schemes' do
-    @task.perform_url = 'ftp://test.host/do'
-    @task.valid?.should be_false
-    @task.errors.on(:perform_url).to_s.should match(/URL/)
+    lambda { @task.update_attributes! @field=>'ftp://test.host/do' }.should raise_error(ActiveRecord::RecordInvalid, /URL/)
   end
 
-end
-
-
-describe Task, 'completion_url' do
-  include Specs::Tasks
-
-  before :each do
-    @task = Task.new(default_values.except(:completion_url))
+  it 'should store normalized URL' do
+    lambda { @task.update_attributes! @field=>'HTTP://Test.Host/Foo' }.should change(@task, @field).to('http://test.host/Foo')
+    lambda { @task.update_attributes! @field=>'http://test.host' }.should change(@task, @field).to('http://test.host/')
   end
 
-  it 'should be required to save task' do
-    @task.valid?.should be_false
-    @task.errors.on(:completion_url).to_s.should match(/URL/)
+  it 'should be modifiable' do
+    lambda { @task.update_attributes! @field=>'http://test.host/' }.should change(@task, @field).from(nil).to('http://test.host/')
   end
 
-  it 'should be tested for validity' do
-    @task.completion_url = 'http://+++'
-    @task.valid?.should be_false
-    @task.errors.on(:completion_url).to_s.should match(/URL/)
-  end
+end
 
-  it 'should allow HTTP URLS' do
-    @task.completion_url = 'http://test.host/completed'
-    @task.valid?.should be_true
-  end
 
-  it 'should allow HTTPS URLS' do
-    @task.completion_url = 'https://test.host/completed'
-    @task.valid?.should be_true
-  end
+describe Task, 'form_url' do
+  include Specs::Tasks
+  it_should_behave_like 'Task url'
 
-  it 'should not allow other URL schemes' do
-    @task.completion_url = 'ftp://test.host/completed'
-    @task.valid?.should be_false
-    @task.errors.on(:completion_url).to_s.should match(/URL/)
+  before :all do
+    @field = :form_url
   end
 
 end
@@ -223,76 +204,77 @@
 
 describe Task, 'view_url' do
   include Specs::Tasks
+  it_should_behave_like 'Task url'
 
-  before :each do
-    @task = Task.new(default_values.except(:view_url))
-  end
-
-  it 'should be optional' do
-    @task.valid?.should be_true
+  before :all do
+    @field = :view_url
   end
 
-  it 'should be tested for validity' do
-    @task.view_url = 'http://+++'
-    @task.valid?.should be_false
-    @task.errors.on(:view_url).to_s.should match(/URL/)
-  end
+end
 
-  it 'should allow HTTP URLS' do
-    @task.view_url = 'http://test.host/see'
-    @task.valid?.should be_true
-  end
 
-  it 'should allow HTTPS URLS' do
-    @task.view_url = 'https://test.host/see'
-    @task.valid?.should be_true
-  end
+describe Task, 'outcome_url' do
+  include Specs::Tasks
+  it_should_behave_like 'Task url'
 
-  it 'should not allow other URL schemes' do
-    @task.view_url = 'ftp://test.host/see'
-    @task.valid?.should be_false
-    @task.errors.on(:view_url).to_s.should match(/URL/)
+  before :all do
+    @field = :outcome_url
   end
 
 end
 
 
-describe Task, 'completion_format' do
+describe Task, 'outcome_type' do
   include Specs::Tasks
 
-  before :each do
-    @task = Task.new(default_values.except(:completion_format))
+  def outcome_values(mime_type)
+    { :outcome_url=>'http://test.host/outcome', :outcome_type=>mime_type }
+  end
+
+  it 'should be ignored unless outcome URL specified' do
+    task = Task.create!(:outcome_type=>Mime::XML)
+    Task.find(task.id).outcome_type.should be_nil
   end
 
   it 'should default to Mime::XML' do
-    @task.completion_format.should be(Mime::XML)
+    task = Task.create!(outcome_values(nil))
+    Task.find(task.id).outcome_type.should be(Mime::XML)
   end
 
   it 'should accept Mime::XML' do
-    @task.update_attributes! :completion_format=>Mime::XML
-    Task.find(@task.id).completion_format.should be(Mime::XML)
+    task = Task.create!(outcome_values(Mime::XML))
+    Task.find(task.id).outcome_type.should be(Mime::XML)
   end
 
   it 'should accept Mime::JSON' do
-    @task.update_attributes! :completion_format=>Mime::JSON
-    Task.find(@task.id).completion_format.should be(Mime::JSON)
+    task = Task.create!(outcome_values(Mime::JSON))
+    Task.find(task.id).outcome_type.should be(Mime::JSON)
   end
 
-  it 'should accept content type' do
-    Task::COMPLETION_FORMATS.each do |format|
-      lambda { @task.update_attributes! :completion_format=>format.to_s }.should change(@task, :completion_format).to(format)
+  it 'should accept all applicable MIME types' do
+    Task::OUTCOME_MIME_TYPES.each do |mime_type|
+      task = Task.create!(outcome_values(mime_type))
+      Task.find(task.id).outcome_type.should be(mime_type)
     end
   end
 
-  it 'should accept extension name' do
-    Task::COMPLETION_FORMATS.each do |format|
-      lambda { @task.update_attributes! :completion_format=>format.to_sym.to_s }.should change(@task, :completion_format).to(format)
+  it 'should accept all applicable MIME types as content type' do
+    Task::OUTCOME_MIME_TYPES.each do |mime_type|
+      task = Task.create!(outcome_values(mime_type.to_s))
+      Task.find(task.id).outcome_type.should be(mime_type)
     end
   end
 
-  it 'should reject unsupported formats' do
-    @task.completion_format = Mime::ATOM
-    lambda { @task.save! }.should raise_error(ActiveRecord::RecordInvalid)
+  it 'should accept all applicable MIME types as extension name' do
+    Task::OUTCOME_MIME_TYPES.each do |mime_type|
+      task = Task.create!(outcome_values(mime_type.to_sym.to_s))
+      Task.find(task.id).outcome_type.should be(mime_type)
+    end
+  end
+
+  it 'should reject unsupported MIME types' do
+    lambda { Task.create!(outcome_values(Mime::ATOM)) }.
+      should raise_error(ActiveRecord::RecordInvalid)
   end
 
 end
@@ -302,7 +284,7 @@
   include Specs::Tasks
 
   before :each do
-    @task = Task.new(default_values)
+    @task = Task.new
   end
 
   it 'should return hash' do
@@ -348,8 +330,8 @@
   end
 
   it 'should be hex digest' do
-    @creator_token.should match(/^[a-z0-9]{32,}$/)
-    @owner_token.should match(/^[a-z0-9]{32,}$/)
+    @creator_token.should match(/^[a-f0-9]{32,}$/)
+    @owner_token.should match(/^[a-f0-9]{32,}$/)
   end
 
   it 'should be consistent for a given person' do
@@ -370,6 +352,10 @@
     lambda { @task.owner = nil }.should change { @task.authorize(@owner_token) }.from(@owner).to(nil)
   end
 
+  it 'should rely one protected attribute access key' do
+    lambda { @task.update_attributes! :access_key=>'foo' }.should_not change(@task, :access_key)
+  end
+
 end
 
 
@@ -377,54 +363,60 @@
   include Specs::Tasks, Specs::Authentication
 
   before :all do
-    @person = person('person')
-  end
-
-  before :each do
-    @task = Task.new(default_values)
+    @person, @other = person('person'), person('alex')
   end
 
   it 'should start as nil' do
     Task::SINGULAR_ROLES.each do |role|
-      @task.send(role).should be_nil
+      Task.new.send(role).should be_nil
     end
   end
 
   it 'should have getter and setter methods' do
     Task::SINGULAR_ROLES.each do |role|
-      lambda { @task.update_attributes! role=>@person }.should change(@task, role).to(@person)
+      task = Task.create!(role=>@person)
+      Task.find(task.id).send(role).should eql(@person)
     end
   end
 
   it 'should have checker methods' do
     Task::SINGULAR_ROLES.each do |role|
-      lambda { @task.update_attributes! role=>@person }.should change { @task.send("#{role}?", @person) }.to(true)
+      task = Task.create!(role=>@person)
+      Task.find(task.id).send("#{role}?", @person).should be_true
     end
   end
 
   it 'should be able to remove person' do
     Task::SINGULAR_ROLES.each do |role|
-      @task.update_attributes! role=>@person
-      lambda { @task.update_attributes! role=>nil }.should change(@task, role).from(@person).to(nil)
+      task = Task.create!(role=>@person)
+      lambda { task.update_attributes! role=>nil }.should change(task, role).from(@person).to(nil)
+    end
+  end
+
+  it 'should be able to change person' do
+    Task::SINGULAR_ROLES.each do |role|
+      task = Task.create!(role=>@person)
+      lambda { task.update_attributes! role=>person(@other) }.should change(task, role).from(@person).to(person(@other))
     end
   end
 
   it 'should accept person at creation' do
     Task::SINGULAR_ROLES.each do |role|
-      Task.create(default_values.merge(role=>@person)).send(role).should eql(@person)
+      Task.create!(role=>@person).send(role).should eql(@person)
     end
   end
 
   it 'should treat blank as nil' do
     Task::SINGULAR_ROLES.each do |role|
-      @task.update_attributes! role=>@person
-      lambda { @task.update_attributes! role=>'' }.should change(@task, role).from(@person).to(nil)
+      task = Task.create!(role=>@person)
+      lambda { task.update_attributes! role=>'' }.should change(task, role).from(@person).to(nil)
     end
   end
 
   it 'should accept unknown identities but fail to save' do
     Task::SINGULAR_ROLES.each do |role|
-      lambda { @task.update_attributes! role=>'unknown' }.
+      task = Task.create!(role=>@person)
+      lambda { task.update_attributes! role=>'unknown' }.
         should raise_error(ActiveRecord::RecordInvalid, /cannot find person/i)
     end
   end
@@ -437,11 +429,11 @@
 
   before :each do
     @people = Array.new(3) { |i| person("person#{i}") }
-    @task = Task.new(default_values)
+    @task = Task.new
   end
 
   def plural(role)
-    role.to_s.pluralize
+    role.to_s.pluralize.to_sym
   end
 
   it 'should start as empty array' do
@@ -458,7 +450,7 @@
 
   it 'should have checker methods' do
     Task::PLURAL_ROLES.each do |role|
-      lambda { @task.update_attributes! plural(role)=>@people.first }.
+      lambda { @task.send "#{plural(role)}=", @people.first }.
         should change { @task.send("#{role}?", @people.first) }.to(true)
       @task.send("#{role}?", @people.last).should be_false
     end
@@ -489,7 +481,7 @@
 
   it 'should accept person at creation' do
     Task::PLURAL_ROLES.each do |role|
-      Task.create(default_values.merge(plural(role)=>@people)).send(plural(role)).should eql(@people)
+      Task.create!(plural(role)=>@people).send(plural(role)).should eql(@people)
     end
   end
 
@@ -511,7 +503,7 @@
 
 
 describe Task, 'creator' do
-  include Specs::Tasks
+  include Specs::Authentication, Specs::Tasks
 
   it 'should be singular role' do
     Task::SINGULAR_ROLES.should include(:creator)
@@ -542,12 +534,11 @@
   include Specs::Tasks, Specs::Authentication
 
   before :all do
-    @task = Task.new(default_values.merge(:owner=>person(:owner), :creator=>person(:creator)))
+    @task = Task.new(@roles = all_roles)
   end
   
   it 'should return true if person associated with task' do
-    @task.stakeholder?(person(:owner)).should be_true
-    @task.stakeholder?(person(:creator)).should be_true
+    @roles.except(:excluded_owners).each { |role, person| @task.stakeholder?(person).should be_true }
   end
 
   it 'should return false if person not associated with task' do
@@ -557,6 +548,123 @@
   it 'should return true for task admin' do
     Task.admins << person(:admin)
     @task.stakeholder?(person(:admin)).should be_true
+  end
+
+  it 'should return false for excluded owner' do
+    @task.stakeholder?(@roles[:excluded_owners]).should be_false
+  end
+
+end
+
+
+describe Task, 'stakeholders' do
+
+  it 'should be protected attribute' do
+    task = Task.new(:stakeholders=>[Stakeholder.new])
+    task.stakeholders.should be_empty
+    lambda { task.update_attributes :stakeholders=>[Stakeholder.new] }.should_not change(task, :stakeholders)
+  end
+
+end
+
+
+describe Task, 'version' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.create!
+  end
+
+  it 'should start at zero' do
+    @task.version.should eql(0)
+  end
+
+  it 'should increment when task saved' do
+    5.times do |n|
+      lambda { @task.save }.should change(@task, :version).from(n).to(n + 1)
+    end
+  end
+
+end
+
+
+describe Task, 'etag' do
+  include Specs::Tasks
+
+  before :each do
+    @task = Task.create!
+  end
+
+  it 'should return same value if task not udpated' do
+    @task.etag.should eql(@task.etag)
+  end
+
+  it 'should change when task updated' do
+    3.times do
+      lambda { @task.save }.should change(@task, :etag)
+    end
+  end
+
+  it 'should be unique across tasks' do
+    @task.etag.should_not eql(Task.create!)
+  end
+
+  it 'should be MD5 hash' do
+    2.times do
+      @task.etag.should match(/^[a-f0-9]{32}$/)
+      @task.save
+    end
+  end
+
+end
+
+
+describe Task, 'cancellation' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :all do
+    Task.admins = @task_admins = person('admins')
+  end
+
+  before :each do
+    @task = Task.new(@roles = all_roles)
+  end
+
+  it 'should default to :admin' do
+    @task.cancellation.should eql(:admin)
+  end
+
+  it 'should accept :owner' do
+    lambda { @task.update_attributes! :cancellation=>:owner }.should change(@task, :cancellation).to(:owner)
+  end
+
+  it 'should allow admin to cancel the task for all values' do
+    @roles.select { |role, person| @task.can_cancel?(person) }.map(&:first).should eql([:admins])
+    @task.can_cancel?(@task_admins).should be_true
+  end
+
+  it 'should allow owner to cancel the task for the value :owner' do
+    @task.cancellation = :owner
+    @roles.select { |role, person| @task.can_cancel?(person) }.map(&:first).sort_by(&:to_s).should eql([:admins, :owner])
+    @task.can_cancel?(@task_admins).should be_true
+  end
+
+end
+
+
+describe Task, 'completion' do
+  include Specs::Authentication, Specs::Tasks
+
+  before :all do
+    Task.admins = @task_admins = person('admins')
+  end
+
+  before :each do
+    @task = Task.new(@roles = all_roles)
+  end
+
+  it 'should allow owner to complete task' do
+    @roles.select { |role, person| @task.can_complete?(person) }.map(&:first).should eql([:owner])
   end
 
 end