

describe("can.Model.Cacheable", function() {

  beforeAll(function() {
    can.Model.Mixin("dummyable");
    spyOn(CMS.Models.Mixins.dummyable, "add_to");

    can.Model.Cacheable.extend("CMS.Models.DummyModel", {
      root_object: "dummy_model",
      root_collection: "dummy_models",
      // The string update key has to be here to make the update conflict tests work.
      //  See can.Model.Cacheable.init for details on how the software
      //  under test is broken. --BM
      findOne: 'GET /api/dummy_models/{id}',
      findAll: 'GET /api/dummy_models/',
      update: "PUT /api/dummy_models/{id}",
      mixins: ["dummyable"],
      attributes: { dummy_attribute: "dummy_convert" },
      is_custom_attributable: true
    }, {});

    can.Model.Cacheable.extend("CMS.Models.DummyJoin", {
      root_object: "dummy_join",
      root_collection: "dummy_joins",
    }, {});
  });

  afterAll(function() {
    delete CMS.Models.DummyModel;
    delete CMS.Models.DummyJoin;
    delete CMS.Models.Mixins.dummyable;
  });

  describe("::setup", function() {

    it("prefers pre-set static names over root object & collection", function() {
      var Model = can.Model.Cacheable.extend("CSM.Models.Dummy", {
        root_object: "wrong_name",
        root_collection: "wrong_names",
        model_singular: "RightName",
        table_singular: "right_name",
        title_singular: "Right Name",
        model_plural: "RightNames",
        table_plural: "right_names",
        title_plural: "Right Names"
      }, {});
      // note that these are not explicit in beforeAll above.
      // model singular is CamelCased form of root_object
      expect(Model.model_singular).toBe("RightName");
      // table singular is under_scored version of same
      expect(Model.table_singular).toBe("right_name");
      // title singular is "Human Readable" version of same
      expect(Model.title_singular).toBe("Right Name");
      // plurals are based on root collection.
      expect(Model.model_plural).toBe("RightNames");
      expect(Model.table_plural).toBe("right_names");
      expect(Model.title_plural).toBe("Right Names");
    });

    it("sets various forms of the name based on root object & collection by default", function() {
      // note that these are not explicit in beforeAll above.
      // model singular is CamelCased form of root_object
      expect(CMS.Models.DummyModel.model_singular).toBe("DummyModel");
      // table singular is under_scored version of same
      expect(CMS.Models.DummyModel.table_singular).toBe("dummy_model");
      // title singular is "Human Readable" version of same
      expect(CMS.Models.DummyModel.title_singular).toBe("Dummy Model");
      // plurals are based on root collection.
      expect(CMS.Models.DummyModel.model_plural).toBe("DummyModels");
      expect(CMS.Models.DummyModel.table_plural).toBe("dummy_models");
      expect(CMS.Models.DummyModel.title_plural).toBe("Dummy Models");
    });

    it("sets findAll to default based on root_collection if not set", function() {
      spyOn(can.Model, "setup");
      var Model = can.Model.Cacheable.extend("CMS.Models.DummyFind", { root_collection: "foos" }, {});
      expect(Model.findAll).toBe("GET /api/foos");
    });

    it("applies mixins based on the mixins property", function() {
      expect(CMS.Models.Mixins.dummyable.add_to).toHaveBeenCalledWith(CMS.Models.DummyModel);
    });

    it("merges in default attributes for created_at and updated_at", function() {
      expect(CMS.Models.DummyModel.attributes).toEqual({
        created_at: "datetime",
        updated_at: "datetime",
        dummy_attribute: "dummy_convert"
      });
    });

  });

  describe("::init", function() {

    it("sets custom attributes", function() {
      // NB using $.extend here creates a new object with all of the static properties of the function.
      //  This is how the custom attributable is implemented in setup.
      expect(GGRC.custom_attributable_types).toContain($.extend({}, CMS.Models.DummyModel));
    });

  });


  describe("::makeDestroy", function() {

    var destroy_spy;
    beforeEach(function() {
      destroy_spy = jasmine.createSpy("destroy");
      spyOn(CMS.Models.BackgroundTask, "findOne").and.returnValue($.when());
    });

    it("finds background task if specified in return object", function(done) {
      var destroy = CMS.Models.DummyModel.makeDestroy(destroy_spy);
      destroy_spy.and.returnValue($.when({ background_task: {id: 1}}));

      destroy(2).then(function() {
        expect(CMS.Models.BackgroundTask.findOne).toHaveBeenCalledWith({ id: 1 });
        done();
      }, failAll(done));
    });

    it("polls background task if found", function(done) {
      var destroy = CMS.Models.DummyModel.makeDestroy(destroy_spy);
      var poll_spy = jasmine.createSpyObj("poll_spy", ["poll"]);
      destroy_spy.and.returnValue($.when({ background_task: {id: 1}}));
      CMS.Models.BackgroundTask.findOne.and.returnValue($.when(poll_spy));

      destroy(2).then(function() {
        expect(CMS.Models.BackgroundTask.findOne).toHaveBeenCalledWith({ id: 1 });
        expect(poll_spy.poll).toHaveBeenCalled();
        done();
      }, failAll(done));
    });

    it("continues normally if no background task specified", function(done) {
      var destroy = CMS.Models.DummyModel.makeDestroy(destroy_spy);
      destroy_spy.and.returnValue($.when({ dummy: {id: 1}}));

      destroy(2).then(function() {
        expect(CMS.Models.BackgroundTask.findOne).not.toHaveBeenCalled();
        done();
      }, failAll(done));
    });

  });

  describe("::update", function() {

    var _obj, id = 0;

    beforeEach(function(done) {
      _obj = new CMS.Models.DummyModel({ id: ++id });
      done();
    });

    it("processes args before sending", function(done) {
      var obj = _obj;
      spyOn(CMS.Models.DummyModel, "process_args");
      spyOn(can, "ajax").and.returnValue($.when({}));
      CMS.Models.DummyModel.update(obj.id, obj).then(function() {
        expect(CMS.Models.DummyModel.process_args).toHaveBeenCalledWith(obj);
        done();
      });
    });

    it("calls resolve_deferred_bindings after send success", function(done) {
      var obj = _obj;
      spyOn(CMS.Models.DummyModel, "resolve_deferred_bindings").and.returnValue(obj);
      spyOn(can, "ajax").and.returnValue($.when({ dummy_model: {id: obj.id}}));
      CMS.Models.DummyModel.update(obj.id, obj.serialize()).then(function() {
        expect(CMS.Models.DummyModel.resolve_deferred_bindings).toHaveBeenCalledWith(obj);
        setTimeout(function() {
          done();
        }, 10);
      }, failAll(done));

    });

    describe("update conflict", function() {

      it("triggers error flash when one property has an update conflict", function(done) {
        var obj = _obj;
        obj.attr("foo", "bar");
        obj.backup();
        expect(obj._backupStore).toEqual(jasmine.objectContaining({ id: obj.id, foo: "bar" }));
        obj.attr("foo", "plonk");
        spyOn($.fn, "trigger").and.callThrough();
        spyOn(obj, "save").and.callFake(function() {
          return new $.Deferred(function(dfd) {
            setTimeout(function() {
              dfd.resolve(obj);
            }, 10);
          });
        });
        spyOn(obj, "refresh").and.callFake(function() {
          obj.attr("foo", "thud"); //uh-oh!  The same attr has been changed locally and remotely!
          return new $.Deferred(function(dfd) {
            setTimeout(function() {
              dfd.resolve(obj);
            }, 10);
          });
        });
        spyOn(can, "ajax")//.and.returnValue(new $.Deferred().reject({status: 409}, 409, "CONFLICT"));
        .and.callFake(function() {
          return new $.Deferred(function(dfd) {
            setTimeout(function() {
              dfd.reject({status: 409}, 409, "CONFLICT");
            }, 10);
          });
        });
        CMS.Models.DummyModel.update(obj.id.toString(), obj.serialize()).then(function() {
          fail("The update handler isn't supposed to resolve here.");
          done();
        }, function() {
          expect($.fn.trigger).toHaveBeenCalledWith("ajax:flash", {warning : [ jasmine.any(String) ] });
          setTimeout(function() {
            done();
          }, 10);
        });
      });

      it("refreshes model", function(done) {
        var obj = _obj;
        spyOn(obj, "refresh").and.returnValue($.when(obj));
        spyOn(can, "ajax").and.returnValue(new $.Deferred().reject({status: 409}, 409, "CONFLICT"));
        CMS.Models.DummyModel.update(obj.id, obj.serialize()).then(function() {
          expect(obj.refresh).toHaveBeenCalledWith();
          setTimeout(function() {
            done();
          }, 10);
        }, failAll(done));
      });

      it("merges changed properties and saves", function(done) {
        var obj = _obj;
        obj.attr("foo", "bar");
        obj.backup();
        expect(obj._backupStore).toEqual(jasmine.objectContaining({ id: obj.id, foo: "bar" }));
        obj.attr("foo", "plonk");
        spyOn(obj, "save").and.returnValue($.when(obj));
        spyOn(obj, "refresh").and.callFake(function() {
          obj.attr("baz", "quux");
          return $.when(obj);
        });
        spyOn(can, "ajax").and.returnValue(new $.Deferred().reject({status: 409}, 409, "CONFLICT"));
        CMS.Models.DummyModel.update(obj.id, obj.serialize()).then(function() {
          expect(obj).toEqual(jasmine.objectContaining({ id: obj.id, foo: "plonk", baz: "quux"}));
          expect(obj.save).toHaveBeenCalled();
          setTimeout(function() {
            done();
          }, 10);
        }, failAll(done));
      });

      it("lets other error statuses pass through", function(done) {
        var obj = new CMS.Models.DummyModel({ id: 1 });
        var xhr = {status: 400};
        spyOn(obj, "refresh").and.returnValue($.when(obj.serialize()));
        spyOn(can, "ajax").and.returnValue(new $.Deferred().reject(xhr, 400, "BAD REQUEST"));
        CMS.Models.DummyModel.update(1, obj.serialize()).then(
          failAll(done),
          function(_xhr) {
            expect(_xhr).toBe(xhr);
            done();
          }
        );
      });
    });

  });

  describe("::resolve_deferred_bindings", function() {

    it("iterates _pending_joins, calling refresh_stubs on each binding", function() {

      var instance = jasmine.createSpyObj("instance", ["get_binding"]);
      var binding = jasmine.createSpyObj("binding", ["refresh_stubs"]);
      instance._pending_joins = [{ what: {}, how: "add", through: "foo" }];
      instance.get_binding.and.returnValue(binding);
      spyOn($.when, "apply").and.returnValue(new $.Deferred().reject());

      can.Model.Cacheable.resolve_deferred_bindings(instance);
      expect(binding.refresh_stubs).toHaveBeenCalled();

    });

    describe("add case", function() {

      var instance, binding, dummy;
      beforeEach(function() {
        dummy = new CMS.Models.DummyModel({id:1});
        instance = jasmine.createSpyObj("instance", ["get_binding", "isNew", "refresh", "attr"]);
        binding = jasmine.createSpyObj("binding", ["refresh_stubs"]);
        instance._pending_joins = [{ what: dummy, how: "add", through: "foo" }];
        instance.isNew.and.returnValue(false);
        instance.get_binding.and.returnValue(binding);
        binding.loader = { model_name: "DummyJoin" };
        binding.list = [];
        spyOn(CMS.Models.DummyJoin, "newInstance");
        spyOn(CMS.Models.DummyJoin.prototype, "save");
      });

      afterEach(function() {
        delete CMS.Models.DummyModel.cache[1];
      });

      it("creates a proxy object when it does not exist", function() {
        can.Model.Cacheable.resolve_deferred_bindings(instance);
        expect(CMS.Models.DummyJoin.newInstance).toHaveBeenCalled();
        expect(CMS.Models.DummyJoin.prototype.save).toHaveBeenCalled();
      });

      it("does not create proxy object when it already exists", function() {
        binding.list.push({ instance: dummy });
        can.Model.Cacheable.resolve_deferred_bindings(instance);
        expect(CMS.Models.DummyJoin.newInstance).not.toHaveBeenCalled();
        expect(CMS.Models.DummyJoin.prototype.save).not.toHaveBeenCalled();
      });

    });


    describe("remove case", function() {

      var instance, binding, dummy, dummy_join;
      beforeEach(function() {
        dummy = new CMS.Models.DummyModel({id:1});
        dummy_join = new CMS.Models.DummyJoin({id:1});
        instance = jasmine.createSpyObj("instance", ["get_binding", "isNew", "refresh", "attr"]);
        binding = jasmine.createSpyObj("binding", ["refresh_stubs"]);
        instance._pending_joins = [{ what: dummy, how: "remove", through: "foo" }];
        instance.isNew.and.returnValue(false);
        instance.get_binding.and.returnValue(binding);
        binding.loader = { model_name: "DummyJoin" };
        binding.list = [];
        spyOn(CMS.Models.DummyJoin, "newInstance");
        spyOn(CMS.Models.DummyJoin.prototype, "save");
      });

      it("removes proxy object if it exists", function() {
        binding.list.push({instance: dummy, get_mappings: function() {return [dummy_join]; }});
        spyOn(dummy_join, "refresh").and.returnValue($.when());
        spyOn(dummy_join, "destroy");
        can.Model.Cacheable.resolve_deferred_bindings(instance);
        expect(dummy_join.destroy).toHaveBeenCalled();
      });

    });

  });


  describe("::findAll", function() {

    it("throws errors when called directly on Cacheable instead of a subclass", function() {
      expect(can.Model.Cacheable.findAll).toThrow(
        "No default findAll() exists for subclasses of Cacheable"
      );
    });

    it("unboxes collections when passed back from the find", function(done) {
      spyOn(can, "ajax").and.returnValue($.when({ dummy_models_collection: { dummy_models: [{id: 1}] }}));
      CMS.Models.DummyModel.findAll().then(function(data) {
        expect(can.ajax).toHaveBeenCalled();
        expect(data).toEqual(jasmine.any(can.List));
        expect(data.length).toBe(1);
        expect(data[0]).toEqual(jasmine.any(CMS.Models.DummyModel));
        expect(data[0]).toEqual(jasmine.objectContaining({ id: 1 }));
        done();
      }, failAll(done));
    });

    it("makes a collection of a single object when passed back from the find", function(done) {
      spyOn(can, "ajax").and.returnValue($.when({id: 1}));
      CMS.Models.DummyModel.findAll().then(function(data) {
        expect(can.ajax).toHaveBeenCalled();
        expect(data).toEqual(jasmine.any(can.List));
        expect(data.length).toBe(1);
        expect(data[0]).toEqual(jasmine.any(CMS.Models.DummyModel));
        expect(data[0]).toEqual(jasmine.objectContaining({ id: 1 }));
        done();
      }, failAll(done));
    });

    // NB -- This unit test is brittle.  It's difficult to unit test for
    //  things like timing, and it's a bit of a hack to spy on Date.now()
    //  since that function is used in more places than just our modelize function.
    //  -- BM 2015-02-03
    it("only pushes instances into the list for 100ms before yielding", function(done) {
      var list = new CMS.Models.DummyModel.List();
      var dummy_models = [
          {id: 1}, {id: 2}, {id: 3}, {id: 4}, {id: 5}, {id: 6}, {id: 7}
        ];
      // Have our modelized instances ready for when
      var dummy_insts = CMS.Models.DummyModel.models(dummy_models);
      // we want to see how our observable list gets items over time, so spy on the push method
      spyOn(list, "push").and.callThrough();
      spyOn(can, "ajax").and.returnValue($.when(dummy_models));
      var st = 4; // preload Date.now() because it's called once before we even get to modelizing
      spyOn(Date, "now").and.callFake(function() {
        // Date.now() is called once at the start of modelizeMS (internal function in findAll)
        //  once in an unknown location
        //  and once per item.
        if((++st % 5) === 0)
          st += 100; // after three, push the time ahead 100ms to force a new call to modelizeMS
        return st;
      });
      // return model instances for the list of returned items from the server
      spyOn(CMS.Models.DummyModel.List, "newInstance").and.returnValue(list);
      //spy so we don't return the list to observe more than once.  That is,
      //  models calls new DummyModel.List() which we're already spying out,
      //  so spy models() out in order to *not* call it.
      spyOn(CMS.Models.DummyModel, "models").and.callFake(function(items) {
        var ids = can.map(items, function(item) { return item.id; });
        return can.map(dummy_insts, function(inst) {
          return ~can.inArray(inst.id, ids) ? inst : undefined;
        });
      });
      CMS.Models.DummyModel.findAll().then(function() {
        // finally, we show that with the 100ms gap between pushing ids 3 and 4, we force a separate push.
        expect(list.push).toHaveBeenCalledWith(
          jasmine.objectContaining({id: 1}),
          jasmine.objectContaining({id: 2}),
          jasmine.objectContaining({id: 3})
        );
        expect(list.push).toHaveBeenCalledWith(
          jasmine.objectContaining({id: 4}),
          jasmine.objectContaining({id: 5}),
          jasmine.objectContaining({id: 6})
        );
        expect(list.push).toHaveBeenCalledWith(
          jasmine.objectContaining({id: 7})
        );
        done();
      }, failAll(done));
    });

  });

  describe("::findPage", function() {
    it("throws errors when called directly on Cacheable instead of a subclass", function() {
      expect(can.Model.Cacheable.findPage).toThrow(
       "No default findPage() exists for subclasses of Cacheable"
      );
    });

  });

  describe("#refresh", function() {

    var inst;
    beforeEach(function() {
      inst = new CMS.Models.DummyModel({ href : '/api/dummy_models/1' });
      spyOn(can, "ajax").and.returnValue(new $.Deferred(function(dfd) {
        setTimeout(function() {
          dfd.resolve(inst.serialize());
        }, 10);
      }));
    });
    afterEach(function() {
      delete CMS.Models.DummyModel.cache[undefined];
    });

    it("calls the object endpoint with the supplied href if no selfLink", function(done) {
      inst.refresh().then(function() {
        expect(can.ajax).toHaveBeenCalledWith(jasmine.objectContaining({
          url: "/api/dummy_models/1",
          type: "get"
        }));
        done();
      }, fail);
    });

    it("throttles the requests to once per second", function(done) {
      inst.refresh();
      inst.refresh();
      setTimeout(function() {
        inst.refresh().then(function() {
          expect(can.ajax.calls.count()).toBe(2);
          done();
        }, fail);
      }, 1000); // 1000ms is enough to trigger a new call to the debounced function
      inst.refresh().then(function() {
        expect(can.ajax.calls.count()).toBe(1);
      }, fail);
    });

    it("backs up the refreshed state immediately after refreshing", function(done) {
      spyOn(CMS.Models.DummyModel, "model").and.returnValue(inst);
      spyOn(inst, "backup");
      inst.refresh().then(function() {
        expect(inst.backup).toHaveBeenCalled();
        done();
      }, fail);
    });
  });

  describe("::parse_deep_property_descriptor", function() {
    it("produces an immutable descriptor", function() {
      var desc = can.Model.Cacheable.parse_deep_property_descriptor("foo.bar|baz|quux"),
          zero = desc[0][0];
          one = desc[1];

      try {
        desc[0][0] = 0;
        desc[1] = 0;
      } catch (err) {
        // we may get an error trying to modify an immutable object
      }
      expect(desc[0][0]).toEqual(zero);
      expect(desc[1]).toEqual(one);
    });
  });

  describe("#get_deep_property", function() {
    var equip = function (value) {
      if (typeof value === "object") {
        value.get_deep_property = can.Model.Cacheable.prototype.get_deep_property;
        _.each(value, equip);
      }
      return value;
    };
    var get = function (key, value) {
       var desc = can.Model.Cacheable.parse_deep_property_descriptor(key);
       return equip(value).get_deep_property(desc);
    };

    it("gets a simple property", function() {
      expect(get("foo", {foo: "bar"})).toBe("bar");
      expect(get("foo", {foo: 2})).toBe(2);
      expect(get("foo", {})).toBe(null);
    });

    it("gets a nested property", function() {
      var value = {
        foo: "bar",
        bar: {
          foo: 1,
          bar: "2",
          baz: { foo: "foo" }
        }
      };
      expect(get("foo", value)).toBe("bar");
      expect(get("bar", value)).toBe(value.bar);
      expect(get("bar.foo", value)).toBe(1);
      expect(get("bar.bar", value)).toBe("2");
      expect(get("bar.baz.foo", value)).toBe("foo");
    });

    it("gets and element from an array", function() {
      expect(get("0.1", [[undefined, 2],[]])).toBe(2);
      expect(get("a.1", {a:["foo", "bar"]})).toBe("bar");
    });

    it("chooses first non-empty", function() {
      var value1 = {
        foo: {
          bar: {
            quux: 3
          }
        }
      };
      var value2 = {
        foo: {
          baz: {
            quux: 3
          }
        }
      };
      expect(get("foo.bar|baz.quux", value1)).toBe(3);
      expect(get("foo.bar|baz.quux", value2)).toBe(3);
    });

    it("automatically calls reify when available", function() {
      var value = {
        foo: {
          reify: function() {
            return { bar: "baz" };
          }
        }
      }
      expect(get("foo.bar", value)).toBe("baz");
    });

    it("does a multi-get for GET_ALL", function() {
      var value = {
        foo: [
            { bar: 1 },
            { bar: 2 },
            { baz: 3 }
          ]
      };
      expect(get("foo.GET_ALL.bar|baz", value)).toEqual([1,2,3]);
    });

    it("produces a tree for nested multi-get", function() {
      var value = {
        foo: [
            { bar : [{y: 1}, {x: 2}, {x: 3}] },
            { bar : [{x: 4}, {x: 5}, {y: 6}] },
            { baz : [{y: 7}, {x: 8}, {x: 9}] },
          ]
      };
      expect(get("foo.GET_ALL.bar|baz.GET_ALL.x|y", value)).
        toEqual([[1,2,3], [4,5,6], [7,8,9]]);
    });
  });

  describe('::_custom_attribute_map', function () {
    it('sets a custom attribute mapping', function () {
      var obj = CMS.Models.DummyModel();
      var target = CMS.Models.DummyModel();

      obj.custom_attribute_definitions = [{
        id: 1,
        attribute_type: 'Map:DummyModel'
      }];
      obj.custom_attributes = can.Map();

      target.stub = function () {
        return {
          type: 'DummyModel',
          id: 1
        };
      };

      obj._custom_attribute_map(1, target);
      expect(obj.custom_attributes[1]).toEqual('DummyModel:1');
      obj._custom_attribute_map(1, 'garbage');
      expect(obj.custom_attributes[1]).toEqual('DummyModel:1');
      obj._custom_attribute_map(1, '');
      expect(obj.custom_attributes[1]).toEqual(undefined);
    });
  });
});
