require 'spec_helper'

RSpec.describe 'deferred has_and_belongs_to_many associations' do

  before(:example) do
    Person.create!(name: 'Alice')
    Person.create!(name: 'Bob')

    Team.create!(name: 'Database Administration')
    Team.create!(name: 'End-User Support')
    Team.create!(name: 'Operations')

    bob; dba; support; operations
  end

  def bob
    @bob ||= Person.where(name: 'Bob').first
  end

  def dba
    @dba ||= Team.where(name: 'Database Administration').first
  end

  def support
    @support ||= Team.where(name: 'End-User Support').first
  end

  def operations
    @operations ||= Team.where(name: 'Operations').first
  end

  describe 'deferring' do
    it 'does not create a link until parent is saved' do
      bob.teams << dba << support
      expect { bob.save! }.to change { Person.find(bob.id).teams.size }.from(0).to(2)
    end

    it 'does not unlink until parent is saved' do
      bob.team_ids = [dba.id, support.id, operations.id]
      bob.save!

      bob.teams.delete([
        Team.find(dba.id),
        Team.find(operations.id)
      ])

      expect { bob.save }.to change { Person.find(bob.id).teams.size }.from(3).to(1)
    end

    it 'does not create a link when parent is not valid' do
      bob.name = nil # Person.name should be present, Person should not be saved.
      bob.teams << dba

      expect { bob.save }.not_to change { Person.find(bob.id).teams.size }
    end

    it 'replaces existing records when assigning a new set of records' do
      bob.teams = [dba]

      # A mistake was made, Bob belongs to Support and Operations instead.
      bob.teams = [support, operations]

      # The initial assignment of Bob to the DBA team did not get saved, so
      # at this moment Bob is not assigned to any team in the database.
      expect { bob.save }.to change { Person.find(bob.id).teams.size }.from(0).to(2)
    end

    it 'drops nil records' do
      bob.teams << nil
      expect(bob.teams).to be_empty

      bob.teams = [nil]
      expect(bob.teams).to be_empty

      bob.teams.delete(nil)
      expect(bob.teams).to be_empty

      bob.teams.destroy(nil)
      expect(bob.teams).to be_empty
    end

    it 'does not load the deferred associations when saving the parent' do
      _, queries = catch_queries { bob.save! }
      expect(queries.size).to eq(0)
    end

    describe '#collection_singular_ids' do
      it 'returns ids of saved & unsaved associated records' do
        bob.teams = [dba, operations]
        expect(bob.team_ids.size).to eq(2)
        expect(bob.team_ids).to eq [dba.id, operations.id]

        expect { bob.save }.to change { Person.find(bob.id).team_ids.size }.from(0).to(2)

        expect(bob.team_ids.size).to eq(2)
        expect(bob.team_ids).to eq [dba.id, operations.id]
      end
    end

    describe '#collections_singular_ids=' do
      it 'sets associated records' do
        bob.team_ids = [dba.id, operations.id]
        bob.save
        expect(bob.teams).to eq [dba, operations]
        expect(bob.team_ids).to eq [dba.id, operations.id]

        bob.reload
        expect(bob.teams).to eq [dba, operations]
        expect(bob.team_ids).to eq [dba.id, operations.id]
      end

      it 'replace existing records when assigning a new set of ids of records' do
        bob.teams = [dba]

        # A mistake was made, Bob belongs to Support and Operations instead. The
        # teams are assigned through the singular collection ids method. Note
        # that, this also updates the teams association.
        bob.team_ids = [support.id, operations.id]
        expect(bob.teams.length).to eq(2)

        expect { bob.save }.to change { Person.find(bob.id).teams.size }.from(0).to(2)
      end

      it 'clears empty values from the ids to be assigned' do
        bob.team_ids = [dba.id, '']
        expect(bob.teams.length).to eq(1)

        expect { bob.save }.to change { Person.where(name: 'Bob').first.teams.size }.from(0).to(1)
      end

      it 'unlinks all records when assigning empty array' do
        bob.team_ids = [dba.id, operations.id]
        bob.save

        bob.team_ids = []
        expect(bob.teams.length).to eq(0)

        expect { bob.save }.to change { Person.where(name: 'Bob').first.teams.size }.from(2).to(0)
      end

      it 'unlinks all records when assigning nil' do
        bob.team_ids = [dba.id, operations.id]
        bob.save

        bob.team_ids = nil
        expect(bob.teams.length).to eq(0)

        expect { bob.save }.to change { Person.where(name: 'Bob').first.teams.size }.from(2).to(0)
      end
    end

    describe '#collection_checked=' do
      it 'set associated records' do
        bob.teams_checked = "#{dba.id},#{operations.id}"
        expect { bob.save }.to change { Person.where(name: 'Bob').first.teams.size }.from(0).to(2)
      end

      it 'replace existing records when assigning a new set of ids of records' do
        bob.team_ids = [dba.id, operations.id]
        bob.save

        # Replace DBA and Operations by Support.
        bob.teams_checked = "#{support.id}"

        expect{ bob.save }.to change{ Person.where(name: 'Bob').first.teams.size }.from(2).to(1)
        expect(bob.teams.first).to eq(support)
      end

      it 'unlinks all records when assigning empty string' do
        bob.team_ids = [dba.id, operations.id]
        bob.save

        # Unlinks DBA and Operations.
        bob.teams_checked = ""
        expect(bob.teams.length).to eq(0)

        expect{ bob.save }.to change{ Person.where(name: 'Bob').first.teams.size }.from(2).to(0)
      end

      it 'unlinks all records when assigning nil' do
        bob.team_ids = [dba.id, operations.id]
        bob.save

        # Unlinks DBA and Operations.
        bob.teams_checked = nil
        expect(bob.teams.length).to eq(0)

        expect{ bob.save }.to change{ Person.where(name: 'Bob').first.teams.size }.from(2).to(0)
      end
    end

    describe '#changed_for_autosave?' do
      it 'return false if nothing has changed' do
        changed, queries = catch_queries { bob.changed_for_autosave? }
        expect(queries).to be_empty
        expect(changed).to eq(false)
      end

      it 'does not query anything if the objects have not been loaded' do
        bob.name = 'James'
        changed, queries = catch_queries { bob.changed_for_autosave? }
        expect(queries).to be_empty
        expect(changed).to eq(true)
      end

      it 'returns true if there is a pending create' do
        bob.teams = [dba]
        changed, queries = catch_queries { bob.changed_for_autosave? }
        expect(queries).to be_empty
        expect(changed).to eq(true)
      end

      it 'returns true if there is a pending delete' do
        bob.teams = [dba]
        bob.save!

        bob = Person.where(name: 'Bob').first
        bob.teams.delete(dba)
        changed, queries = catch_queries { bob.changed_for_autosave? }
        expect(queries).to be_empty
        expect(changed).to eq(true)
      end

      it 'does not perform any queries if the original association has not been loaded' do
        bob.teams = [dba]
        changed, queries = catch_queries { bob.changed_for_autosave? }
        expect(queries).to be_empty
        expect(changed).to eq(true)
      end
    end
  end

  describe 'validating' do
    xit 'does not add duplicate values' do
      pending 'uniq does not work correctly yet' do
        dba = Team.first
        dba.people = [Person.first, Person.find(2), Person.last]

        expect(dba.people.size).to eq 2
        expect(dba.person_ids).to eq [1,2]
      end
    end

    xit 'deferred habtm <=> regular habtm' do
      alice = Person.where(name: 'Alice').first
      bob = Person.where(name: 'Bob').first

      team = Team.first
      team.people << alice << bob
      team.save!

      bob.reload
      expect(bob.teams.size).to eq(1)

      alice.reload
      expect(alice.teams.size).to eq(1)

      team.people.create!(name: 'Chuck')
      expect(team).to_not be_valid

      bob.reload
      alice.reload

      expect(bob).to_not be_valid
      expect(alice).to_not be_valid

      expect(bob.save).to be_falsey
      expect(alice.save).to be_falsey
    end

    xit 'does not validate records when validate: false' do
      pending 'validate: false does not work' do
        alice = Person.where(name: 'Alice').first
        alice.teams.build(name: nil)
        alice.save!

        expect(alice.teams.size).to eq 1
      end
    end
  end

  describe 'preloading' do
    before do
      bob.teams << dba << support
      bob.save!
    end

    it 'loads the association when pre-loading' do
      person = Person.preload(:teams).where(name: 'Bob').first
      expect(person.teams.loaded?).to be_truthy
      expect(person.team_ids).to eq [dba.id, support.id]
    end

    it 'loads the association when eager loading' do
      person = Person.eager_load(:teams).where(name: 'Bob').first
      expect(person.teams.loaded?).to be_truthy
      expect(person.team_ids).to eq [dba.id, support.id]
    end

    it 'loads the association when joining' do
      person = Person.includes(:teams).where(name: 'Bob').first
      expect(person.teams.loaded?).to be_truthy
      expect(person.team_ids).to eq [dba.id, support.id]
    end

    it 'does not load the association when using a regular query' do
      person = Person.where(name: 'Bob').first
      expect(person.teams.loaded?).to be_falsey
    end
  end

  describe 'reloading' do
    before do
      bob.teams << operations
      bob.save!
    end

    it 'throws away unsaved changes when reloading the parent' do
      # Assign Bob to some teams, but reload Bob before saving. This should
      # remove the unsaved teams from the list of teams Bob is assigned to.
      bob.teams << dba << support
      bob.reload

      expect(bob.teams).to eq [operations]
      expect(bob.teams.pending_creates).to be_empty
    end

    it 'throws away unsaved changes when reloading the association' do
      # Assign Bob to some teams, but reload the association before saving Bob.
      bob.teams << dba << support
      bob.teams.reload

      expect(bob.teams).to eq [operations]
      expect(bob.teams.pending_creates).to be_empty
    end

    it 'loads changes saved on the other side of the association' do
      # The DBA team will add Bob to their team, without him knowing it!
      dba.people << bob

      # Bob does not know about the fact that he has been added to the DBA team.
      expect(bob.teams).to eq [operations]

      # After resetting Bob, the teams are retrieved from the database and Bob
      # finds out he is now also a team-member of team DBA!
      bob.teams.reload
      expect(bob.teams).to eq [operations, dba]
    end
  end

  describe 'resetting' do
    before do
      bob.teams << operations
      bob.save!
    end

    it 'throws away unsaved changes when resetting the association' do
      # Assign Bob to some teams, but reset the association before saving Bob.
      bob.teams << dba << support
      bob.teams.reset

      expect(bob.teams).to eq [operations]
      expect(bob.teams.pending_creates).to be_empty
    end

    it 'loads changes saved on the other side of the association' do
      # The DBA team will add Bob to their team, without him knowing it!
      dba.people << bob

      # Bob does not know about the fact that he has been added to the DBA team.
      expect(bob.teams).to eq [operations]

      # After resetting Bob, the teams are retrieved from the database and Bob
      # finds out he is now also a team-member of team DBA!
      bob.teams.reset
      expect(bob.teams).to eq [operations, dba]
    end
  end

  describe 'enumerable methods that conflict with ActiveRecord' do
    describe '#select' do
      before do
        bob.teams << dba << support << operations
        bob.save!
      end

      it 'selects specified columns directly from the database' do
        teams = bob.teams.select('name')

        expect(teams.map(&:name)).to eq ['Database Administration', 'End-User Support', 'Operations']
        expect(teams.map(&:id)).to eq [nil, nil, nil]
      end

      it 'calls a block on the deferred associations' do
        teams = bob.teams.select { |team| team.id == 1 }
        expect(teams.map(&:id)).to eq [1]
      end
    end

    describe 'find' do
      # TODO: Write some tests.
    end

    describe 'first' do
      # TODO: Write some tests.
    end
  end

  describe 'callbacks' do
    before(:example) do
      bob = Person.where(name: 'Bob').first
      bob.teams = [Team.find(3)]
      bob.save!
    end

    it 'calls the link callbacks when adding a record using <<' do
      bob = Person.where(name: 'Bob').first
      bob.teams << Team.find(1)

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before linking team 1',
        'After linking team 1'
      ])
    end

    it 'calls the link callbacks when adding a record using push' do
      bob = Person.where(name: 'Bob').first
      bob.teams.push(Team.find(1))

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before linking team 1',
        'After linking team 1'
      ])
    end

    it 'calls the link callbacks when adding a record using append' do
      bob = Person.where(name: 'Bob').first
      bob.teams.append(Team.find(1))

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before linking team 1',
        'After linking team 1'
      ])
    end

    it 'calls the link callbacks when adding a record using build' do
      bob = Person.where(name: 'Bob').first
      bob.teams.build(name: 'Foooo')

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before linking new team',
        'After linking new team'
      ])
    end

    it 'only calls the Rails callbacks when creating a record on the association using create' do
      bob = Person.where(name: 'Bob').first
      bob.teams.create(name: 'HR')

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before adding new team',
        'After adding team 4'
      ])
    end

    it 'only calls the Rails callbacks when creating a record on the association using create!' do
      bob = Person.where(name: 'Bob').first
      bob.teams.create!(name: 'HR')

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before adding new team',
        'After adding team 4'
      ])
    end

    it 'calls the unlink callbacks when removing a record using delete' do
      bob = Person.where(name: 'Bob').first
      bob.teams.delete(Team.find(3))

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before unlinking team 3',
        'After unlinking team 3'
      ])
    end

    it 'calls the unlink callbacks when removing a record using destroy' do
      bob = Person.where(name: 'Bob').first
      bob.teams.destroy(Team.find(3))

      expect(bob.audit_log.length).to eq(2)
      expect(bob.audit_log).to eq([
        'Before unlinking team 3',
        'After unlinking team 3'
      ])
    end

    it 'calls the regular Rails callbacks after saving' do
      bob = Person.where(name: 'Bob').first
      bob.teams = [Team.find(1), Team.find(3)]
      bob.save!

      bob = Person.where(name: 'Bob').first
      bob.teams.delete(Team.find(1))
      bob.teams << Team.find(2)
      bob.teams.build(name: 'Service Desk')
      bob.teams.destroy(Team.find(3))
      bob.save!

      expect(bob.audit_log.length).to eq(16)
      expect(bob.audit_log).to eq([
        'Before unlinking team 1', 'After unlinking team 1',
        'Before linking team 2',   'After linking team 2',
        'Before linking new team', 'After linking new team',
        'Before unlinking team 3', 'After unlinking team 3',
        'Before removing team 3',
        'Before removing team 1',
        'After removing team 3',
        'After removing team 1',
        'Before adding team 2',    'After adding team 2',
        'Before adding new team',  'After adding team 4'
      ])
    end
  end

  describe 'links & unlinks (aka pending creates and deletes)' do
    describe 'links' do
      it 'returns newly build records' do
        bob.teams.build(name: 'Service Desk')
        expect(bob.teams.links.size).to eq(1)
      end

      it 'does not return newly created records' do
        bob.teams.create!(name: 'Service Desk')
        expect(bob.teams.links).to be_empty
      end

      it 'returns associated records that need to be linked to parent' do
        bob.teams = [dba]
        expect(bob.teams.links).to eq [dba]
      end

      it 'does not return associated records that already have a link' do
        bob.teams = [dba]
        bob.save!

        bob.teams << operations

        expect(bob.teams.links).to_not include dba
        expect(bob.teams.links).to include operations
      end

      it 'does not return associated records that are to be deleted' do
        bob.teams = [dba, operations]
        bob.save!
        bob.teams.delete(dba)

        expect(bob.teams.links).to be_empty
      end

      it 'does not return a record that has just been removed (and has not been saved)' do
        bob.teams = [dba]
        bob.save!

        bob.teams.delete(dba)
        bob.teams << dba

        expect(bob.teams.unlinks).to be_empty
        expect(bob.teams.links).to be_empty
      end

      it 'does not load the objects if the original association has not been loaded' do
        bob.teams = [dba, operations]
        bob.save!
        bob = Person.where(name: 'Bob').first

        unlinks, queries = catch_queries { bob.teams.links }
        expect(queries).to be_empty
        expect(unlinks).to eq([])
      end
    end

    describe 'unlinks' do
      it 'returns associated records that need to be unlinked from parent' do
        bob.teams = [dba]
        bob.save!
        bob.teams.delete(dba)

        expect(bob.teams.unlinks).to eq [dba]
      end

      it 'returns an empty array when no records are to be deleted' do
        bob.teams = [dba]
        bob.save!

        expect(bob.teams.unlinks).to be_empty
      end

      it 'does not return a record that has just been added (and has not been saved)' do
        bob.teams = [dba]
        bob.teams.delete(dba)

        expect(bob.teams.unlinks).to be_empty
        expect(bob.teams.links).to be_empty
      end

      it 'does not load the objects if the original association has not been loaded' do
        bob.teams = [dba, operations]
        bob.save!
        bob = Person.where(name: 'Bob').first

        unlinks, queries = catch_queries { bob.teams.unlinks }
        expect(queries).to be_empty
        expect(unlinks).to eq([])
      end
    end
  end

  describe 'active record api' do
    # it 'should execute first on deferred association' do
    #   p = Person.first
    #   p.team_ids = [dba.id, support.id, operations.id]
    #   expect(p.teams.first).to eq(dba)
    #   p.save!
    #   expect(Person.first.teams.first).to eq(dba)
    # end

    # it 'should execute last on deferred association' do
    #   p = Person.first
    #   p.team_ids = [dba.id, support.id, operations.id]
    #   expect(p.teams.last).to eq(operations)
    #   p.save!
    #   expect(Person.first.teams.last).to eq(operations)
    # end

    describe '#build' do
      it 'builds a new record' do
        p = Person.first
        p.teams.build(name: 'Service Desk')

        expect(p.teams[0]).to be_new_record
        expect{ p.save }.to change{ Person.first.teams.count }.from(0).to(1)
      end
    end

    describe '#create!' do
      it 'should create a persisted record' do
        p = Person.first
        p.teams.create!(:name => 'Service Desk')
        expect(p.teams[0]).to_not be_new_record
      end

      it 'should automatically save the created record' do
        p = Person.first
        p.teams.create!(:name => 'Service Desk')
        expect(Person.first.teams.size).to eq(1)
        expect{ p.save }.to_not change{ Person.first.teams.count }
      end
    end

    describe '#destroy' do
      context 'when called on has_many association with dependent: :delete_all' do
        it 'destroys the records supplied and removes them from the collection' do
          printer = Issue.create!(subject: 'Printer PRT-001 jammed')
          database = Issue.create!(subject: 'Database server DB-1337 down')
          sandwich = Issue.create!(subject: 'Make me a sandwich!')

          bob.issues << printer << database << sandwich
          bob.save!

          expect {
            bob.issues.destroy(printer)
            bob.save!
          }.to change {
            Person.find(bob.id).issues.size
          }.from(3).to(2)
          expect { Issue.find(printer.id) }.to raise_error(ActiveRecord::RecordNotFound)
        end
      end

      context 'when called on has_many association without dependent: :delete_all' do
        it 'removes the records supplied from the collection' do
          printer = Issue.create!(subject: 'Printer PRT-001 jammed')
          database = Issue.create!(subject: 'Database server DB-1337 down')
          sandwich = Issue.create!(subject: 'Make me a sandwich!')

          bob.other_issues << printer << database << sandwich
          bob.save!

          expect {
            bob.other_issues.destroy(printer)
            bob.save!
          }.to change {
            Person.find(bob.id).other_issues.size
          }.from(3).to(2)
          expect(Issue.find(printer.id)).to eq(printer)
        end
      end

      context 'when called on has_and_belongs_to_many association' do
        it 'removes the records supplied from the collection' do
          bob.teams << dba << operations
          bob.save!

          expect {
            bob.teams.destroy(dba)
            bob.save!
          }.to change {
            Person.find(bob.id).teams.size
          }.from(2).to(1)
          expect(Team.find(dba.id)).to eq(dba)
        end
      end

      it 'returns an array with the removed records' do
        printer = Issue.create!(subject: 'Printer PRT-001 jammed')
        database = Issue.create!(subject: 'Database server DB-1337 down')
        sandwich = Issue.create!(subject: 'Make me a sandwich!')

        bob.issues << printer << database << sandwich
        bob.save!

        expect(bob.issues.destroy(database)).to eq([database])
      end

      it 'accepts Fixnum values' do
        bob.teams << dba << operations
        bob.save!

        expect {
          bob.teams.destroy(dba.id)
          bob.save!
        }.to change {
          Person.find(bob.id).teams.size
        }.from(2).to(1)
      end

      it 'accepts String values' do
        bob.teams << dba << operations
        bob.save!

        expect {
          bob.teams.destroy("#{dba.id}", "#{operations.id}")
          bob.save!
        }.to change {
          Person.find(bob.id).teams.size
        }.from(2).to(0)
      end
    end

    it 'should allow ActiveRecord::QueryMethods' do
      p = Person.first
      p.teams << dba << operations
      p.save!
      expect(Person.first.teams.where(name: 'Operations').first).to eq(operations)
    end

    it 'should find one without loading collection' do
      p = Person.first
      p.teams = [Team.first, Team.find(3)]
      p.save!

      p = Person.first
      _, queries = catch_queries { p.teams }
      expect(queries).to be_empty
      expect(p.teams.loaded?).to eq(false)

      team, queries = catch_queries { p.teams.find(3) }
      expect(queries.size).to eq(1)
      expect(team).to eq(Team.find(3))
      expect(p.teams.loaded?).to eq(false)

      team, queries = catch_queries { p.teams.first }
      expect(queries.size).to eq(1)
      expect(team).to eq(Team.first)
      expect(p.teams.loaded?).to eq(false)

      team, queries = catch_queries { p.teams.last }
      expect(queries.size).to eq(1)
      expect(team).to eq(Team.find(3))
      expect(p.teams.loaded?).to eq(false)
    end
  end
end