// Copyright 2012 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

goog.provide('goog.dbTest');
goog.setTestOnly('goog.dbTest');

goog.require('goog.Disposable');
goog.require('goog.Promise');
goog.require('goog.array');
goog.require('goog.db');
goog.require('goog.db.Cursor');
goog.require('goog.db.Error');
goog.require('goog.db.IndexedDb');
goog.require('goog.db.KeyRange');
goog.require('goog.db.Transaction');
goog.require('goog.events');
goog.require('goog.object');
goog.require('goog.testing.PropertyReplacer');
goog.require('goog.testing.asserts');
goog.require('goog.testing.jsunit');
goog.require('goog.userAgent.product');

var idbSupported = goog.userAgent.product.CHROME;
var dbName;
var dbBaseName = 'testDb';
var globalDb = null;
var dbsToClose = [];
var propertyReplacer;

var baseVersion = 1;

var dbVersion = 1;

var TransactionMode = goog.db.Transaction.TransactionMode;
var EventTypes = goog.db.Transaction.EventTypes;

function openDatabase() {
  return goog.db.openDatabase(dbName).addCallback(function(db) {
    dbsToClose.push(db);
  });
}

function incrementVersion(db, onUpgradeNeeded) {
  db.close();

  var onBlocked = function(ev) {
    fail('Upgrade to version ' + dbVersion + ' is blocked.');
  };

  return goog.db.openDatabase(dbName, ++dbVersion, onUpgradeNeeded, onBlocked)
      .addCallback(function(db) {
        dbsToClose.push(db);
        assertEquals(dbVersion, db.getVersion());
      });
}

function addStore(db) {
  return incrementVersion(
      db, function(ev, db, tx) { db.createObjectStore('store'); });
}

function addStoreWithIndex(db) {
  return incrementVersion(db, function(ev, db, tx) {
    var store = db.createObjectStore('store', {keyPath: 'key'});
    store.createIndex('index', 'value');
  });
}

function populateStore(values, keys, db) {
  var putTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
  var store = putTx.objectStore('store');
  for (var i = 0; i < values.length; ++i) {
    store.put(values[i], keys[i]);
  }
  return putTx.wait();
}

function populateStoreWithObjects(values, keys, db) {
  var putTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
  var store = putTx.objectStore('store');
  goog.array.forEach(values, function(value, index) {
    store.put({'key': keys[index], 'value': value});
  });
  return putTx.wait();
}

function assertStoreValues(values, db) {
  var assertStoreTx = db.createTransaction(['store']);
  assertStoreTx.objectStore('store').getAll().addCallback(function(results) {
    assertSameElements(values, results);
  });
}

function assertStoreObjectValues(values, db) {
  var assertStoreTx = db.createTransaction(['store']);
  assertStoreTx.objectStore('store').getAll().addCallback(function(results) {
    var retrievedValues =
        goog.array.map(results, function(result) { return result['value']; });
    assertSameElements(values, retrievedValues);
  });
}

function assertStoreValuesAndCursorsDisposed(values, cursors, db) {
  var assertStoreTx = db.createTransaction(['store']);
  assertStoreTx.objectStore('store').getAll().addCallback(function(results) {
    assertSameElements(values, results);
    assertTrue(cursors.length > 0);
    goog.array.forEach(cursors, function(elem, index, array) {
      console.log(elem);
      assertTrue(
          'array[' + index + '] (' + elem + ') is not disposed',
          goog.Disposable.isDisposed(elem));
    });
  });
}

function assertStoreDoesntExist(db) {
  try {
    db.createTransaction(['store']);
    fail('Create transaction with a non-existent store should have failed.');
  } catch (e) {
    // expected
    assertEquals(e.getName(), goog.db.Error.ErrorName.NOT_FOUND_ERR);
  }
}

function transactionToPromise(db, trx) {
  return new goog.Promise(function(resolve, reject) {
    goog.events.listen(trx, EventTypes.ERROR, reject);
    goog.events.listen(trx, EventTypes.COMPLETE, function() { resolve(db); });
  });
}

// Calls onRecordReady each time that a new record can be read by the
// cursor with cursor.next(). Returns a promise that resolves or rejects
// based on when the cursor is complete or errors out. If onRecordReady
// returns a promise that promises is also waited on before the returned
// promise resolves.
function forEachRecord(cursor, onRecordReady) {
  var promises = [];
  return new goog
      .Promise(function(resolve, reject) {
        var key = goog.events.listen(
            cursor, goog.db.Cursor.EventType.NEW_DATA, function() {
              var result = onRecordReady();
              if (result && ('then' in result)) {
                promises.push(result);
              }
            });

        goog.events.listenOnce(
            cursor,
            [goog.db.Cursor.EventType.COMPLETE, goog.db.Cursor.EventType.ERROR],
            function(evt) {
              goog.events.unlistenByKey(key);
              if (evt.type == goog.db.Cursor.EventType.COMPLETE) {
                resolve();
              } else {
                reject(evt);
              }
            });
      })
      .then(function() { return goog.Promise.all(promises); });
}

