Home Reference Source Test

test/adapters/IDB_test.js

"use strict";

import sinon from "sinon";
import { expect } from "chai";

import IDB, { open, execute } from "../../src/adapters/IDB.js";
import { v4 as uuid4 } from "uuid";

/** @test {IDB} */
describe("adapter.IDB", () => {
  let sandbox, db;

  beforeEach(() => {
    sandbox = sinon.createSandbox();
    db = new IDB("test/foo");
    return db.clear();
  });

  afterEach(() => sandbox.restore());

  /** @test {IDB#open} */
  describe("#open", () => {
    it("should be fullfilled when a connection is opened", () => {
      return db.open().should.be.fulfilled;
    });

    it("should reject on open request error", () => {
      const fakeOpenRequest = {};
      sandbox.stub(indexedDB, "open").returns(fakeOpenRequest);
      const db = new IDB("another/db");
      const prom = db.open(indexedDB);

      fakeOpenRequest.onerror({ target: { error: "fail" } });

      return prom.should.be.rejectedWith("fail");
    });
  });

  /** @test {IDB#close} */
  describe("#close", () => {
    it("should be fullfilled when a connection is closed", () => {
      return db.close().should.be.fulfilled;
    });

    it("should be fullfilled when no connection has been opened", () => {
      db._db = null;
      return db.close().should.be.fulfilled;
    });

    it("should close an opened connection to the database", () => {
      return db
        .close()
        .then(_ => db._db)
        .should.become(null);
    });
  });

  /** @test {IDB#clear} */
  describe("#clear", () => {
    it("should clear the database", () => {
      return db
        .execute(transaction => {
          transaction.create({ id: 1 });
          transaction.create({ id: 2 });
        })
        .then(() => db.clear())
        .then(() => db.list())
        .should.eventually.have.length.of(0);
    });

    it("should isolate records by collection", async () => {
      const db1 = new IDB("main/tippytop");
      const db2 = new IDB("main/tippytop-2");

      await db1.open();
      await db1.execute(t => t.create({ id: 1 }));
      await db1.saveLastModified(42);
      await db1.close();

      await db2.open();
      await db2.execute(t => t.create({ id: 1 }));
      await db2.execute(t => t.create({ id: 2 }));
      await db1.saveLastModified(43);
      await db2.close();

      await db1.clear();

      expect(await db1.list()).to.have.length(0);
      expect(await db1.getLastModified(), null);
      expect(await db2.list()).to.have.length(2);
      expect(await db2.getLastModified(), 43);
    });

    it("should reject on transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        callback({
          index() {
            return {
              openKeyCursor() {
                throw new Error("transaction error");
              },
            };
          },
        });
      });
      return db.clear().should.be.rejectedWith(Error, "transaction error");
    });
  });

  /** @test {IDB#execute} */
  describe("#execute", () => {
    it("should return a promise", () => {
      return db.execute(() => {}).should.be.fulfilled;
    });

    describe("No preloading", () => {
      it("should open a connection to the db", () => {
        const open = sandbox.stub(db, "open").returns(Promise.resolve());

        return db.execute(() => {}).then(_ => sinon.assert.calledOnce(open));
      });

      it("should execute the specified callback", () => {
        const callback = sandbox.spy();
        return db.execute(callback).then(() => sinon.assert.called(callback));
      });

      it("should fail if the callback returns a promise", () => {
        const callback = () => Promise.resolve();
        return db
          .execute(callback)
          .should.eventually.be.rejectedWith(Error, /Promise/);
      });

      it("should rollback if the callback fails", () => {
        const callback = transaction => {
          transaction.execute(t => t.create({ id: 1, foo: "bar" }));
          throw new Error("Unexpected");
        };
        return db
          .execute(callback)
          .catch(() => db.list())
          .should.become([]);
      });

      it("should provide a transaction parameter", () => {
        const callback = sandbox.spy();
        return db.execute(callback).then(() => {
          const handler = callback.getCall(0).args[0];
          expect(handler)
            .to.have.property("get")
            .to.be.a("function");
          expect(handler)
            .to.have.property("create")
            .to.be.a("function");
          expect(handler)
            .to.have.property("update")
            .to.be.a("function");
          expect(handler)
            .to.have.property("delete")
            .to.be.a("function");
        });
      });

      it("should create a record", () => {
        const data = { id: 1, foo: "bar" };
        return db
          .execute(t => t.create(data))
          .then(() => db.list())
          .should.become([data]);
      });

      it("should update a record", () => {
        const data = { id: 1, foo: "bar" };
        return db
          .execute(t => t.create(data))
          .then(_ => {
            return db.execute(transaction => {
              transaction.update({ ...data, foo: "baz" });
            });
          })
          .then(_ => db.get(data.id))
          .then(res => res.foo)
          .should.become("baz");
      });

      it("should delete a record", () => {
        const data = { id: 1, foo: "bar" };
        return db
          .execute(t => t.create(data))
          .then(_ => {
            return db.execute(transaction => {
              transaction.delete(data.id);
            });
          })
          .then(_ => db.get(data.id))
          .should.become(undefined);
      });

      it("should reject on store method error", () => {
        sandbox
          .stub(db, "prepare")
          .callsFake(async (name, callback, options) => {
            const abort = e => {
              throw e;
            };
            callback(
              {
                openCursor: () => ({
                  set onsuccess(cb) {
                    cb({ target: {} });
                  },
                }),
                add() {
                  throw new Error("add error");
                },
              },
              abort
            );
          });
        return db
          .execute(transaction => transaction.create({ id: 42 }))
          .should.be.rejectedWith(Error, "add error");
      });

      it("should reject on transaction error", () => {
        sandbox
          .stub(db, "prepare")
          .callsFake(async (name, callback, options) => {
            return callback({
              openCursor() {
                throw new Error("transaction error");
              },
            });
          });
        return db
          .execute(transaction => transaction.create({}), { preload: [1, 2] })
          .should.be.rejectedWith(Error, "transaction error");
      });
    });

    describe("Preloaded records", () => {
      const articles = [{ id: 1, title: "title1" }, { id: 2, title: "title2" }];

      it("should expose preloaded records using get()", () => {
        return db
          .execute(t => articles.map(a => t.create(a)))
          .then(_ => {
            return db.execute(
              transaction => {
                return [transaction.get(1), transaction.get(2)];
              },
              { preload: articles.map(article => article.id) }
            );
          })
          .should.become(articles);
      });
    });
  });

  /** @test {IDB#get} */
  describe("#get", () => {
    beforeEach(() => {
      return db.execute(t => t.create({ id: 1, foo: "bar" }));
    });

    it("should retrieve a record from its id", () => {
      return db
        .get(1)
        .then(res => res.foo)
        .should.eventually.eql("bar");
    });

    it("should return undefined when record is not found", () => {
      return db.get(999).should.eventually.eql(undefined);
    });

    it("should reject on transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        return callback({
          get() {
            throw new Error("transaction error");
          },
        });
      });
      return db.get().should.be.rejectedWith(Error, "transaction error");
    });
  });

  /** @test {IDB#list} */
  describe("#list", () => {
    beforeEach(() => {
      return db.execute(transaction => {
        for (let id = 1; id <= 10; id++) {
          // id is indexed, name is not
          transaction.create({ id, name: "#" + id });
        }
      });
    });

    it("should retrieve the list of records", () => {
      return db.list().should.eventually.have.length.of(10);
    });

    it("should prefix error encountered", () => {
      sandbox.stub(db, "open").returns(Promise.reject("error"));
      return db.list().should.be.rejectedWith(Error, /^IndexedDB list()/);
    });

    it("should reject on transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        return callback({
          index() {
            return {
              getAll() {
                throw new Error("transaction error");
              },
            };
          },
        });
      });
      return db
        .list()
        .should.be.rejectedWith(Error, "IndexedDB list() transaction error");
    });

    it("should isolate records by collection", async () => {
      const db1 = new IDB("main/tippytop");
      const db2 = new IDB("main/tippytop-2");
      await db1.clear();
      await db2.clear();

      await db1.open();
      await db2.open();
      await db1.execute(t => t.create({ id: 1 }));
      await db2.execute(t => t.create({ id: 1 }));
      await db2.execute(t => t.create({ id: 2 }));
      await db1.close();
      await db2.close();

      expect(await db1.list()).to.have.length(1);
      expect(await db2.list()).to.have.length(2);
    });

    describe("Filters", () => {
      describe("on non-indexed fields", () => {
        describe("single value", () => {
          it("should filter the list on a single pre-indexed column", () => {
            return db
              .list({ filters: { name: "#4" } })
              .should.eventually.eql([{ id: 4, name: "#4" }]);
          });
        });

        describe("multiple values", () => {
          it("should filter the list on a single pre-indexed column", () => {
            return db
              .list({ filters: { name: ["#4", "#5"] } })
              .should.eventually.eql([
                { id: 4, name: "#4" },
                { id: 5, name: "#5" },
              ]);
          });

          it("should handle non-existent keys", () => {
            return db
              .list({ filters: { name: ["#4", "qux"] } })
              .should.eventually.eql([{ id: 4, name: "#4" }]);
          });

          it("should handle empty lists", () => {
            return db.list({ filters: { name: [] } }).should.eventually.eql([]);
          });
        });
      });

      describe("on indexed fields", () => {
        describe("single value", () => {
          it("should filter the list on a single pre-indexed column", () => {
            return db
              .list({ filters: { id: 4 } })
              .should.eventually.eql([{ id: 4, name: "#4" }]);
          });
        });

        describe("multiple values", () => {
          it("should filter the list on a single pre-indexed column", () => {
            return db
              .list({ filters: { id: [5, 4] } })
              .should.eventually.eql([
                { id: 4, name: "#4" },
                { id: 5, name: "#5" },
              ]);
          });

          it("should filter the list combined with other filters", () => {
            return db
              .list({ filters: { id: [5, 4], name: "#4" } })
              .should.eventually.eql([{ id: 4, name: "#4" }]);
          });

          it("should handle non-existent keys", () => {
            return db
              .list({ filters: { id: [4, 9999] } })
              .should.eventually.eql([{ id: 4, name: "#4" }]);
          });

          it("should handle empty lists", () => {
            return db.list({ filters: { id: [] } }).should.eventually.eql([]);
          });
        });
      });
    });
  });

  /**
   * @deprecated
   * @test {IDB#loadDump}
   */
  describe("Deprecated #loadDump", () => {
    it("should call importBulk", () => {
      sandbox.stub(db, "importBulk").returns(Promise.resolve());
      return db
        .loadDump([{ foo: "bar" }])
        .then(_ => sinon.assert.calledOnce(db.importBulk));
    });
  });

  /** @test {IDB#importBulk} */
  describe("#importBulk", () => {
    it("should reject on transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        return callback({
          put() {
            throw new Error("transaction error");
          },
        });
      });
      return db
        .importBulk([{ foo: "bar" }])
        .should.be.rejectedWith(Error, /^IndexedDB importBulk()/);
    });
  });

  /** @test {IDB#getLastModified} */
  describe("#getLastModified", () => {
    it("should reject with any encountered transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        return callback({
          get() {
            throw new Error("transaction error");
          },
        });
      });
      return db.getLastModified().should.be.rejectedWith(/transaction error/);
    });
  });

  /** @test {IDB#saveLastModified} */
  describe("#saveLastModified", () => {
    it("should resolve with lastModified value", () => {
      return db.saveLastModified(42).should.eventually.become(42);
    });

    it("should save a lastModified value", () => {
      return db
        .saveLastModified(42)
        .then(_ => db.getLastModified())
        .should.eventually.become(42);
    });

    it("should allow updating previous value", () => {
      return db
        .saveLastModified(42)
        .then(_ => db.saveLastModified(43))
        .then(_ => db.getLastModified())
        .should.eventually.become(43);
    });

    it("should reject on transaction error", () => {
      sandbox.stub(db, "prepare").callsFake(async (name, callback, options) => {
        return callback({
          put() {
            throw new Error("transaction error");
          },
        });
      });
      return db.saveLastModified().should.be.rejectedWith(/transaction error/);
    });
  });

  /** @test {IDB#importBulk} */
  describe("#importBulk", () => {
    it("should import a list of records.", () => {
      return db
        .importBulk([{ id: 1, foo: "bar" }, { id: 2, foo: "baz" }])
        .should.eventually.have.length(2);
    });

    it("should override existing records.", () => {
      return db
        .importBulk([{ id: 1, foo: "bar" }, { id: 2, foo: "baz" }])
        .then(() => {
          return db.importBulk([{ id: 1, foo: "baz" }, { id: 3, foo: "bab" }]);
        })
        .then(() => db.list())
        .should.eventually.eql([
          { id: 1, foo: "baz" },
          { id: 2, foo: "baz" },
          { id: 3, foo: "bab" },
        ]);
    });

    it("should update the collection lastModified value.", () => {
      return db
        .importBulk([
          { id: uuid4(), title: "foo", last_modified: 1457896541 },
          { id: uuid4(), title: "bar", last_modified: 1458796542 },
        ])
        .then(() => db.getLastModified())
        .should.eventually.become(1458796542);
    });

    it("should preserve older collection lastModified value.", () => {
      return db
        .saveLastModified(1458796543)
        .then(() =>
          db.importBulk([
            { id: uuid4(), title: "foo", last_modified: 1457896541 },
            { id: uuid4(), title: "bar", last_modified: 1458796542 },
          ])
        )
        .then(() => db.getLastModified())
        .should.eventually.become(1458796543);
    });
  });

  /** @test {IDB#list} */
  /** @test {IDB#getLastModified} */
  describe("With custom dbName", () => {
    it("should isolate records by dbname", async () => {
      const db1 = new IDB("main/tippytop", { dbName: "KintoDB" });
      const db2 = new IDB("main/tippytop", { dbName: "RemoteSettings" });
      await db1.clear();
      await db2.clear();

      await db1.open();
      await db2.open();
      await db1.execute(t => t.create({ id: 1 }));
      await db2.execute(t => t.create({ id: 1 }));
      await db2.execute(t => t.create({ id: 2 }));
      await db1.close();
      await db2.close();

      expect(await db1.list()).to.have.length(1);
      expect(await db2.list()).to.have.length(2);
    });

    it("should isolate timestamps by dbname", async () => {
      const db1 = new IDB("main/tippytop", { dbName: "KintoDB" });
      const db2 = new IDB("main/tippytop", { dbName: "RemoteSettings" });

      await db1.open();
      await db2.open();
      await db1.saveLastModified(41);
      await db2.saveLastModified(42);
      await db1.close();
      await db2.close();

      expect(await db1.getLastModified()).to.be.equal(41);
      expect(await db2.getLastModified()).to.be.equal(42);
    });
  });

  /** @test {IDB#saveMetadata} */
  describe("#saveMetadata", () => {
    it("should return null when no metadata is found", () => {
      return db.getMetadata().should.eventually.eql(null);
    });

    it("should store metadata in db", async () => {
      await db.saveMetadata({ id: "abc", schema: { type: "object" } });

      const retrieved = await db.getMetadata();
      expect(retrieved.id, "abc");
    });
  });

  /** @test {IDB#open} */
  describe("#migration", () => {
    let idb;
    async function createOldDB(dbName) {
      const oldDb = await open(dbName, {
        version: 1,
        onupgradeneeded: event => {
          // https://github.com/Kinto/kinto.js/blob/v11.2.2/src/adapters/IDB.js#L154-L171
          const db = event.target.result;
          db.createObjectStore(dbName, { keyPath: "id" });
          db.createObjectStore("__meta__", { keyPath: "name" });
        },
      });
      await execute(
        oldDb,
        dbName,
        store => {
          store.put({ id: 1 });
          store.put({ id: 2 });
        },
        { mode: "readwrite" }
      );
      await execute(
        oldDb,
        "__meta__",
        store => {
          store.put({ name: "lastModified", value: 43 });
        },
        { mode: "readwrite" }
      );
      oldDb.close(); // synchronous.
    }

    const cid = "main/tippytop";

    before(async () => {
      await createOldDB(cid);
      await createOldDB("another/not-migrated");

      idb = new IDB(cid, {
        migrateOldData: true,
      });
    });

    after(() => {
      return idb.close();
    });

    it("should migrate records", async () => {
      return idb.list().should.eventually.become([{ id: 1 }, { id: 2 }]);
    });

    it("should migrate timestamps", () => {
      return idb.getLastModified().should.eventually.become(43);
    });

    it("should create the collections store", async () => {
      const metadata = { id: "abc" };
      await idb.saveMetadata(metadata);
      return idb.getMetadata().should.eventually.become(metadata);
    });

    it("should not fail if already migrated", () => {
      return idb
        .close()
        .then(() => idb.open())
        .then(() => idb.close())
        .then(() => idb.open()).should.be.fulfilled;
    });

    it("should delete the old database", () => {
      return open(cid, {
        version: 1,
        onupgradeneeded: event => event.target.transaction.abort(),
      }).should.eventually.be.rejected;
    });

    it("should not delete other databases", () => {
      return open("another/not-migrated", {
        version: 1,
        onupgradeneeded: event => event.target.transaction.abort(),
      }).should.eventually.be.fulfilled;
    });

    it("should not migrate if option is set to false", () => {
      const idb = new IDB("another/not-migrated", { migrateOldData: false });
      return idb.list().should.eventually.become([]);
    });

    it("should not fail if old database is broken or incomplete", async () => {
      const oldDb = await open("some/db", {
        version: 1,
        onupgradeneeded: event => {},
      });
      oldDb.close();
      const idb = new IDB("some/db", { migrateOldData: true });
      return idb.open().should.eventually.be.fulfilled;
    });
  });
});