/* eslint-disable
    camelcase,
    mocha/no-identical-title,
    no-return-assign,
    no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
 * decaffeinate suggestions:
 * DS102: Remove unnecessary code created because of implicit returns
 * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
 */
const sinon = require('sinon')
const chai = require('chai')
const should = chai.should()
const modulePath = '../../../../app/js/RedisManager.js'
const SandboxedModule = require('sandboxed-module')
const Errors = require('../../../../app/js/Errors')
const crypto = require('crypto')
const tk = require('timekeeper')

describe('RedisManager', function () {
  beforeEach(function () {
    let Timer
    this.multi = { exec: sinon.stub() }
    this.rclient = { multi: () => this.multi }
    tk.freeze(new Date())
    this.RedisManager = SandboxedModule.require(modulePath, {
      requires: {
        'logger-sharelatex': (this.logger = {
          error: sinon.stub(),
          log: sinon.stub(),
          warn: sinon.stub()
        }),
        './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}),
        'settings-sharelatex': (this.settings = {
          documentupdater: { logHashErrors: { write: true, read: true } },
          apis: {
            project_history: { enabled: true }
          },
          redis: {
            documentupdater: {
              key_schema: {
                blockingKey({ doc_id }) {
                  return `Blocking:${doc_id}`
                },
                docLines({ doc_id }) {
                  return `doclines:${doc_id}`
                },
                docOps({ doc_id }) {
                  return `DocOps:${doc_id}`
                },
                docVersion({ doc_id }) {
                  return `DocVersion:${doc_id}`
                },
                docHash({ doc_id }) {
                  return `DocHash:${doc_id}`
                },
                projectKey({ doc_id }) {
                  return `ProjectId:${doc_id}`
                },
                pendingUpdates({ doc_id }) {
                  return `PendingUpdates:${doc_id}`
                },
                docsInProject({ project_id }) {
                  return `DocsIn:${project_id}`
                },
                ranges({ doc_id }) {
                  return `Ranges:${doc_id}`
                },
                pathname({ doc_id }) {
                  return `Pathname:${doc_id}`
                },
                projectHistoryId({ doc_id }) {
                  return `ProjectHistoryId:${doc_id}`
                },
                projectHistoryType({ doc_id }) {
                  return `ProjectHistoryType:${doc_id}`
                },
                projectState({ project_id }) {
                  return `ProjectState:${project_id}`
                },
                unflushedTime({ doc_id }) {
                  return `UnflushedTime:${doc_id}`
                },
                lastUpdatedBy({ doc_id }) {
                  return `lastUpdatedBy:${doc_id}`
                },
                lastUpdatedAt({ doc_id }) {
                  return `lastUpdatedAt:${doc_id}`
                }
              }
            },
            history: {
              key_schema: {
                uncompressedHistoryOps({ doc_id }) {
                  return `UncompressedHistoryOps:${doc_id}`
                },
                docsWithHistoryOps({ project_id }) {
                  return `DocsWithHistoryOps:${project_id}`
                }
              }
            }
          }
        }),
        'redis-sharelatex': {
          createClient: () => this.rclient
        },
        './Metrics': (this.metrics = {
          inc: sinon.stub(),
          summary: sinon.stub(),
          Timer: (Timer = class Timer {
            constructor() {
              this.start = new Date()
            }

            done() {
              const timeSpan = new Date() - this.start
              return timeSpan
            }
          })
        }),
        './Errors': Errors
      },
      globals: {
        JSON: (this.JSON = JSON)
      }
    })

    this.doc_id = 'doc-id-123'
    this.project_id = 'project-id-123'
    this.projectHistoryId = 123
    return (this.callback = sinon.stub())
  })

  afterEach(function () {
    return tk.reset()
  })

  describe('getDoc', function () {
    beforeEach(function () {
      this.lines = ['one', 'two', 'three', 'これは'] // include some utf8
      this.jsonlines = JSON.stringify(this.lines)
      this.version = 42
      this.hash = crypto
        .createHash('sha1')
        .update(this.jsonlines, 'utf8')
        .digest('hex')
      this.ranges = { comments: 'mock', entries: 'mock' }
      this.json_ranges = JSON.stringify(this.ranges)
      this.unflushed_time = 12345
      this.pathname = '/a/b/c.tex'
      this.multi.get = sinon.stub()
      this.multi.exec = sinon
        .stub()
        .callsArgWith(0, null, [
          this.jsonlines,
          this.version,
          this.hash,
          this.project_id,
          this.json_ranges,
          this.pathname,
          this.projectHistoryId.toString(),
          this.unflushed_time
        ])
      return (this.rclient.sadd = sinon.stub().yields(null, 0))
    })

    describe('successfully', function () {
      beforeEach(function () {
        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      it('should get the lines from redis', function () {
        return this.multi.get
          .calledWith(`doclines:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the version from', function () {
        return this.multi.get
          .calledWith(`DocVersion:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the hash', function () {
        return this.multi.get
          .calledWith(`DocHash:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the ranges', function () {
        return this.multi.get
          .calledWith(`Ranges:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the unflushed time', function () {
        return this.multi.get
          .calledWith(`UnflushedTime:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the pathname', function () {
        return this.multi.get
          .calledWith(`Pathname:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the projectHistoryId as an integer', function () {
        return this.multi.get
          .calledWith(`ProjectHistoryId:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get lastUpdatedAt', function () {
        return this.multi.get
          .calledWith(`lastUpdatedAt:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get lastUpdatedBy', function () {
        return this.multi.get
          .calledWith(`lastUpdatedBy:${this.doc_id}`)
          .should.equal(true)
      })

      it('should check if the document is in the DocsIn set', function () {
        return this.rclient.sadd
          .calledWith(`DocsIn:${this.project_id}`)
          .should.equal(true)
      })

      it('should return the document', function () {
        return this.callback
          .calledWithExactly(
            null,
            this.lines,
            this.version,
            this.ranges,
            this.pathname,
            this.projectHistoryId,
            this.unflushed_time,
            this.lastUpdatedAt,
            this.lastUpdatedBy
          )
          .should.equal(true)
      })

      return it('should not log any errors', function () {
        return this.logger.error.calledWith().should.equal(false)
      })
    })

    describe('when the document is not present', function () {
      beforeEach(function () {
        this.multi.exec = sinon
          .stub()
          .callsArgWith(0, null, [
            null,
            null,
            null,
            null,
            null,
            null,
            null,
            null,
            null,
            null
          ])
        this.rclient.sadd = sinon.stub().yields()
        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      it('should not check if the document is in the DocsIn set', function () {
        return this.rclient.sadd
          .calledWith(`DocsIn:${this.project_id}`)
          .should.equal(false)
      })

      it('should return an empty result', function () {
        return this.callback
          .calledWithExactly(null, null, 0, {}, null, null, null, null, null)
          .should.equal(true)
      })

      return it('should not log any errors', function () {
        return this.logger.error.calledWith().should.equal(false)
      })
    })

    describe('when the document is missing from the DocsIn set', function () {
      beforeEach(function () {
        this.rclient.sadd = sinon.stub().yields(null, 1)
        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      it('should log an error', function () {
        return this.logger.error.calledWith().should.equal(true)
      })

      return it('should return the document', function () {
        return this.callback
          .calledWithExactly(
            null,
            this.lines,
            this.version,
            this.ranges,
            this.pathname,
            this.projectHistoryId,
            this.unflushed_time,
            this.lastUpdatedAt,
            this.lastUpdatedBy
          )
          .should.equal(true)
      })
    })

    describe('with a corrupted document', function () {
      beforeEach(function () {
        this.badHash = 'INVALID-HASH-VALUE'
        this.multi.exec = sinon
          .stub()
          .callsArgWith(0, null, [
            this.jsonlines,
            this.version,
            this.badHash,
            this.project_id,
            this.json_ranges
          ])
        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      it('should log a hash error', function () {
        return this.logger.error.calledWith().should.equal(true)
      })

      return it('should return the document', function () {
        return this.callback
          .calledWith(null, this.lines, this.version, this.ranges)
          .should.equal(true)
      })
    })

    describe('with a slow request to redis', function () {
      beforeEach(function () {
        this.multi.exec = sinon
          .stub()
          .callsArgWith(0, null, [
            this.jsonlines,
            this.version,
            this.badHash,
            this.project_id,
            this.json_ranges,
            this.pathname,
            this.unflushed_time
          ])
        this.clock = sinon.useFakeTimers()
        this.multi.exec = (cb) => {
          this.clock.tick(6000)
          return cb(null, [
            this.jsonlines,
            this.version,
            this.another_project_id,
            this.json_ranges,
            this.pathname,
            this.unflushed_time
          ])
        }

        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      afterEach(function () {
        return this.clock.restore()
      })

      return it('should return an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })

    return describe('getDoc with an invalid project id', function () {
      beforeEach(function () {
        this.another_project_id = 'project-id-456'
        this.multi.exec = sinon
          .stub()
          .callsArgWith(0, null, [
            this.jsonlines,
            this.version,
            this.hash,
            this.another_project_id,
            this.json_ranges,
            this.pathname,
            this.unflushed_time
          ])
        return this.RedisManager.getDoc(
          this.project_id,
          this.doc_id,
          this.callback
        )
      })

      return it('should return an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Errors.NotFoundError))
          .should.equal(true)
      })
    })
  })

  describe('getPreviousDocOpsTests', function () {
    describe('with a start and an end value', function () {
      beforeEach(function () {
        this.first_version_in_redis = 30
        this.version = 70
        this.length = this.version - this.first_version_in_redis
        this.start = 50
        this.end = 60
        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
        this.rclient.get = sinon
          .stub()
          .callsArgWith(1, null, this.version.toString())
        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
        return this.RedisManager.getPreviousDocOps(
          this.doc_id,
          this.start,
          this.end,
          this.callback
        )
      })

      it('should get the length of the existing doc ops', function () {
        return this.rclient.llen
          .calledWith(`DocOps:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the current version of the doc', function () {
        return this.rclient.get
          .calledWith(`DocVersion:${this.doc_id}`)
          .should.equal(true)
      })

      it('should get the appropriate docs ops', function () {
        return this.rclient.lrange
          .calledWith(
            `DocOps:${this.doc_id}`,
            this.start - this.first_version_in_redis,
            this.end - this.first_version_in_redis
          )
          .should.equal(true)
      })

      return it('should return the docs with the doc ops deserialized', function () {
        return this.callback.calledWith(null, this.ops).should.equal(true)
      })
    })

    describe('with an end value of -1', function () {
      beforeEach(function () {
        this.first_version_in_redis = 30
        this.version = 70
        this.length = this.version - this.first_version_in_redis
        this.start = 50
        this.end = -1
        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
        this.rclient.get = sinon
          .stub()
          .callsArgWith(1, null, this.version.toString())
        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
        return this.RedisManager.getPreviousDocOps(
          this.doc_id,
          this.start,
          this.end,
          this.callback
        )
      })

      it('should get the appropriate docs ops to the end of list', function () {
        return this.rclient.lrange
          .calledWith(
            `DocOps:${this.doc_id}`,
            this.start - this.first_version_in_redis,
            -1
          )
          .should.equal(true)
      })

      return it('should return the docs with the doc ops deserialized', function () {
        return this.callback.calledWith(null, this.ops).should.equal(true)
      })
    })

    describe('when the requested range is not in Redis', function () {
      beforeEach(function () {
        this.first_version_in_redis = 30
        this.version = 70
        this.length = this.version - this.first_version_in_redis
        this.start = 20
        this.end = -1
        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
        this.rclient.get = sinon
          .stub()
          .callsArgWith(1, null, this.version.toString())
        this.rclient.lrange = sinon.stub().callsArgWith(3, null, this.jsonOps)
        return this.RedisManager.getPreviousDocOps(
          this.doc_id,
          this.start,
          this.end,
          this.callback
        )
      })

      it('should return an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Errors.OpRangeNotAvailableError))
          .should.equal(true)
      })

      return it('should log out the problem', function () {
        return this.logger.warn.called.should.equal(true)
      })
    })

    return describe('with a slow request to redis', function () {
      beforeEach(function () {
        this.first_version_in_redis = 30
        this.version = 70
        this.length = this.version - this.first_version_in_redis
        this.start = 50
        this.end = 60
        this.ops = [{ mock: 'op-1' }, { mock: 'op-2' }]
        this.jsonOps = this.ops.map((op) => JSON.stringify(op))
        this.rclient.llen = sinon.stub().callsArgWith(1, null, this.length)
        this.rclient.get = sinon
          .stub()
          .callsArgWith(1, null, this.version.toString())
        this.clock = sinon.useFakeTimers()
        this.rclient.lrange = (key, start, end, cb) => {
          this.clock.tick(6000)
          return cb(null, this.jsonOps)
        }
        return this.RedisManager.getPreviousDocOps(
          this.doc_id,
          this.start,
          this.end,
          this.callback
        )
      })

      afterEach(function () {
        return this.clock.restore()
      })

      return it('should return an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })
  })

  describe('updateDocument', function () {
    beforeEach(function () {
      this.lines = ['one', 'two', 'three', 'これは']
      this.ops = [{ op: [{ i: 'foo', p: 4 }] }, { op: [{ i: 'bar', p: 8 }] }]
      this.version = 42
      this.hash = crypto
        .createHash('sha1')
        .update(JSON.stringify(this.lines), 'utf8')
        .digest('hex')
      this.ranges = { comments: 'mock', entries: 'mock' }
      this.updateMeta = { user_id: 'last-author-fake-id' }
      this.doc_update_list_length = sinon.stub()
      this.project_update_list_length = sinon.stub()

      this.RedisManager.getDocVersion = sinon.stub()
      this.multi.set = sinon.stub()
      this.multi.rpush = sinon.stub()
      this.multi.expire = sinon.stub()
      this.multi.ltrim = sinon.stub()
      this.multi.del = sinon.stub()
      this.multi.exec = sinon
        .stub()
        .callsArgWith(0, null, [
          this.hash,
          null,
          null,
          null,
          null,
          null,
          null,
          this.doc_update_list_length,
          null,
          null
        ])
      return (this.ProjectHistoryRedisManager.queueOps = sinon
        .stub()
        .callsArgWith(
          this.ops.length + 1,
          null,
          this.project_update_list_length
        ))
    })

    describe('with a consistent version', function () {
      beforeEach(function () {})

      describe('with project history enabled', function () {
        beforeEach(function () {
          this.settings.apis.project_history.enabled = true
          this.RedisManager.getDocVersion
            .withArgs(this.doc_id)
            .yields(null, this.version - this.ops.length)
          return this.RedisManager.updateDocument(
            this.project_id,
            this.doc_id,
            this.lines,
            this.version,
            this.ops,
            this.ranges,
            this.updateMeta,
            this.callback
          )
        })

        it('should get the current doc version to check for consistency', function () {
          return this.RedisManager.getDocVersion
            .calledWith(this.doc_id)
            .should.equal(true)
        })

        it('should set the doclines', function () {
          return this.multi.set
            .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
            .should.equal(true)
        })

        it('should set the version', function () {
          return this.multi.set
            .calledWith(`DocVersion:${this.doc_id}`, this.version)
            .should.equal(true)
        })

        it('should set the hash', function () {
          return this.multi.set
            .calledWith(`DocHash:${this.doc_id}`, this.hash)
            .should.equal(true)
        })

        it('should set the ranges', function () {
          return this.multi.set
            .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
            .should.equal(true)
        })

        it('should set the unflushed time', function () {
          return this.multi.set
            .calledWith(`UnflushedTime:${this.doc_id}`, Date.now(), 'NX')
            .should.equal(true)
        })

        it('should set the last updated time', function () {
          return this.multi.set
            .calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
            .should.equal(true)
        })

        it('should set the last updater', function () {
          return this.multi.set
            .calledWith(`lastUpdatedBy:${this.doc_id}`, 'last-author-fake-id')
            .should.equal(true)
        })

        it('should push the doc op into the doc ops list', function () {
          return this.multi.rpush
            .calledWith(
              `DocOps:${this.doc_id}`,
              JSON.stringify(this.ops[0]),
              JSON.stringify(this.ops[1])
            )
            .should.equal(true)
        })

        it('should renew the expiry ttl on the doc ops array', function () {
          return this.multi.expire
            .calledWith(`DocOps:${this.doc_id}`, this.RedisManager.DOC_OPS_TTL)
            .should.equal(true)
        })

        it('should truncate the list to 100 members', function () {
          return this.multi.ltrim
            .calledWith(
              `DocOps:${this.doc_id}`,
              -this.RedisManager.DOC_OPS_MAX_LENGTH,
              -1
            )
            .should.equal(true)
        })

        it('should push the updates into the history ops list', function () {
          return this.multi.rpush
            .calledWith(
              `UncompressedHistoryOps:${this.doc_id}`,
              JSON.stringify(this.ops[0]),
              JSON.stringify(this.ops[1])
            )
            .should.equal(true)
        })

        it('should push the updates into the project history ops list', function () {
          return this.ProjectHistoryRedisManager.queueOps
            .calledWith(this.project_id, JSON.stringify(this.ops[0]))
            .should.equal(true)
        })

        it('should call the callback', function () {
          return this.callback
            .calledWith(
              null,
              this.doc_update_list_length,
              this.project_update_list_length
            )
            .should.equal(true)
        })

        return it('should not log any errors', function () {
          return this.logger.error.calledWith().should.equal(false)
        })
      })

      describe('with project history disabled', function () {
        beforeEach(function () {
          this.settings.apis.project_history.enabled = false
          this.RedisManager.getDocVersion
            .withArgs(this.doc_id)
            .yields(null, this.version - this.ops.length)
          return this.RedisManager.updateDocument(
            this.project_id,
            this.doc_id,
            this.lines,
            this.version,
            this.ops,
            this.ranges,
            this.updateMeta,
            this.callback
          )
        })

        it('should not push the updates into the project history ops list', function () {
          return this.ProjectHistoryRedisManager.queueOps.called.should.equal(
            false
          )
        })

        return it('should call the callback', function () {
          return this.callback
            .calledWith(null, this.doc_update_list_length)
            .should.equal(true)
        })
      })

      return describe('with a doc using project history only', function () {
        beforeEach(function () {
          this.RedisManager.getDocVersion
            .withArgs(this.doc_id)
            .yields(null, this.version - this.ops.length, 'project-history')
          return this.RedisManager.updateDocument(
            this.project_id,
            this.doc_id,
            this.lines,
            this.version,
            this.ops,
            this.ranges,
            this.updateMeta,
            this.callback
          )
        })

        it('should not push the updates to the track-changes ops list', function () {
          return this.multi.rpush
            .calledWith(`UncompressedHistoryOps:${this.doc_id}`)
            .should.equal(false)
        })

        it('should push the updates into the project history ops list', function () {
          return this.ProjectHistoryRedisManager.queueOps
            .calledWith(this.project_id, JSON.stringify(this.ops[0]))
            .should.equal(true)
        })

        return it('should call the callback with the project update count only', function () {
          return this.callback
            .calledWith(null, undefined, this.project_update_list_length)
            .should.equal(true)
        })
      })
    })

    describe('with an inconsistent version', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version - this.ops.length - 1)
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ops,
          this.ranges,
          this.updateMeta,
          this.callback
        )
      })

      it('should not call multi.exec', function () {
        return this.multi.exec.called.should.equal(false)
      })

      return it('should call the callback with an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })

    describe('with no updates', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version)
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          [],
          this.ranges,
          this.updateMeta,
          this.callback
        )
      })

      it('should not try to enqueue doc updates', function () {
        return this.multi.rpush.called.should.equal(false)
      })

      it('should not try to enqueue project updates', function () {
        return this.ProjectHistoryRedisManager.queueOps.called.should.equal(
          false
        )
      })

      return it('should still set the doclines', function () {
        return this.multi.set
          .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
          .should.equal(true)
      })
    })

    describe('with empty ranges', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version - this.ops.length)
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ops,
          {},
          this.updateMeta,
          this.callback
        )
      })

      it('should not set the ranges', function () {
        return this.multi.set
          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
          .should.equal(false)
      })

      return it('should delete the ranges key', function () {
        return this.multi.del
          .calledWith(`Ranges:${this.doc_id}`)
          .should.equal(true)
      })
    })

    describe('with null bytes in the serialized doc lines', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version - this.ops.length)
        this._stringify = JSON.stringify
        this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]'
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ops,
          this.ranges,
          this.updateMeta,
          this.callback
        )
      })

      afterEach(function () {
        return (this.JSON.stringify = this._stringify)
      })

      it('should log an error', function () {
        return this.logger.error.called.should.equal(true)
      })

      return it('should call the callback with an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })

    describe('with ranges that are too big', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version - this.ops.length)
        this.RedisManager._serializeRanges = sinon
          .stub()
          .yields(new Error('ranges are too large'))
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ops,
          this.ranges,
          this.updateMeta,
          this.callback
        )
      })

      it('should log an error', function () {
        return this.logger.error.called.should.equal(true)
      })

      return it('should call the callback with the error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })

    return describe('without user id from meta', function () {
      beforeEach(function () {
        this.RedisManager.getDocVersion
          .withArgs(this.doc_id)
          .yields(null, this.version - this.ops.length)
        return this.RedisManager.updateDocument(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ops,
          this.ranges,
          {},
          this.callback
        )
      })

      it('should set the last updater to null', function () {
        return this.multi.del
          .calledWith(`lastUpdatedBy:${this.doc_id}`)
          .should.equal(true)
      })

      return it('should still set the last updated time', function () {
        return this.multi.set
          .calledWith(`lastUpdatedAt:${this.doc_id}`, Date.now())
          .should.equal(true)
      })
    })
  })

  describe('putDocInMemory', function () {
    beforeEach(function () {
      this.multi.set = sinon.stub()
      this.rclient.sadd = sinon.stub().yields()
      this.multi.del = sinon.stub()
      this.lines = ['one', 'two', 'three', 'これは']
      this.version = 42
      this.hash = crypto
        .createHash('sha1')
        .update(JSON.stringify(this.lines), 'utf8')
        .digest('hex')
      this.multi.exec = sinon.stub().callsArgWith(0, null, [this.hash])
      this.ranges = { comments: 'mock', entries: 'mock' }
      return (this.pathname = '/a/b/c.tex')
    })

    describe('with non-empty ranges', function () {
      beforeEach(function (done) {
        return this.RedisManager.putDocInMemory(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ranges,
          this.pathname,
          this.projectHistoryId,
          done
        )
      })

      it('should set the lines', function () {
        return this.multi.set
          .calledWith(`doclines:${this.doc_id}`, JSON.stringify(this.lines))
          .should.equal(true)
      })

      it('should set the version', function () {
        return this.multi.set
          .calledWith(`DocVersion:${this.doc_id}`, this.version)
          .should.equal(true)
      })

      it('should set the hash', function () {
        return this.multi.set
          .calledWith(`DocHash:${this.doc_id}`, this.hash)
          .should.equal(true)
      })

      it('should set the ranges', function () {
        return this.multi.set
          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
          .should.equal(true)
      })

      it('should set the project_id for the doc', function () {
        return this.multi.set
          .calledWith(`ProjectId:${this.doc_id}`, this.project_id)
          .should.equal(true)
      })

      it('should set the pathname for the doc', function () {
        return this.multi.set
          .calledWith(`Pathname:${this.doc_id}`, this.pathname)
          .should.equal(true)
      })

      it('should set the projectHistoryId for the doc', function () {
        return this.multi.set
          .calledWith(`ProjectHistoryId:${this.doc_id}`, this.projectHistoryId)
          .should.equal(true)
      })

      it('should add the doc_id to the project set', function () {
        return this.rclient.sadd
          .calledWith(`DocsIn:${this.project_id}`, this.doc_id)
          .should.equal(true)
      })

      return it('should not log any errors', function () {
        return this.logger.error.calledWith().should.equal(false)
      })
    })

    describe('with empty ranges', function () {
      beforeEach(function (done) {
        return this.RedisManager.putDocInMemory(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          {},
          this.pathname,
          this.projectHistoryId,
          done
        )
      })

      it('should delete the ranges key', function () {
        return this.multi.del
          .calledWith(`Ranges:${this.doc_id}`)
          .should.equal(true)
      })

      return it('should not set the ranges', function () {
        return this.multi.set
          .calledWith(`Ranges:${this.doc_id}`, JSON.stringify(this.ranges))
          .should.equal(false)
      })
    })

    describe('with null bytes in the serialized doc lines', function () {
      beforeEach(function () {
        this._stringify = JSON.stringify
        this.JSON.stringify = () => '["bad bytes! \u0000 <- here"]'
        return this.RedisManager.putDocInMemory(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ranges,
          this.pathname,
          this.projectHistoryId,
          this.callback
        )
      })

      afterEach(function () {
        return (this.JSON.stringify = this._stringify)
      })

      it('should log an error', function () {
        return this.logger.error.called.should.equal(true)
      })

      return it('should call the callback with an error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })

    return describe('with ranges that are too big', function () {
      beforeEach(function () {
        this.RedisManager._serializeRanges = sinon
          .stub()
          .yields(new Error('ranges are too large'))
        return this.RedisManager.putDocInMemory(
          this.project_id,
          this.doc_id,
          this.lines,
          this.version,
          this.ranges,
          this.pathname,
          this.projectHistoryId,
          this.callback
        )
      })

      it('should log an error', function () {
        return this.logger.error.called.should.equal(true)
      })

      return it('should call the callback with the error', function () {
        return this.callback
          .calledWith(sinon.match.instanceOf(Error))
          .should.equal(true)
      })
    })
  })

  describe('removeDocFromMemory', function () {
    beforeEach(function (done) {
      this.multi.strlen = sinon.stub()
      this.multi.del = sinon.stub()
      this.multi.srem = sinon.stub()
      this.multi.exec.yields()
      return this.RedisManager.removeDocFromMemory(
        this.project_id,
        this.doc_id,
        done
      )
    })

    it('should check the length of the current doclines', function () {
      return this.multi.strlen
        .calledWith(`doclines:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the lines', function () {
      return this.multi.del
        .calledWith(`doclines:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the version', function () {
      return this.multi.del
        .calledWith(`DocVersion:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the hash', function () {
      return this.multi.del
        .calledWith(`DocHash:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the unflushed time', function () {
      return this.multi.del
        .calledWith(`UnflushedTime:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the project_id for the doc', function () {
      return this.multi.del
        .calledWith(`ProjectId:${this.doc_id}`)
        .should.equal(true)
    })

    it('should remove the doc_id from the project set', function () {
      return this.multi.srem
        .calledWith(`DocsIn:${this.project_id}`, this.doc_id)
        .should.equal(true)
    })

    it('should delete the pathname for the doc', function () {
      return this.multi.del
        .calledWith(`Pathname:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete the pathname for the doc', function () {
      return this.multi.del
        .calledWith(`ProjectHistoryId:${this.doc_id}`)
        .should.equal(true)
    })

    it('should delete lastUpdatedAt', function () {
      return this.multi.del
        .calledWith(`lastUpdatedAt:${this.doc_id}`)
        .should.equal(true)
    })

    return it('should delete lastUpdatedBy', function () {
      return this.multi.del
        .calledWith(`lastUpdatedBy:${this.doc_id}`)
        .should.equal(true)
    })
  })

  describe('clearProjectState', function () {
    beforeEach(function (done) {
      this.rclient.del = sinon.stub().callsArg(1)
      return this.RedisManager.clearProjectState(this.project_id, done)
    })

    return it('should delete the project state', function () {
      return this.rclient.del
        .calledWith(`ProjectState:${this.project_id}`)
        .should.equal(true)
    })
  })

  return describe('renameDoc', function () {
    beforeEach(function () {
      this.rclient.rpush = sinon.stub().yields()
      this.rclient.set = sinon.stub().yields()
      return (this.update = {
        id: this.doc_id,
        pathname: (this.pathname = 'pathname'),
        newPathname: (this.newPathname = 'new-pathname')
      })
    })

    describe('the document is cached in redis', function () {
      beforeEach(function () {
        this.RedisManager.getDoc = sinon
          .stub()
          .callsArgWith(2, null, 'lines', 'version')
        this.ProjectHistoryRedisManager.queueRenameEntity = sinon
          .stub()
          .yields()
        return this.RedisManager.renameDoc(
          this.project_id,
          this.doc_id,
          this.userId,
          this.update,
          this.projectHistoryId,
          this.callback
        )
      })

      it('update the cached pathname', function () {
        return this.rclient.set
          .calledWith(`Pathname:${this.doc_id}`, this.newPathname)
          .should.equal(true)
      })

      return it('should queue an update', function () {
        return this.ProjectHistoryRedisManager.queueRenameEntity
          .calledWithExactly(
            this.project_id,
            this.projectHistoryId,
            'doc',
            this.doc_id,
            this.userId,
            this.update,
            this.callback
          )
          .should.equal(true)
      })
    })

    describe('the document is not cached in redis', function () {
      beforeEach(function () {
        this.RedisManager.getDoc = sinon
          .stub()
          .callsArgWith(2, null, null, null)
        this.ProjectHistoryRedisManager.queueRenameEntity = sinon
          .stub()
          .yields()
        return this.RedisManager.renameDoc(
          this.project_id,
          this.doc_id,
          this.userId,
          this.update,
          this.projectHistoryId,
          this.callback
        )
      })

      it('does not update the cached pathname', function () {
        return this.rclient.set.called.should.equal(false)
      })

      return it('should queue an update', function () {
        return this.ProjectHistoryRedisManager.queueRenameEntity
          .calledWithExactly(
            this.project_id,
            this.projectHistoryId,
            'doc',
            this.doc_id,
            this.userId,
            this.update,
            this.callback
          )
          .should.equal(true)
      })
    })

    return describe('getDocVersion', function () {
      beforeEach(function () {
        return (this.version = 12345)
      })

      describe('when the document does not have a project history type set', function () {
        beforeEach(function () {
          this.rclient.mget = sinon
            .stub()
            .withArgs(
              `DocVersion:${this.doc_id}`,
              `ProjectHistoryType:${this.doc_id}`
            )
            .callsArgWith(2, null, [`${this.version}`])
          return this.RedisManager.getDocVersion(this.doc_id, this.callback)
        })

        return it('should return the document version and an undefined history type', function () {
          return this.callback
            .calledWithExactly(null, this.version, undefined)
            .should.equal(true)
        })
      })

      return describe('when the document has a project history type set', function () {
        beforeEach(function () {
          this.rclient.mget = sinon
            .stub()
            .withArgs(
              `DocVersion:${this.doc_id}`,
              `ProjectHistoryType:${this.doc_id}`
            )
            .callsArgWith(2, null, [`${this.version}`, 'project-history'])
          return this.RedisManager.getDocVersion(this.doc_id, this.callback)
        })

        return it('should return the document version and history type', function () {
          return this.callback
            .calledWithExactly(null, this.version, 'project-history')
            .should.equal(true)
        })
      })
    })
  })
})