function failOnErrorEvent(ev) {
  fail(ev.target.message);
}

function setUpPage() {
  propertyReplacer = new goog.testing.PropertyReplacer();
}

function setUp() {
  if (!idbSupported) {
    return;
  }

  // Always use a clean database by generating a new database name.
  dbName = dbBaseName + Date.now().toString();
  globalDb = openDatabase();
}

function tearDown() {
  for (var i = 0; i < dbsToClose.length; i++) {
    dbsToClose[i].close();
  }
  dbsToClose = [];
  propertyReplacer.reset();
}

function testDatabaseOpened() {
  if (!idbSupported) {
    return;
  }

  assertNotNull(globalDb);
  return globalDb.branch().addCallback(function(db) {
    assertTrue(db.isOpen());
  });
}

function testOpenWithNewVersion() {
  if (!idbSupported) {
    return;
  }

  var upgradeNeeded = false;
  return globalDb.branch()
      .addCallback(function(db) {
        assertEquals(baseVersion, db.getVersion());
        return incrementVersion(
            db, function(ev, db, tx) { upgradeNeeded = true; });
      })
      .addCallback(function(db) { assertTrue(upgradeNeeded); });
}

function testManipulateObjectStores() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        assertEquals(baseVersion, db.getVersion());
        return incrementVersion(db, function(ev, db, tx) {
          db.createObjectStore('basicStore');
          db.createObjectStore('keyPathStore', {keyPath: 'keyGoesHere'});
          db.createObjectStore('autoIncrementStore', {autoIncrement: true});
        });
      })
      .addCallback(function(db) {
        var storeNames = db.getObjectStoreNames();
        assertEquals(3, storeNames.length);
        assertTrue(storeNames.contains('basicStore'));
        assertTrue(storeNames.contains('keyPathStore'));
        assertTrue(storeNames.contains('autoIncrementStore'));
        return incrementVersion(
            db, function(ev, db, tx) { db.deleteObjectStore('basicStore'); });
      })
      .addCallback(function(db) {
        var storeNames = db.getObjectStoreNames();
        assertEquals(2, storeNames.length);
        assertFalse(storeNames.contains('basicStore'));
        assertTrue(storeNames.contains('keyPathStore'));
        assertTrue(storeNames.contains('autoIncrementStore'));
      });
}

function testBadObjectStoreManipulation() {
  if (!idbSupported) {
    return;
  }

  var expectedCode = goog.db.Error.ErrorName.INVALID_STATE_ERR;
  return globalDb.branch()
      .addCallback(function(db) {
        try {
          db.createObjectStore('diediedie');
          fail('Create object store outside transaction should have failed.');
        } catch (err) {
          // expected
          assertEquals(expectedCode, err.getName());
        }
      })
      .addCallback(addStore)
      .addCallback(function(db) {
        try {
          db.deleteObjectStore('store');
          fail('Delete object store outside transaction should have failed.');
        } catch (err) {
          // expected
          assertEquals(expectedCode, err.getName());
        }
      })
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          try {
            db.deleteObjectStore('diediedie');
            fail('Delete non-existent store should have failed.');
          } catch (err) {
            // expected
            assertEquals(goog.db.Error.ErrorName.NOT_FOUND_ERR, err.getName());
          }
        });
      });
}

function testGetNonExistentObjectStore() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch().addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store']);
    try {
      tx.objectStore('diediedie');
      fail('getting non-existent object store should have failed');
    } catch (err) {
      assertEquals(goog.db.Error.ErrorName.NOT_FOUND_ERR, err.getName());
    }
  });
}

function testCreateTransaction() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch().addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store']);
    assertEquals('mode not READ_ONLY', TransactionMode.READ_ONLY, tx.getMode());
    tx = db.createTransaction(
        ['store'], goog.db.Transaction.TransactionMode.READ_WRITE);
    assertEquals(
        'mode not READ_WRITE', TransactionMode.READ_WRITE, tx.getMode());
  });
}

function testPutRecord() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(db) {
        var initialPutTx =
            db.createTransaction(['store'], TransactionMode.READ_WRITE);
        var putOperation = initialPutTx.objectStore('store').put(
            {key: 'initial', value: 'value1'}, 'putKey');
        putOperation.addCallback(function(key) {
          assertEquals('putKey', key);
        });
        return transactionToPromise(db, initialPutTx);
      })
      .addCallback(function(db) {
        var checkResultsTx = db.createTransaction(['store']);
        var getOperation = checkResultsTx.objectStore('store').get('putKey');
        getOperation.addCallback(function(result) {
          assertEquals('initial', result.key);
          assertEquals('value1', result.value);
        });
        return transactionToPromise(db, checkResultsTx);
      })
      .addCallback(function(db) {
        var overwriteTx =
            db.createTransaction(['store'], TransactionMode.READ_WRITE);
        var putOperation = overwriteTx.objectStore('store').put(
            {key: 'overwritten', value: 'value2'}, 'putKey');
        putOperation.addCallback(function(key) {
          assertEquals('putKey', key);
        });
        return transactionToPromise(db, overwriteTx);
      })
      .addCallback(function(db) {
        var checkOverwriteTx = db.createTransaction(['store']);
        checkOverwriteTx.objectStore('store').get('putKey').addCallback(
            function(result) {
              // this is guaranteed to run before the COMPLETE event fires on
              // the transaction
              assertEquals('overwritten', result.key);
              assertEquals('value2', result.value);
            });

        return transactionToPromise(db, checkOverwriteTx);
      });
}

