'use strict';

const sinon = require('sinon');
const {
  serialize,
  deserialize,
  SerializableEvent,
  SerializableWorkerResult
} = require('../../lib/nodejs/serializer');

describe('serializer', function () {
  afterEach(function () {
    sinon.restore();
  });

  describe('function', function () {
    describe('serialize', function () {
      describe('when passed a non-object value', function () {
        it('should return the value', function () {
          expect(serialize('knees & toes'), 'to be', 'knees & toes');
        });
      });

      describe('when passed an object value', function () {
        describe('w/o a `serialize` method', function () {
          it('should return the value', function () {
            const obj = {};
            expect(serialize(obj), 'to be', obj);
          });
        });

        describe('having a `serialize` method', function () {
          it('should return the result of the `serialize` method', function () {
            const serializedObj = {foo: 'bar'};
            const obj = {serialize: sinon.stub().returns(serializedObj)};
            expect(serialize(obj), 'to be', serializedObj);
          });
        });
      });

      describe('when not passed anything', function () {
        it('should return `undefined`', function () {
          expect(serialize(), 'to be undefined');
        });
      });
    });

    describe('deserialize', function () {
      describe('when passed nothing', function () {
        it('should return `undefined`', function () {
          expect(deserialize(), 'to be undefined');
        });
      });

      describe('when passed a non-object value', function () {
        it('should return the value', function () {
          expect(deserialize(500), 'to be', 500);
        });
      });

      describe('when passed an object value which is not a SerializedWorkerResult', function () {
        it('should return the value', function () {
          const obj = {};
          expect(deserialize(obj), 'to be', obj);
        });
      });

      describe('when passed a SerializedWorkerResult object', function () {
        // note that SerializedWorkerResult is an interface (typedef), not a class.

        it('should return the result of `SerializableWorkerResult.deserialize` called on the value', function () {
          const obj = Object.assign({}, SerializableWorkerResult.create());
          sinon.stub(SerializableWorkerResult, 'deserialize').returns('butts');
          deserialize(obj);
          expect(
            SerializableWorkerResult.deserialize,
            'to have a call satisfying',
            {
              args: [obj],
              returned: 'butts'
            }
          );
        });
      });
    });
  });

  describe('SerializableEvent', function () {
    describe('constructor', function () {
      describe('when called without `eventName`', function () {
        it('should throw "invalid arg value" error', function () {
          expect(() => new SerializableEvent(), 'to throw', {
            code: 'ERR_MOCHA_INVALID_ARG_TYPE'
          });
        });
      });

      describe('when called with a non-object `rawObject`', function () {
        it('should throw "invalid arg type" error', function () {
          expect(() => new SerializableEvent('blub', 'glug'), 'to throw', {
            code: 'ERR_MOCHA_INVALID_ARG_TYPE'
          });
        });
      });
    });

    describe('instance method', function () {
      describe('serialize', function () {
        it('should mutate the instance in-place', function () {
          const evt = SerializableEvent.create('foo');
          expect(evt.serialize(), 'to be', evt);
        });

        it('should freeze the instance', function () {
          expect(
            Object.isFrozen(SerializableEvent.create('foo').serialize()),
            'to be true'
          );
        });

        describe('when passed an object with a `serialize` method', function () {
          it('should call the `serialize` method', function () {
            const obj = {
              serialize: sinon.stub()
            };
            SerializableEvent.create('some-event', obj).serialize();
            expect(obj.serialize, 'was called once');
          });
        });

        describe('when passed an object containing an object with a `serialize` method', function () {
          it('should call the `serialize` method', function () {
            const stub = sinon.stub();
            const obj = {
              nested: {
                serialize: stub
              }
            };
            SerializableEvent.create('some-event', obj).serialize();
            expect(stub, 'was called once');
          });
        });

        describe('when passed an object containing a non-`serialize` method', function () {
          it('should remove the method', function () {
            const obj = {
              func: () => {}
            };

            expect(
              SerializableEvent.create('some-event', obj).serialize(),
              'to satisfy',
              {
                data: expect.it('not to have property', 'func')
              }
            );
          });
        });

        describe('when passed an object containing an array', function () {
          it('should serialize the array', function () {
            const obj = {
              list: [{herp: 'derp'}, {bing: 'bong'}]
            };
            expect(
              SerializableEvent.create('some-event', obj).serialize(),
              'to satisfy',
              {data: {list: [{herp: 'derp'}, {bing: 'bong'}]}}
            );
          });
        });

        describe('when passed an error', function () {
          it('should serialize the error', function () {
            const obj = {};
            const err = new Error('monkeypants');
            expect(
              SerializableEvent.create('some-event', obj, err).serialize(),
              'to satisfy',
              {
                eventName: 'some-event',
                error: {
                  message: 'monkeypants',
                  stack: /^Error: monkeypants/,
                  __type: 'Error'
                },
                data: obj
              }
            );
          });

          it('should retain own props', function () {
            const obj = {};
            const err = new Error('monkeypants');
            err.code = 'MONKEY';
            expect(
              SerializableEvent.create('some-event', obj, err).serialize(),
              'to satisfy',
              {
                eventName: 'some-event',
                error: {
                  code: 'MONKEY',
                  message: 'monkeypants',
                  stack: /^Error: monkeypants/,
                  __type: 'Error'
                },
                data: obj
              }
            );
          });

          it('should not retain not-own props', function () {
            const obj = {};
            const err = new Error('monkeypants');
            // eslint-disable-next-line no-proto
            err.__proto__.code = 'MONKEY';
            expect(
              SerializableEvent.create('some-event', obj, err).serialize(),
              'to satisfy',
              {
                eventName: 'some-event',
                error: {
                  message: 'monkeypants',
                  stack: /^Error: monkeypants/,
                  __type: 'Error'
                },
                data: obj
              }
            );
          });
        });

        describe('when passed an object containing a top-level prop with an Error value', function () {
          it('should serialize the Error', function () {
            const obj = {
              monkeyError: new Error('pantsmonkey')
            };
            const evt = SerializableEvent.create('some-event', obj);
            expect(evt.serialize(), 'to satisfy', {
              eventName: 'some-event',
              data: {
                monkeyError: {
                  message: 'pantsmonkey',
                  stack: /^Error: pantsmonkey/,
                  __type: 'Error'
                }
              }
            });
          });
        });
        describe('when passed an object containing a nested prop with an Error value', function () {
          it('should serialize the Error', function () {
            const obj = {
              nestedObj: {
                monkeyError: new Error('pantsmonkey')
              }
            };
            const evt = SerializableEvent.create('some-event', obj);
            expect(evt.serialize(), 'to satisfy', {
              eventName: 'some-event',
              data: {
                nestedObj: {
                  monkeyError: {
                    message: 'pantsmonkey',
                    stack: /^Error: pantsmonkey/,
                    __type: 'Error'
                  }
                }
              }
            });
          });
        });
      });
    });

    describe('static method', function () {
      describe('deserialize', function () {
        describe('when passed a falsy parameter', function () {
          it('should throw "invalid arg type" error', function () {
            expect(SerializableEvent.deserialize, 'to throw', {
              code: 'ERR_MOCHA_INVALID_ARG_TYPE'
            });
          });
        });

        it('should return a new object w/ null prototype', function () {
          const obj = {bob: 'bob'};
          expect(SerializableEvent.deserialize(obj), 'to satisfy', obj)
            .and('not to equal', obj)
            .and('not to have property', 'constructor');
        });

        describe('when passed value contains `data` prop', function () {
          it('should ignore __proto__', function () {
            const obj = {
              data: Object.create(null)
            };
            // eslint-disable-next-line no-proto
            obj.data.__proto__ = {peaches: 'prunes'};

            const expected = Object.assign(Object.create(null), {
              data: Object.create(null)
            });
            expect(SerializableEvent.deserialize(obj), 'to equal', expected);
          });

          describe('when `data` prop contains a nested serialized Error prop', function () {
            it('should create an Error instance from the nested serialized Error prop', function () {
              const message = 'problems!';
              const stack = 'problem instructions';
              const code = 'EIEIO';
              const expected = Object.assign(Object.create(null), {
                data: {
                  whoops: Object.assign(new Error(message), {
                    stack,
                    code
                  })
                }
              });

              expect(
                SerializableEvent.deserialize({
                  data: {
                    whoops: {
                      message,
                      stack,
                      code,
                      __type: 'Error'
                    }
                  }
                }),
                'to equal',
                expected
              );
            });
          });
        });

        describe('when passed value contains an `error` prop', function () {
          it('should create an Error instance from the prop', function () {
            const message = 'problems!';
            const stack = 'problem instructions';
            const code = 'EIEIO';
            const expected = Object.assign(Object.create(null), {
              error: Object.assign(new Error(message), {
                stack,
                code
              })
            });

            expect(
              SerializableEvent.deserialize({
                error: {
                  message,
                  stack,
                  code,
                  __type: 'Error'
                }
              }),
              'to equal',
              expected
            );
          });
        });

        describe('when passed value data contains a prop beginning with "$$"', function () {
          let result;

          beforeEach(function () {
            result = SerializableEvent.deserialize({data: {$$foo: 'bar'}});
          });
          it('should create a new prop having a function value', function () {
            expect(result, 'to satisfy', {
              data: {
                foo: expect.it('to be a function')
              }
            });
          });

          it('should create a new prop returning the original value', function () {
            expect(result.data.foo(), 'to equal', 'bar');
          });

          it('should remove the prop with the "$$" prefix', function () {
            expect(result, 'not to have property', '$$foo');
          });
        });

        describe('when the value data contains a prop with an array value', function () {
          beforeEach(function () {
            sinon.spy(SerializableEvent, '_deserializeObject');
          });

          it('should deserialize each prop', function () {
            const obj = {data: {foo: [{bar: 'baz'}]}};
            SerializableEvent.deserialize(obj);
            expect(
              SerializableEvent._deserializeObject,
              'to have a call satisfying',
              {
                args: [obj.data.foo, 0]
              }
            );
          });
        });
      });

      describe('create', function () {
        it('should instantiate a SerializableEvent', function () {
          expect(
            SerializableEvent.create('some-event'),
            'to be a',
            SerializableEvent
          );
        });
      });
    });
  });

  describe('SerializableWorkerResult', function () {
    describe('static method', function () {
      describe('create', function () {
        it('should return a new SerializableWorkerResult instance', function () {
          expect(
            SerializableWorkerResult.create(),
            'to be a',
            SerializableWorkerResult
          );
        });
      });

      describe('isSerializedWorkerResult', function () {
        describe('when passed an instance', function () {
          it('should return `true`', function () {
            expect(
              SerializableWorkerResult.isSerializedWorkerResult(
                new SerializableWorkerResult()
              ),
              'to be true'
            );
          });
        });

        describe('when passed an object with an appropriate `__type` prop', function () {
          it('should return `true`', function () {
            // this is the most likely use-case, as the object is transmitted over IPC
            // and loses its prototype
            const original = new SerializableWorkerResult();
            const clone = Object.assign({}, original);
            expect(
              SerializableWorkerResult.isSerializedWorkerResult(clone),
              'to be true'
            );
          });
        });

        describe('when passed an object without an appropriate `__type` prop', function () {
          it('should return `false`', function () {
            expect(
              SerializableWorkerResult.isSerializedWorkerResult({
                mister: 'mister'
              }),
              'to be false'
            );
          });
        });
      });

      describe('deserialize', function () {
        beforeEach(function () {
          sinon.stub(SerializableEvent, 'deserialize');
        });

        it('should call SerializableEvent#deserialize on each item in its `events` prop', function () {
          const result = Object.assign(
            {},
            SerializableWorkerResult.create([
              {eventName: 'foo'},
              {eventName: 'bar'}
            ])
          );
          SerializableWorkerResult.deserialize(result);
          expect(SerializableEvent.deserialize, 'to have calls satisfying', [
            {args: [{eventName: 'foo'}]},
            {args: [{eventName: 'bar'}]}
          ]);
        });

        it('should return the deserialized value', function () {
          const result = Object.assign(
            {},
            SerializableWorkerResult.create([
              {eventName: 'foo'},
              {eventName: 'bar'}
            ])
          );
          expect(
            SerializableWorkerResult.deserialize(result),
            'to equal',
            result
          );
        });
      });
    });

    describe('instance method', function () {
      describe('serialize', function () {
        it('should return a read-only value', function () {
          expect(
            Object.isFrozen(SerializableWorkerResult.create().serialize()),
            'to be true'
          );
        });

        it('should call `SerializableEvent#serialize` of each of its events', function () {
          sinon.spy(SerializableEvent.prototype, 'serialize');
          const events = [
            SerializableEvent.create('foo'),
            SerializableEvent.create('bar')
          ];
          SerializableWorkerResult.create(events).serialize();
          expect(
            SerializableEvent.prototype.serialize,
            'to have calls satisfying',
            [{thisValue: events[0]}, {thisValue: events[1]}]
          );
        });
      });
    });
    describe('constructor', function () {
      // the following two tests should be combined into one, but not sure how to express
      // as a single assertion

      it('should add a readonly `__type` prop', function () {
        expect(
          new SerializableWorkerResult(),
          'to have readonly property',
          '__type'
        );
      });
    });
  });
});
