
#
# testing ruote
#
# Mon Dec 14 15:03:13 JST 2009
#

require File.expand_path('../../test_helper', __FILE__)
require_json
require File.expand_path('../../functional/storage_helper', __FILE__)
require File.expand_path('../../functional/signals', __FILE__)
require 'ruote'


# Please note:

# Operations return something trueish when they fail and nil
# when they succeed.
#
# The pattern is: when it fails because the document passed as argument is
# outdated, you will receive the current version of the document (trueish),
# when it fails because the document is gone (deleted meanwhile), you will
# receive true (which is obviously trueish).

class FtStorage < Test::Unit::TestCase

  #
  # test preparation

  def setup

    @s = determine_storage({})
    @s = @s.storage if @s.respond_to?(:storage)

    %w[ errors expressions msgs workitems ].each do |t|
      @s.purge_type!(t)
    end
  end

  def teardown

    return unless @s

    @s.purge!

    @s.shutdown
  end

  #
  # helpers

  def put_toto_doc

    @s.put('_id' => 'toto', 'type' => 'errors', 'message' => 'testing')
  end

  def get_toto_doc

    @s.get('errors', 'toto')
  end

  #
  # the tests

  # === put

  # When successful, #put returns nil.
  #
  def test_put

    doc = { '_id' => 'toto', 'type' => 'errors', 'message' => 'testing' }

    r = @s.put(doc)

    assert_nil r

    assert_nil doc['_rev']
    assert_nil doc['put_at']

    doc = @s.get('errors', 'toto')

    assert_not_nil doc['_rev']
    assert_not_nil doc['put_at']

    assert_equal 'testing', doc['message']
  end

  # When a document with the same _id and type already existent, the put
  # doesn't happen and #put returns that already existing document.
  #
  def test_put_when_already_existent

    put_toto_doc

    doc = { '_id' => 'toto', 'type' => 'errors', 'message' => 'two' }

    r = @s.put(doc)

    assert_match /Hash$/, r.class.name
    assert_not_nil r['_rev']
    assert_not_nil r['put_at']

    assert_nil doc['_rev']
    assert_nil doc['put_at']
  end

  # A successful reput (_id/type/_rev do match) returns nil.
  #
  def test_reput

    put_toto_doc

    d0 = get_toto_doc

    d0['message'] = 'test_reput'

    r = @s.put(d0)

    assert_nil r

    d1 = get_toto_doc

    assert_not_equal d1['_rev'], d0['_rev']
    assert d1['put_at'] >= d0['put_at']

    assert_equal 'test_reput', d1['message']
  end

  # A reput with the wrong rev (our document is outdated probably) will
  # not happen and #put will return the current (newest probably) document.
  #
  def test_reput_fail_wrong_rev

    put_toto_doc

    d1 = get_toto_doc

    rev = d1['_rev']

    @s.put(d1.merge('message' => 'x'))

    d2 = get_toto_doc

    r = @s.put(d2.merge('_rev' => rev, 'message' => 'y'))

    assert_not_nil r
    assert_not_equal d1['_rev'], d2['_rev']
    assert_equal 'x', d2['message']
  end

  # Attempting to put a document that is gone (got deleted meanwhile) will
  # return true.
  #
  def test_reput_fail_gone

    put_toto_doc

    doc = get_toto_doc

    @s.delete(doc)

    r = @s.put(doc)

    assert_equal true, r
  end

  # Attempting to put a document with a _rev directly will raise an
  # ArgumentError.
  #
  def test_put_doc_with_rev

    put_toto_doc; doc = get_toto_doc
      # just to get a valid _rev

    r = @s.put(
      '_id' => 'doc_with_rev', 'type' => 'errors', '_rev' => doc['_rev'])

    assert_equal true, r
  end

  # #put takes an optional :update_rev. When set to true and the put
  # succeeds, the _rev and the put_at of the [local] document are set/updated.
  #
  def test_put_update_rev_new_document

    doc = { '_id' => 'urev', 'type' => 'errors' }

    r = @s.put(doc, :update_rev => true)

    assert_nil r
    assert_not_nil doc['_rev']
    assert_not_nil doc['put_at']
  end

  # When putting a document with the :update_rev option set, the just put
  # document will get the new _rev (and the put_at)
  #
  def test_put_update_rev_existing_document

    put_toto_doc; doc = get_toto_doc

    initial_rev = doc['_rev']
    initial_put_at = doc['put_at']

    r = @s.put(doc, :update_rev => true)

    assert_nil r
    assert_not_nil initial_rev
    assert_not_nil initial_put_at
    assert_not_nil doc['_rev']
    assert_not_equal doc['_rev'], initial_rev
    assert_not_nil doc['put_at']
    assert doc['put_at'] >= initial_put_at
  end

  # put_at and _rev should not repeat
  #
  def test_put_sequence

    revs = []
    doc = { '_id' => 'putseq', 'type' => 'errors' }

    77.times do |i|

      r = @s.put(doc)
      doc = @s.get('errors', 'putseq')

      revs << doc['_rev']

      assert_nil r
      assert_not_nil doc['put_at']
      assert_equal i + 1, revs.uniq.size
    end
  end

  # Be lenient with the input (accept symbols, but turn them into strings).
  #
  def test_put_turns_symbols_into_strings

    r = @s.put('_id' => 'skeys', 'type' => 'errors', :a => :b)

    assert_nil r

    doc = @s.get('errors', 'skeys')

    return if doc.class != Hash
      # MongoDB uses BSON::OrderedHash which is happy with symbols...

    assert_equal 'b', doc['a']
  end

  # === get

  # Getting a non-existent document returns nil.
  #
  def test_get_non_existent

    assert_nil @s.get('errors', 'nemo')
  end

  # Getting a document returns it (well the most up-to-date revision of it).
  #
  def test_get

    put_toto_doc

    doc = @s.get('errors', 'toto')

    doc.delete('_rev')
    doc.delete('put_at')
    doc.delete('_wfid') # ruote-mon

    assert_equal(
      { '_id' => 'toto', 'type' => 'errors', 'message' => 'testing' },
      doc)
  end

  # === delete

  # When successful, #delete returns nil (like the other methods...).
  #
  def test_delete

    put_toto_doc

    doc = @s.get('errors', 'toto')

    r = @s.delete(doc)

    assert_equal nil, r

    doc = @s.get('errors', 'toto')

    assert_equal nil, doc
  end

  # When attempting to delete a document and that document argument has no
  # _rev, it will raise an ArgumentError.
  #
  def test_delete_document_without_rev

    assert_raise(
      ArgumentError, "can't delete doc without _rev"
    ) do
      @s.delete('_id' => 'without_rev', 'type' => 'errors')
    end
  end

  # Deleting a document that doesn't exist returns true.
  #
  def test_delete_non_existent_document

    put_toto_doc; doc = get_toto_doc
      # just to get a valid _rev

    r = @s.delete('_id' => 'ned', 'type' => 'errors', '_rev' => doc['_rev'])

    assert_equal true, r
  end

  # Deleting a document that is gone (got deleted meanwhile) returns true.
  #
  def test_delete_gone_document

    put_toto_doc
    doc = get_toto_doc
    @s.delete(doc)

    r = @s.delete(doc)

    assert_equal true, r
  end

  # === get_many

  # Get many documents at once, use a string or regex key, or not.
  #
  def test_get_many

    load_30_errors

    assert_equal 30, @s.get_many('errors').size
    assert_equal 0, @s.get_many('errors', '7').size
    assert_equal 1, @s.get_many('errors', '07').size
    assert_equal 1, @s.get_many('errors', /!07$/).size
    assert_equal 30, @s.get_many('errors', /^yy!/).size
    assert_equal 30, @s.get_many('errors', /y/).size

    assert_equal 'yy!07', @s.get_many('errors', '07').first['_id']
    assert_equal 'yy!07', @s.get_many('errors', /!07/).first['_id']
  end

  # Get many documents at once, use an array of string or regex keys.
  #
  def test_get_many_array_of_keys

    load_30_errors

    assert_equal 30, @s.get_many('errors').size
    assert_equal 2, @s.get_many('errors', [ '07', '08' ]).size
    assert_equal 2, @s.get_many('errors', [ /!07$/, /!08$/ ]).size

    assert_equal(
      %w[ yy!07 yy!08 ],
      @s.get_many('errors', [ '07', '08' ]).collect { |d| d['_id'] }.sort)
    assert_equal(
      %w[ yy!07 yy!08 ],
      @s.get_many('errors', [ /!07$/, /!08$/ ]).collect { |d| d['_id'] }.sort)
  end

  # Limit the number of documents received.
  #
  def test_get_many_limit

    load_30_errors

    assert_equal 10, @s.get_many('errors', nil, :limit => 10).size
  end

  # Count the documents (in a type).
  #
  def test_get_many_count

    load_30_errors

    assert_equal 30, @s.get_many('errors', nil, :count => true)
  end

  # Paginate documents.
  #
  def test_get_many_skip_and_limit

    load_30_errors

    assert_equal(
      %w[ yy!01 yy!02 yy!03 yy!04 ],
      @s.get_many(
        'errors', nil, :skip => 0, :limit => 4
      ).collect { |d| d['_id'] })
    assert_equal(
      %w[ yy!04 yy!05 yy!06 ],
      @s.get_many(
        'errors', nil, :skip => 3, :limit => 3
      ).collect { |d| d['_id'] })
  end

  # Pagination and :descending are not incompatible.
  #
  def test_get_many_skip_limit_and_reverse

    load_30_errors

    assert_equal(
      %w[ yy!30 yy!29 yy!28 ],
      @s.get_many(
        'errors', nil, :skip => 0, :limit => 3, :descending => true
      ).collect { |d| d['_id'] })
    assert_equal(
      %w[ yy!27 yy!26 yy!25 ],
      @s.get_many(
        'errors', nil, :skip => 3, :limit => 3, :descending => true
      ).collect { |d| d['_id'] })
  end

  # === purge!

  # Purge removes all the documents in the storage.
  #
  def test_purge

    put_toto_doc

    assert_equal 1, @s.get_many('errors').size

    @s.purge!

    assert_equal 0, @s.get_many('errors').size
  end

  # === reserve

  # Making sure that Storage#reserve(msg) returns true once and only once
  # for a given msg. Stresses the storage for a while and then checks
  # for collisions.
  #
  def test_reserve

    # TODO: eventually return here if the storage being tested has
    #       no need for a real reserve implementation (ruote-swf for example).

    taoe = Thread.abort_on_exception
    Thread.abort_on_exception = true

    reserved = []
    threads = []

    threads << Thread.new do
      i = 0
      loop do
        @s.put_msg('launch', 'tree' => i)
        i = i + 1
      end
    end

    2.times do

      threads << Thread.new do
        loop do
          msgs = @s.get_msgs
          msgs[0, 100].each do |msg|
            next if msg['tree'].nil?
            next unless @s.reserve(msg)
            if reserved.include?(msg['tree'])
              puts "=" * 80
              p [ :dbl, :r, msg['_rev'], :t, msg['tree'] ]
            end
            reserved << msg['tree']
            sleep(rand * 0.01)
          end
        end
      end
    end

    sleep 7

    threads.each { |t| t.terminate }

    Thread.abort_on_exception = taoe

    assert_equal false, reserved.empty?
    assert_equal reserved.size, reserved.uniq.size
  end

  # === ids

  # Storage#ids(type) returns all the ids present for a document type, in
  # sorted order.
  #
  def test_ids

    ids = load_30_errors

    assert_equal ids.sort, @s.ids('errors')
  end

  # === dump

  # #dump returns a string representation of the storage's content. Warning,
  # this is a debug/test method.
  #
  def test_dump

    load_30_errors

    dump = @s.dump('errors')

    assert_match /^[ -] _id: yy!01\n/, dump
    assert_match /^[ -] _id: yy!21\n/, dump
  end

  # === clear

  # #clear clears the storage
  #
  def test_clear

    put_toto_doc

    assert_equal 1, @s.get_many('errors').size

    @s.clear

    assert_equal 0, @s.get_many('errors').size
  end

  # === remove_process

  # Put documents for process 0 and process 1, remove_process(1), check that
  # only documents for process 0 are remaining.
  #
  # Don't forget to deal with trackers and schedules.
  #
  def test_remove_process

    @s.purge_type!('errors')
    ts = @s.get_trackers
    @s.delete(ts) if ts['_rev']

    dboard = Ruote::Dashboard.new(Ruote::Worker.new(@s))
    dboard.noisy = ENV['NOISY'] == 'true'

    dboard.register :human, Ruote::StorageParticipant

    pdef = Ruote.define do
      concurrence do
        wait '1d'
        human
        listen :to => 'bob'
        error 'nada'
      end
    end

    wfid0 = dboard.launch(pdef)
    wfid1 = dboard.launch(pdef)

    dboard.wait_for('error_intercepted')
    dboard.wait_for('error_intercepted')

    assert_equal 12, @s.get_many('expressions').size
    assert_equal 2, @s.get_many('schedules').size
    assert_equal 2, @s.get_many('workitems').size
    assert_equal 2, @s.get_many('errors').size
    assert_equal 2, @s.get_trackers['trackers'].size

    @s.remove_process(wfid0)

    assert_equal 6, @s.get_many('expressions').size
    assert_equal 1, @s.get_many('schedules').size
    assert_equal 1, @s.get_many('workitems').size
    assert_equal 1, @s.get_many('errors').size
    assert_equal 1, @s.get_trackers['trackers'].size

  ensure
    dboard.shutdown rescue nil
  end

  # === configuration

  # Simply getting the engine configuration should work.
  #
  def test_get_configuration

    assert_not_nil @s.get_configuration('engine')
  end

  # The initial configuration passed when initializing the storage overrides
  # any previous configuration.
  #
  def test_override_configuration

    determine_storage('house' => 'taira', 'domain' => 'harima')
    s = determine_storage('house' => 'minamoto')

    assert_equal 'minamoto', s.get_configuration('engine')['house']
    assert_equal nil, s.get_configuration('engine')['domain']
  end

  # Testing the 'preserve_configuration' option for storage initialization.
  #
  def test_preserve_configuration

    return if @s.class == Ruote::HashStorage
      # this test makes no sense with an in-memory hash

    determine_storage(
      'house' => 'taira')
    s = determine_storage(
      'house' => 'minamoto', 'preserve_configuration' => true)

    assert_equal 'taira', s.get_configuration('engine')['house']

    # if this test is giving a
    # "NoMethodError: undefined method `[]' for nil:NilClass"
    # for ruote-dm, comment out the auto_upgrade! block in
    # ruote-dm/test/functional_connection.rb
  end

  # === query workitems

  # Query by workitem field.
  #
  def test_by_field

    return unless @s.respond_to?(:by_field)

    load_workitems

    assert_equal 3, @s.by_field('workitems', 'place', 'kyouto').size
    assert_equal 1, @s.by_field('workitems', 'place', 'sendai').size

    assert_equal(
      Ruote::Workitem, @s.by_field('workitems', 'place', 'sendai').first.class)
  end

  # Query by participant name.
  #
  def test_by_participant

    return unless @s.respond_to?(:by_participant)

    load_workitems

    assert_equal 2, @s.by_participant('workitems', 'fujiwara', {}).size
    assert_equal 1, @s.by_participant('workitems', 'shingen', {}).size

    assert_equal(
      Ruote::Workitem, @s.by_participant('workitems', 'shingen', {}).first.class)
  end

  # General #query_workitems method.
  #
  def test_query_workitems

    return unless @s.respond_to?(:query_workitems)

    load_workitems

    assert_equal 3, @s.query_workitems('place' => 'kyouto').size
    assert_equal 1, @s.query_workitems('place' => 'kyouto', 'at' => 'kamo').size

    assert_equal(
      Ruote::Workitem, @s.query_workitems('place' => 'kyouto').first.class)
  end

  # === misc

  # Simply make sure the storage (well, at least its "error" type) is empty.
  #
  def test_starts_empty

    assert_equal 0, @s.get_many('errors').size
  end

  protected

  #
  # helpers

  def load_30_errors

    (1..30).to_a.shuffle.collect do |i|

      id = sprintf('yy!%0.2d', i)

      @s.put(
        '_id' => id,
        'type' => 'errors',
        'msg' => "whatever #{i}",
        'fei' => { 'wfid' => id.split('!').last } )

      id
    end
  end

  def put_workitem(wfid, participant_name, fields)

    @s.put(
      'type' => 'workitems',
      '_id' => "wi!0_0!12ff!#{wfid}",
      'participant_name' => participant_name,
      'wfid' => wfid,
      'fields' => fields)
  end

  def load_workitems

    put_workitem(
      '20110218-nadanada', 'fujiwara', 'place' => 'kyouto')
    put_workitem(
      '20110218-nedenada', 'fujiwara', 'place' => 'kyouto', 'at' => 'kamo')
    put_workitem(
      '20110218-nadanodo', 'taira', 'place' => 'kyouto')
    put_workitem(
      '20110218-nodonada', 'date', 'place' => 'sendai')
    put_workitem(
      '20110218-nadanudu', 'shingen', 'place' => 'nagoya')
  end
end