function testAddRecord() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(db) {
        var initialAddTx =
            db.createTransaction(['store'], TransactionMode.READ_WRITE);
        var addOperation = initialAddTx.objectStore('store').add(
            {key: 'hi', value: 'something'}, 'stuff');
        addOperation.addCallback(function(key) {
          assertEquals('stuff', key);
        });
        return transactionToPromise(db, initialAddTx);
      })
      .addCallback(function(db) {
        var successfulAddTx = db.createTransaction(['store']);
        var getOperation = successfulAddTx.objectStore('store').get('stuff');
        getOperation.addCallback(function(result) {
          assertEquals('hi', result.key);
          assertEquals('something', result.value);
        });
        return transactionToPromise(db, successfulAddTx);
      })
      .addCallback(function(db) {
        var addOverwriteTx =
            db.createTransaction(['store'], TransactionMode.READ_WRITE);
        addOverwriteTx.objectStore('store')
            .add({key: 'bye', value: 'nothing'}, 'stuff')
            .addErrback(function(err) {
              // expected
              assertEquals(
                  goog.db.Error.ErrorName.CONSTRAINT_ERR, err.getName());
            });
        return transactionToPromise(db, addOverwriteTx)
            .then(
                function() {
                  fail('adding existing record should not have succeeded');
                },
                function(ev) {
                  // expected
                  assertEquals(
                      goog.db.Error.ErrorName.CONSTRAINT_ERR,
                      ev.target.getName());
                });
      });
}

function testPutRecordKeyPathStore() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          db.createObjectStore('keyStore', {keyPath: 'key'});
        });
      })
      .addCallback(function(db) {
        var putTx =
            db.createTransaction(['keyStore'], TransactionMode.READ_WRITE);
        var putOperation =
            putTx.objectStore('keyStore').put({key: 'hi', value: 'something'});
        putOperation.addCallback(function(key) {
          assertEquals('hi', key);
        });
        return transactionToPromise(db, putTx);
      })
      .addCallback(function(db) {
        var checkResultsTx = db.createTransaction(['keyStore']);
        checkResultsTx.objectStore('keyStore')
            .get('hi')
            .addCallback(function(result) {
              assertNotUndefined(result);
              assertEquals('hi', result.key);
              assertEquals('something', result.value);
            });
        return transactionToPromise(db, checkResultsTx);
      });
}

function testPutBadRecordKeyPathStore() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          db.createObjectStore('keyStore', {keyPath: 'key'});
        });
      })
      .addCallback(function(db) {
        var badTx =
            db.createTransaction(['keyStore'], TransactionMode.READ_WRITE);
        return badTx.objectStore('keyStore')
            .put({key: 'diedie', value: 'anything'}, 'badKey')
            .then(
                function() {
                  fail('inserting with explicit key should have failed');
                },
                function(err) {
                  // expected
                  assertEquals(goog.db.Error.ErrorName.DATA_ERR, err.getName());
                });
      });
}

function testPutRecordAutoIncrementStore() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          db.createObjectStore('aiStore', {autoIncrement: true});
        });
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['aiStore'], TransactionMode.READ_WRITE);
        var putOperation1 = tx.objectStore('aiStore').put('1');
        var putOperation2 = tx.objectStore('aiStore').put('2');
        var putOperation3 = tx.objectStore('aiStore').put('3');
        putOperation1.addCallback(function(key) {
          assertNotUndefined(key);
        });
        putOperation2.addCallback(function(key) {
          assertNotUndefined(key);
        });
        putOperation3.addCallback(function(key) {
          assertNotUndefined(key);
        });
        return transactionToPromise(db, tx);
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['aiStore']);
        var getAllOperation = tx.objectStore('aiStore').getAll();
        return getAllOperation.addCallback(function(results) {
          assertEquals(3, results.length);
          // only checking to see if the results are included because the keys
          // are not specified
          assertNotEquals(-1, results.indexOf('1'));
          assertNotEquals(-1, results.indexOf('2'));
          assertNotEquals(-1, results.indexOf('3'));
        });
      });
}

function testPutRecordKeyPathAndAutoIncrementStore() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          db.createObjectStore(
              'hybridStore', {keyPath: 'key', autoIncrement: true});
        });
      })
      .addCallback(function(db) {
        var tx =
            db.createTransaction(['hybridStore'], TransactionMode.READ_WRITE);
        var putOperation =
            tx.objectStore('hybridStore').put({value: 'whatever'});
        putOperation.addCallback(function(key) {
          assertNotUndefined(key);
        });
        return putOperation.addCallback(function() {
          return db;
        });
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['hybridStore']);
        return tx.objectStore('hybridStore').getAll().then(function(results) {
          assertEquals(1, results.length);
          assertEquals('whatever', results[0].value);
          assertNotUndefined(results[0].key);
        });
      });
}

function testPutIllegalRecords() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch().addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);

    var promises = [];
    var badKeyFail = function(keyKind) {
      return function() {
        return fail('putting with ' + keyKind + ' key should have failed');
      }
    };
    var assertExpectedError = function(err) {
      assertEquals(goog.db.Error.ErrorName.DATA_ERR, err.getName());
    };

    promises.push(
        tx.objectStore('store')
            .put('death', null)
            .then(badKeyFail('null'), assertExpectedError));

    promises.push(
        tx.objectStore('store')
            .put('death', NaN)
            .then(badKeyFail('NaN'), assertExpectedError));

    promises.push(
        tx.objectStore('store')
            .put('death', undefined)
            .then(badKeyFail('undefined'), assertExpectedError));

    return goog.Promise.all(promises);
  });
}

function testPutIllegalRecordsWithIndex() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(function(db) {
        var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
        var promises = [];
        var badKeyFail = function(keyKind) {
          return function() {
            fail('putting with ' + keyKind + ' key should have failed');
          }
        };
        var assertExpectedError = function(err) {
          // expected
          assertEquals(goog.db.Error.ErrorName.DATA_ERR, err.getName());
        };

        promises.push(
            tx.objectStore('store')
                .put({value: 'diediedie', key: null})
                .then(badKeyFail('null'), assertExpectedError));

        promises.push(
            tx.objectStore('store')
                .put({value: 'dietodeath', key: NaN})
                .then(badKeyFail('NaN'), assertExpectedError));
        promises.push(
            tx.objectStore('store')
                .put({value: 'dietodeath', key: undefined})
                .then(badKeyFail('undefined'), assertExpectedError));

        return goog.Promise.all(promises);
      });
}

function testDeleteRecord() {
  if (!idbSupported) {
    return;
  }

  var db;
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(openedDb) {
        db = openedDb;
        return db.createTransaction(['store'], TransactionMode.READ_WRITE)
            .objectStore('store')
            .put({key: 'hi', value: 'something'}, 'stuff');
      })
      .addCallback(function() {
        return db.createTransaction(['store'], TransactionMode.READ_WRITE)
            .objectStore('store')
            .remove('stuff');
      })
      .addCallback(function() {
        return db.createTransaction(['store']).objectStore('store').get(
            'stuff');
      })
      .addCallback(function(result) { assertUndefined(result); });
}

function testDeleteRange() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3'];
  var keys = ['a', 'b', 'c'];

  var addData = goog.partial(populateStore, values, keys);
  var checkStore = goog.partial(assertStoreValues, ['1']);

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(function(db) {
        return db.createTransaction(['store'], TransactionMode.READ_WRITE)
            .objectStore('store')
            .remove(goog.db.KeyRange.bound('b', 'c'))
            .then(function() {
              return db;
            });
      })
      .addCallback(checkStore);
}

function testGetAll() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3'];
  var keys = ['a', 'b', 'c'];

  var addData = goog.partial(populateStore, values, keys);
  var checkStore = goog.partial(assertStoreValues, values);

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(checkStore);
}

function testGetAllFreesCursor() {
  if (!idbSupported) {
    return;
  }
  var values = ['1', '2', '3'];
  var keys = ['a', 'b', 'c'];

  var addData = goog.partial(populateStore, values, keys);
  var origCursor = goog.db.Cursor;
  var cursors = [];
  /** @constructor */
  var testCursor = function() {
    origCursor.call(this);
    cursors.push(this);
  };
  goog.object.extend(testCursor, origCursor);
  // We don't use goog.inherits here because we are going to be overwriting
  // goog.db.Cursor and we don't want a new "base" method as
  // goog.db.Cursor.base(this, 'constructor') would be a call to
  // testCursor.base(this, 'constructor') which would be goog.db.Cursor and be
  // an infinite loop.
  testCursor.prototype = origCursor.prototype;
  propertyReplacer.replace(goog.db, 'Cursor', testCursor);
  var checkStoreAndCursorDisposed =
      goog.partial(assertStoreValuesAndCursorsDisposed, values, cursors);

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(checkStoreAndCursorDisposed);
}

function testObjectStoreCursorGet() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStore, values, keys);
  var db;

  var resultValues = [];
  var resultKeys = [];
  // Open the cursor over range ['b', 'c'], move in backwards direction.
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(function(theDb) {
        db = theDb;
        var cursorTx = db.createTransaction(['store']);
        var store = cursorTx.objectStore('store');

        var cursor = store.openCursor(
            goog.db.KeyRange.bound('b', 'c'), goog.db.Cursor.Direction.PREV);

        var whenCursorComplete = forEachRecord(cursor, function() {
          resultValues.push(cursor.getValue());
          resultKeys.push(cursor.getKey());
          cursor.next();
        });

        return goog.Promise.all([cursorTx.wait(), whenCursorComplete]);
      })
      .addCallback(function() {
        assertArrayEquals(['3', '2'], resultValues);
        assertArrayEquals(['c', 'b'], resultKeys);
      });
}

function testObjectStoreCursorReplace() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStore, values, keys);

  // Store should contain ['1', '2', '5', '4'] after replacement.
  var checkStore = goog.partial(assertStoreValues, ['1', '2', '5', '4']);

  // Use a bounded cursor for ('b', 'c'] to update value '3' -> '5'.
  var openCursorAndReplace = function(db) {
    var cursorTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    var store = cursorTx.objectStore('store');

    var cursor = store.openCursor(goog.db.KeyRange.bound('b', 'c', true));
    var whenCursorComplete = forEachRecord(cursor, function() {
      assertEquals('3', cursor.getValue());
      return cursor.update('5').addCallback(function() { cursor.next(); });
    });

    return goog.Promise.all([cursorTx.wait(), whenCursorComplete])
        .then(function() { return db; });
  };

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(openCursorAndReplace)
      .addCallback(checkStore);
}

function testObjectStoreCursorRemove() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStore, values, keys);

  // Store should contain ['1', '2'] after removing elements.
  var checkStore = goog.partial(assertStoreValues, ['1', '2']);

  // Use a bounded cursor for ('b', ...) to remove '3', '4'.
  var openCursorAndRemove = function(db) {
    var cursorTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);

    var store = cursorTx.objectStore('store');
    var cursor = store.openCursor(goog.db.KeyRange.lowerBound('b', true));
    var whenCursorComplete = forEachRecord(cursor, function() {
      return cursor.remove('5').addCallback(function() { cursor.next(); });
    });
    return goog.Promise.all([cursorTx.wait(), whenCursorComplete])
        .then(function(results) { return db; });
  };

  // Setup and execute test case.
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(openCursorAndRemove)
      .addCallback(checkStore);
}

function testClear() {
  if (!idbSupported) {
    return;
  }

  var db;
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(theDb) {
        db = theDb;

        var putTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
        putTx.objectStore('store').put('1', 'a');
        putTx.objectStore('store').put('2', 'b');
        putTx.objectStore('store').put('3', 'c');
        return putTx.wait();
      })
      .addCallback(function() {
        return db.createTransaction(['store']).objectStore('store').getAll();
      })
      .addCallback(function(results) {
        assertEquals(3, results.length);
        return db.createTransaction(['store'], TransactionMode.READ_WRITE)
            .objectStore('store')
            .clear();
      })
      .addCallback(function() {
        return db.createTransaction(['store']).objectStore('store').getAll();
      })
      .addCallback(function(results) { assertEquals(0, results.length); });
}

function testAbortTransaction() {
  if (!idbSupported) {
    return;
  }

  var db;
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(theDb) {
        db = theDb;
        return new Promise(function(resolve, reject) {
          var abortTx =
              db.createTransaction(['store'], TransactionMode.READ_WRITE);
          abortTx.objectStore('store')
              .put('data', 'stuff')
              .addCallback(function() { abortTx.abort(); });
          goog.events.listen(abortTx, EventTypes.ERROR, reject);

          goog.events.listen(abortTx, EventTypes.COMPLETE, function() {
            fail(
                'transaction shouldn\'t have' +
                ' completed after being aborted');
          });

          goog.events.listen(abortTx, EventTypes.ABORT, resolve);
        });
      })
      .addCallback(function() {
        var checkResultsTx = db.createTransaction(['store']);
        return checkResultsTx.objectStore('store').get('stuff');
      })
      .addCallback(function(result) { assertUndefined(result); });
}

function testInactiveTransaction() {
  if (!idbSupported) {
    return;
  }

  var db;
  var store;
  var index;
  var createAndFinishTransaction = function(theDb) {
    db = theDb;
    var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    store = tx.objectStore('store');
    index = store.getIndex('index');
    store.put({key: 'something', value: 'anything'});
    return tx.wait();
  };

  var assertCantUseInactiveTransaction = function() {
    var expectedCode = goog.db.Error.ErrorName.TRANSACTION_INACTIVE_ERR;
    var promises = [];

    var failOp = function(op) {
      return function() {
        fail(op + ' with inactive transaction should have failed');
      };
    };
    var assertCorrectError = function(err) {
      assertEquals(expectedCode, err.getName());
    };
    promises.push(
        store.put({key: 'another', value: 'thing'})
            .then(failOp('putting'), assertCorrectError));
    promises.push(
        store.add({key: 'another', value: 'thing'})
            .then(failOp('adding'), assertCorrectError));
    promises.push(
        store.remove('something').then(failOp('deleting'), assertCorrectError));
    promises.push(
        store.get('something').then(failOp('getting'), assertCorrectError));
    promises.push(
        store.getAll().then(failOp('getting all'), assertCorrectError));
    promises.push(
        store.clear().then(failOp('clearing all'), assertCorrectError));

    promises.push(
        index.get('anything')
            .then(failOp('getting from index'), assertCorrectError));
    promises.push(
        index.getKey('anything')
            .then(failOp('getting key from index'), assertCorrectError));
    promises.push(
        index.getAll('anything')
            .then(failOp('getting all from index'), assertCorrectError));
    promises.push(
        index.getAllKeys('anything')
            .then(failOp('getting all keys from index'), assertCorrectError));
    return goog.Promise.all(promises);
  };

  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(createAndFinishTransaction)
      .addCallback(assertCantUseInactiveTransaction);
}

function testWrongTransactionMode() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch().addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store']);
    assertEquals(goog.db.Transaction.TransactionMode.READ_ONLY, tx.getMode());
    var promises = [];
    promises.push(
        tx.objectStore('store')
            .put('KABOOM!', 'anything')
            .then(
                function() { fail('putting should have failed'); },
                function(err) {
                  assertEquals(
                      goog.db.Error.ErrorName.READ_ONLY_ERR, err.getName());
                }));
    promises.push(
        tx.objectStore('store')
            .add('EXPLODE!', 'die')
            .then(
                function() { fail('adding should have failed'); },
                function(err) {
                  assertEquals(
                      goog.db.Error.ErrorName.READ_ONLY_ERR, err.getName());
                }));
    promises.push(
        tx.objectStore('store')
            .remove('no key', 'nothing')
            .then(
                function() { fail('deleting should have failed'); },
                function(err) {
                  assertEquals(
                      goog.db.Error.ErrorName.READ_ONLY_ERR, err.getName());
                }));
    return goog.Promise.all(promises);
  });
}

function testManipulateIndexes() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          var store = db.createObjectStore('store');
          store.createIndex('index', 'attr1');
          store.createIndex('uniqueIndex', 'attr2', {unique: true});
          store.createIndex('multirowIndex', 'attr3', {multirow: true});
        });
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['store']);
        var store = tx.objectStore('store');
        var index = store.getIndex('index');
        var uniqueIndex = store.getIndex('uniqueIndex');
        var multirowIndex = store.getIndex('multirowIndex');
        try {
          var dies = store.getIndex('diediedie');
          fail('getting non-existent index should have failed');
        } catch (err) {
          // expected
          assertEquals(goog.db.Error.ErrorName.NOT_FOUND_ERR, err.getName());
        }

        return tx.wait();
      })
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          var store = tx.objectStore('store');
          store.deleteIndex('index');
          try {
            store.deleteIndex('diediedie');
            fail('deleting non-existent index should have failed');
          } catch (err) {
            // expected
            assertEquals(goog.db.Error.ErrorName.NOT_FOUND_ERR, err.getName());
          }
        });
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['store']);
        var store = tx.objectStore('store');
        try {
          var index = store.getIndex('index');
          fail('getting deleted index should have failed');
        } catch (err) {
          // expected
          assertEquals(goog.db.Error.ErrorName.NOT_FOUND_ERR, err.getName());
        }
        var uniqueIndex = store.getIndex('uniqueIndex');
        var multirowIndex = store.getIndex('multirowIndex');
      });
}

function testAddRecordWithIndex() {
  if (!idbSupported) {
    return;
  }

  var addData = function(db) {
    var store = db.createTransaction(['store'], TransactionMode.READ_WRITE)
                    .objectStore('store');
    assertFalse(store.getIndex('index').isUnique());
    assertEquals('value', store.getIndex('index').getKeyPath());
    return store.add({key: 'someKey', value: 'lookUpThis'})
        .addCallback(function() { return db; });
  };
  var readAndAssertAboutData = function(db) {
    var index =
        db.createTransaction(['store']).objectStore('store').getIndex('index');
    var promises = [
      index.get('lookUpThis').addCallback(function(result) {
        assertNotUndefined(result);
        assertEquals('someKey', result.key);
        assertEquals('lookUpThis', result.value);
      }),
      index.getKey('lookUpThis').addCallback(function(result) {
        assertNotUndefined(result);
        assertEquals('someKey', result);
      })
    ];
    return goog.Promise.all(promises).then(function() { return db; });
  };
  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(addData)
      .addCallback(readAndAssertAboutData);
}

function testGetMultipleRecordsFromIndex() {
  if (!idbSupported) {
    return;
  }

  var addData = function(db) {
    var addTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    addTx.objectStore('store').add({key: '1', value: 'a'});
    addTx.objectStore('store').add({key: '2', value: 'a'});
    addTx.objectStore('store').add({key: '3', value: 'b'});

    return addTx.wait();
  };
  var readData = function(db) {
    var index =
        db.createTransaction(['store']).objectStore('store').getIndex('index');
    var promises = [];
    promises.push(index.getAll().addCallback(function(results) {
      assertNotUndefined(results);
      assertEquals(3, results.length);
    }));
    promises.push(index.getAll('a').addCallback(function(results) {
      assertNotUndefined(results);
      assertEquals(2, results.length);
    }));
    promises.push(index.getAllKeys().addCallback(function(results) {
      assertNotUndefined(results);
      assertEquals(3, results.length);
      assertArrayEquals(['1', '2', '3'], results);
    }));
    promises.push(index.getAllKeys('b').addCallback(function(results) {
      assertNotUndefined(results);
      assertEquals(1, results.length);
      assertArrayEquals(['3'], results);
    }));

    return goog.Promise.all(promises).then(function() { return db; });
  };
  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(addData)
      .addCallback(readData);
}

function testUniqueIndex() {
  if (!idbSupported) {
    return;
  }

  var storeDuplicatesToUniqueIndex = function(db) {
    var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    assertTrue(tx.objectStore('store').getIndex('index').isUnique());
    tx.objectStore('store').add({key: '1', value: 'a'});
    tx.objectStore('store').add({key: '2', value: 'a'});
    return transactionToPromise(db, tx).then(
        function() {
          fail('Expected transaction violating unique constraint to fail');
        },
        function(ev) {
          // expected
          assertEquals(
              goog.db.Error.ErrorName.CONSTRAINT_ERR, ev.target.getName());
        });
  };

  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          var store = db.createObjectStore('store', {keyPath: 'key'});
          store.createIndex('index', 'value', {unique: true});
        });
      })
      .addCallback(storeDuplicatesToUniqueIndex);
}

function testDeleteDatabase() {
  if (!idbSupported) {
    return;
  }

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(db) {
        db.close();
        return goog.db.deleteDatabase(dbName, function() {
          fail('didn\'t expect deleteDatabase to be blocked');
        });
      })
      .addCallback(openDatabase)
      .addCallback(assertStoreDoesntExist);
}

function testDeleteDatabaseIsBlocked() {
  if (!idbSupported) {
    return;
  }

  var wasBlocked = false;
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(db) {
        db.close();
        // Get a fresh connection, without any events registered on globalDb.
        return goog.db.openDatabase(dbName);
      })
      .addCallback(function(db) {
        dbsToClose.push(db);
        return goog.db.deleteDatabase(dbName, function(ev) {
          wasBlocked = true;
          db.close();
        });
      })
      .addCallback(function() {
        assertTrue(wasBlocked);
        return openDatabase();
      })
      .addCallback(assertStoreDoesntExist);
}

function testBlockedDeleteDatabaseWithVersionChangeEvent() {
  if (!idbSupported) {
    return;
  }

  var gotVersionChange = false;
  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(function(db) {
        db.close();
        // Get a fresh connection, without any events registered on globalDb.
        return goog.db.openDatabase(dbName);
      })
      .addCallback(function(db) {
        dbsToClose.push(db);
        goog.events.listen(
            db, goog.db.IndexedDb.EventType.VERSION_CHANGE, function(ev) {
              gotVersionChange = true;
              db.close();
            });
        return goog.db.deleteDatabase(dbName);
      })
      .addCallback(function() {
        assertTrue(gotVersionChange);
        return openDatabase();
      })
      .addCallback(assertStoreDoesntExist);
}

function testDeleteNonExistentDatabase() {
  if (!idbSupported) {
    return;
  }

  // Deleting non-existent db is a no-op.  Shall not throw anything.
  return globalDb.branch().addCallback(function(db) {
    db.close();
    return goog.db.deleteDatabase('non-existent-db');
  });
}

function testObjectStoreCountAll() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStore, values, keys);

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(function(db) {
        var tx = db.createTransaction(['store']);
        return tx.objectStore('store').count().addCallback(function(count) {
          assertEquals(values.length, count);
        });
      });
}


function testObjectStoreCountSome() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStore, values, keys);
  var countData = function(db) {
    var tx = db.createTransaction(['store']);
    return tx.objectStore('store')
        .count(goog.db.KeyRange.bound('b', 'c'))
        .addCallback(function(count) { assertEquals(2, count); });
  };

  return globalDb.branch()
      .addCallback(addStore)
      .addCallback(addData)
      .addCallback(countData);
}

function testIndexCursorGet() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];
  var addData = goog.partial(populateStoreWithObjects, values, keys);

  var valuesResult = [];
  var keysResult = [];

  // Open the cursor over range ['b', 'c'], move in backwards direction.
  var walkBackwardsOverCursor = function(db) {
    var cursorTx = db.createTransaction(['store']);
    var index = cursorTx.objectStore('store').getIndex('index');
    var values = [];
    var keys = [];

    var cursor = index.openCursor(
        goog.db.KeyRange.bound('2', '3'), goog.db.Cursor.Direction.PREV);
    var cursorFinished = forEachRecord(cursor, function() {
      valuesResult.push(cursor.getValue()['value']);
      keysResult.push(cursor.getValue()['key']);
      cursor.next();
    });

    return goog.Promise.all([cursorFinished, cursorTx.wait()]).then(function() {
      return db
    });
  };

  return globalDb.branch()
      .addCallbacks(addStoreWithIndex)
      .addCallback(addData)
      .addCallback(walkBackwardsOverCursor)
      .addCallback(function(db) {
        assertArrayEquals(['3', '2'], valuesResult);
        assertArrayEquals(['c', 'b'], keysResult);
      });
}

function testIndexCursorReplace() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStoreWithObjects, values, keys);
  var valuesResult = [];
  var keysResult = [];

  // Store should contain ['1', '2', '5', '4'] after replacement.
  var checkStore = goog.partial(assertStoreObjectValues, ['1', '2', '5', '4']);

  // Use a bounded cursor for ['3', '4') to update value '3' -> '5'.
  var openCursorAndReplace = function(db) {
    var cursorTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    var index = cursorTx.objectStore('store').getIndex('index');
    var cursor =
        index.openCursor(goog.db.KeyRange.bound('3', '4', false, true));

    var cursorFinished = forEachRecord(cursor, function() {
      assertEquals('3', cursor.getValue()['value']);
      return cursor.update({'key': cursor.getValue()['key'], 'value': '5'})
          .addCallback(function() { cursor.next(); });
    });

    return goog.Promise.all([cursorFinished, cursorTx.wait()])
        .then(function(results) { return db; });
  };

  // Setup and execute test case.
  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(addData)
      .addCallback(openCursorAndReplace)
      .addCallback(checkStore);
}

function testIndexCursorRemove() {
  if (!idbSupported) {
    return;
  }

  var values = ['1', '2', '3', '4'];
  var keys = ['a', 'b', 'c', 'd'];

  var addData = goog.partial(populateStoreWithObjects, values, keys);

  // Store should contain ['1', '2'] after removing elements.
  var checkStore = goog.partial(assertStoreObjectValues, ['1', '2']);

  // Use a bounded cursor for ('2', ...) to remove '3', '4'.
  var openCursorAndRemove = function(db) {
    var cursorTx = db.createTransaction(['store'], TransactionMode.READ_WRITE);

    var store = cursorTx.objectStore('store');
    var index = store.getIndex('index');
    var cursor = index.openCursor(goog.db.KeyRange.lowerBound('2', true));
    var cursorFinished = forEachRecord(cursor, function() {
      return cursor.remove('5').addCallback(function() { cursor.next(); });
    });

    return goog.Promise.all([cursorFinished, cursorTx.wait()])
        .then(function(results) { return db; });
  };

  // Setup and execute test case.
  return globalDb.branch()
      .addCallback(addStoreWithIndex)
      .addCallback(addData)
      .addCallback(openCursorAndRemove)
      .addCallback(checkStore);
}

function testCanWaitForTransactionToComplete() {
  if (!idbSupported) {
    return;
  }
  return globalDb.branch().addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    tx.objectStore('store').add({key: 'hi', value: 'something'}, 'stuff');
    return tx.wait();
  });
}

function testWaitingOnTransactionThatHasAnError() {
  if (!idbSupported) {
    return;
  }
  return globalDb.branch()
      .addCallback(function(db) {
        return incrementVersion(db, function(ev, db, tx) {
          var store = db.createObjectStore('store', {keyPath: 'key'});
          store.createIndex('index', 'value', {unique: true});
        });
      })
      .addCallback(function(db) {
        var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
        assertTrue(tx.objectStore('store').getIndex('index').isUnique());
        tx.objectStore('store').add({key: '1', value: 'a'});
        tx.objectStore('store').add({key: '2', value: 'a'});
        return transactionToPromise(db, tx).then(
            function() { fail('expected transaction to fail'); },
            function(ev) {
              // expected
              assertEquals(
                  goog.db.Error.ErrorName.CONSTRAINT_ERR, ev.target.getName());
            });
      });
}

function testWaitingOnAnAbortedTransaction() {
  if (!idbSupported) {
    return;
  }
  return globalDb.addCallback(addStore).addCallback(function(db) {
    var tx = db.createTransaction(['store'], TransactionMode.READ_WRITE);
    var waiting = tx.wait().then(
        function() { fail('Wait result should have failed'); },
        function(e) {
          assertEquals(goog.db.Error.ErrorName.ABORT_ERR, e.getName());
        });
    tx.abort();
    return waiting;
  });
}
