Mailspring/spec/database-view-spec.coffee
2015-10-01 21:39:44 -07:00

375 lines
16 KiB
CoffeeScript

_ = require 'underscore'
EventEmitter = require('events').EventEmitter
proxyquire = require 'proxyquire'
Label = require '../src/flux/models/label'
Thread = require '../src/flux/models/thread'
Message = require '../src/flux/models/message'
DatabaseStore = require '../src/flux/stores/database-store'
DatabaseView = proxyquire '../src/flux/stores/database-view',
DatabaseStore: DatabaseStore
describe "DatabaseView", ->
beforeEach ->
@queries = []
spyOn(DatabaseStore, 'run').andCallFake (query) =>
new Promise (resolve, reject) =>
query.resolve = resolve
@queries.push(query)
describe "constructor", ->
it "should require a model class", ->
expect(( -> new DatabaseView())).toThrow()
expect(( -> new DatabaseView(Thread))).not.toThrow()
view = new DatabaseView(Thread)
expect(view.klass).toBe(Thread)
it "should optionally populate matchers and includes", ->
config =
matchers: [Message.attributes.accountId.equal('asd')]
includes: [Message.attributes.body]
view = new DatabaseView(Message, config)
expect(view._matchers).toEqual(config.matchers)
expect(view._includes).toEqual(config.includes)
it "should optionally populate ordering", ->
config =
orders: [Message.attributes.date.descending()]
view = new DatabaseView(Message, config)
expect(view._orders).toEqual(config.orders)
it "should optionally accept a metadata provider", ->
provider = ->
view = new DatabaseView(Message, {}, provider)
expect(view._metadataProvider).toEqual(provider)
it "should initialize the row count to -1", ->
view = new DatabaseView(Message)
expect(view.count()).toBe(-1)
it "should immediately start fetching a row count", ->
config =
matchers: [Message.attributes.accountId.equal('asd')]
view = new DatabaseView(Message, config)
# Count query
expect(@queries[0]._count).toEqual(true)
expect(@queries[0]._matchers).toEqual(config.matchers)
describe "instance methods", ->
beforeEach ->
config =
matchers: [Message.attributes.accountId.equal('asd')]
@view = new DatabaseView(Message, config)
@view._pages =
0:
items: [new Thread(id: 'a'), new Thread(id: 'b'), new Thread(id: 'c')]
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
loaded: true
1:
items: [new Thread(id: 'd'), new Thread(id: 'e'), new Thread(id: 'f')]
metadata: {'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'}
loaded: true
@view._count = 1
spyOn(@view, 'invalidateRetainedRange').andCallFake ->
describe "setMetadataProvider", ->
it "should empty the page cache and re-fetch all pages", ->
@view.setMetadataProvider( -> false)
expect(@view._pages).toEqual({})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
describe "setMatchers", ->
it "should reset the row count", ->
@view.setMatchers([])
expect(@view._count).toEqual(-1)
it "should empty the page cache and re-fetch all pages", ->
@view.setMatchers([])
expect(@view._pages).toEqual({})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
describe "setIncludes", ->
it "should empty the page cache and re-fetch all pages", ->
@view.setIncludes([])
expect(@view._pages).toEqual({})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
describe "invalidate", ->
it "should clear the metadata cache for each page and re-fetch", ->
@view.invalidate({shallow: false})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
expect(@view._pages[0].metadata).toEqual({})
describe "when the shallow option is provided", ->
it "should refetch items in each page, but not flush the item metadata cache", ->
beforeMetadata = @view._pages[0].metadata
@view.invalidate({shallow: true})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
expect(@view._pages[0].metadata).toEqual(beforeMetadata)
describe "when the shallow option is provided with specific changed items", ->
it "should determine whether changes to these items make page(s) invalid", ->
spyOn(@view, 'invalidateAfterDatabaseChange').andCallFake ->
@view.invalidate({shallow: true, change: {objects: ['a'], type: 'persist'}})
expect(@view.invalidateAfterDatabaseChange).toHaveBeenCalled()
describe "invalidateMetadataFor", ->
it "should clear cached metadata for just the items whose ids are provided", ->
expect(@view._pages[0].metadata).toEqual({'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'})
expect(@view._pages[1].metadata).toEqual({'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'})
@view.invalidateMetadataFor(['b', 'e'])
expect(@view._pages[0].metadata['b']).toBe(undefined)
expect(@view._pages[1].metadata['e']).toBe(undefined)
it "should re-retrieve page metadata for only impacted pages", ->
spyOn(@view, 'retrievePageMetadata')
@view.invalidateMetadataFor(['e'])
expect(@view.retrievePageMetadata).toHaveBeenCalled()
expect(@view.retrievePageMetadata.calls[0].args[0]).toEqual('1')
describe "invalidateAfterDatabaseChange", ->
beforeEach ->
@inbox = new Label(id: 'l-1', name: 'inbox', displayName: 'Inbox')
@archive = new Label(id: 'l-2', name: 'archive', displayName: 'archive')
@a = new Thread(id: 'a', subject: 'a', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@b = new Thread(id: 'b', subject: 'b', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@c = new Thread(id: 'c', subject: 'c', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@d = new Thread(id: 'd', subject: 'd', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@e = new Thread(id: 'e', subject: 'e', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@f = new Thread(id: 'f', subject: 'f', labels:[@inbox], lastMessageReceivedTimestamp: new Date(1428526885604))
@view = new DatabaseView Thread,
matchers: [Thread.attributes.labels.contains('l-1')]
@view._pages =
"0":
items: [@a, @b, @c]
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
loaded: true
"1":
items: [@d, @e, @f]
metadata: {'d': 'd-metadata', 'e': 'e-metadata', 'f': 'f-metadata'}
loaded: true
spyOn(@view, 'invalidateRetainedRange')
it "should invalidate the entire range if more than 5 items are provided", ->
@view.invalidateAfterDatabaseChange({objects:[@a, @b, @c, @d, @e, @f], type:'persist'})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
it "should invalidate the entire range if a provided item is in the set but no longer matches the set", ->
a = new Thread(@a)
a.labels = [@archive]
@view.invalidateAfterDatabaseChange({objects:[a], type:'persist'})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
it "should invalidate the entire range if a provided item is not in the set but matches the set", ->
incoming = new Thread(id: 'a', subject: 'a', labels:[@inbox], lastMessageReceivedTimestamp: new Date())
@view.invalidateAfterDatabaseChange({objects:[incoming], type:'persist'})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
it "should invalidate the entire range if a provided item matches the set and the value of it's sorting attribute has changed", ->
a = new Thread(@a)
a.lastMessageReceivedTimestamp = new Date(1428526909533)
@view.invalidateAfterDatabaseChange({objects:[a], type:'persist'})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
it "should not do anything if no provided items are in the set or belong in the set", ->
archived = new Thread(id: 'zz', labels: [@archive])
@view.invalidateAfterDatabaseChange({objects:[archived], type: 'persist'})
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
it "should replace items in place otherwise", ->
a = new Thread(@a)
a.subject = 'Subject changed, nothing to see here!'
@view.invalidateAfterDatabaseChange({objects:[a], type: 'persist'})
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
a = new Thread(@a)
a.labels = [@inbox, @archive] # not realistic, but doesn't change membership in set
@view.invalidateAfterDatabaseChange({objects:[a], type: 'persist'})
expect(@view.invalidateRetainedRange).not.toHaveBeenCalled()
it "should attach the metadata field to replaced items", ->
spyOn(@view._emitter, 'emit')
subject = 'Subject changed, nothing to see here!'
runs ->
e = new Thread(@e)
e.subject = subject
@view.invalidateAfterDatabaseChange({objects:[e], type: 'persist'})
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[1].items[1].id).toEqual(@e.id)
expect(@view._pages[1].items[1].subject).toEqual(subject)
expect(@view._pages[1].items[1].metadata).toEqual(@view._pages[1].metadata[@e.id])
describe "when items have been removed", ->
beforeEach ->
spyOn(@view._emitter, 'emit')
@start = @view._pages[1].lastTouchTime
runs ->
b = new Thread(@b)
b.labels = []
@view.invalidateAfterDatabaseChange({objects:[b], type: 'persist'})
waitsFor ->
@view._emitter.emit.callCount > 0
it "should optimistically remove them and shift result pages", ->
expect(@view._pages[0].items).toEqual([@a, @c, @d])
expect(@view._pages[1].items).toEqual([@e, @f])
it "should change the lastTouchTime date of changed pages so that refreshes started before the replacement do not revert it's changes", ->
expect(@view._pages[0].lastTouchTime isnt @start).toEqual(true)
expect(@view._pages[1].lastTouchTime isnt @start).toEqual(true)
describe "when items have been unpersisted but still match criteria", ->
beforeEach ->
spyOn(@view._emitter, 'emit')
@start = @view._pages[1].lastTouchTime
runs ->
@view.invalidateAfterDatabaseChange({objects:[@b], type: 'unpersist'})
waitsFor ->
@view._emitter.emit.callCount > 0
it "should optimistically remove them and shift result pages", ->
expect(@view._pages[0].items).toEqual([@a, @c, @d])
expect(@view._pages[1].items).toEqual([@e, @f])
it "should change the lastTouchTime date of changed pages so that refreshes started before the replacement do not revert it's changes", ->
expect(@view._pages[0].lastTouchTime isnt @start).toEqual(true)
expect(@view._pages[1].lastTouchTime isnt @start).toEqual(true)
describe "cullPages", ->
beforeEach ->
@view._retainedRange = {start: 200, end: 399}
@view._pages = {}
for i in [0..9]
@view._pages[i] =
items: [new Thread(id: 'a'), new Thread(id: 'b'), new Thread(id: 'c')]
metadata: {'a': 'a-metadata', 'b': 'b-metadata', 'c': 'c-metadata'}
loaded: true
it "should not remove pages in the retained range", ->
@view.cullPages()
expect(@view._pages[2]).toBeDefined()
expect(@view._pages[3]).toBeDefined()
expect(@view._pages[4]).toBeDefined()
it "should remove pages far from the retained range", ->
@view.cullPages()
expect(@view._pages[7]).not.toBeDefined()
expect(@view._pages[8]).not.toBeDefined()
expect(@view._pages[9]).not.toBeDefined()
describe "retrievePage", ->
beforeEach ->
@config =
matchers: [Message.attributes.accountId.equal('asd')]
orders: [Message.attributes.date.descending()]
@view = new DatabaseView(Message, @config)
@queries = []
it "should initialize the page and set loading to true", ->
@view.retrievePage(0)
expect(@view._pages[0].metadata).toEqual({})
expect(@view._pages[0].items).toEqual([])
expect(@view._pages[0].loading).toEqual(true)
it "should make a database query for the correct item range", ->
@view.retrievePage(2)
expect(@queries.length).toBe(1)
expect(@queries[0]._range).toEqual({offset: @view._pageSize * 2, limit: @view._pageSize})
expect(@queries[0]._matchers).toEqual(@config.matchers)
it "should order results properly", ->
@view.retrievePage(2)
expect(@queries.length).toBe(1)
expect(@queries[0]._orders).toEqual(@config.orders)
describe "once the database request has completed", ->
beforeEach ->
@view.retrievePage(0)
@completeQuery = =>
@items = [new Thread(id: 'model-a'), new Thread(id: 'model-b'), new Thread(id: 'model-c')]
@queries[0].resolve(@items)
@queries = []
spyOn(@view, 'loaded').andCallFake -> true
spyOn(@view._emitter, 'emit')
it "should populate the page items and call trigger", ->
runs ->
@completeQuery()
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].items).toEqual(@items)
expect(@view._emitter.emit).toHaveBeenCalled()
it "should set loading to false for the page", ->
runs ->
expect(@view._pages[0].loading).toEqual(true)
@completeQuery()
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].loading).toEqual(false)
describe "if an item metadata provider is configured", ->
beforeEach ->
@view._metadataProvider = (ids) ->
results = {}
for id in ids
results[id] = "metadata-for-#{id}"
Promise.resolve(results)
it "should set .metadata of each item", ->
runs ->
@completeQuery()
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].items[0].metadata).toEqual('metadata-for-model-a')
expect(@view._pages[0].items[1].metadata).toEqual('metadata-for-model-b')
it "should cache the metadata on the page object", ->
runs ->
@completeQuery()
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].metadata).toEqual
'model-a': 'metadata-for-model-a'
'model-b': 'metadata-for-model-b'
'model-c': 'metadata-for-model-c'
it "should always wait for metadata promises to resolve", ->
@resolves = []
@view._metadataProvider = (ids) =>
new Promise (resolve, reject) =>
results = {}
for id in ids
results[id] = "metadata-for-#{id}"
@resolves.push -> resolve(results)
runs ->
@completeQuery()
expect(@view._pages[0].items).toEqual([])
expect(@view._pages[0].metadata).toEqual({})
expect(@view._emitter.emit).not.toHaveBeenCalled()
waitsFor ->
@resolves.length > 0
runs ->
for resolve,idx in @resolves
resolve()
waitsFor ->
@view._emitter.emit.callCount > 0
runs ->
expect(@view._pages[0].items[0].metadata).toEqual('metadata-for-model-a')
expect(@view._pages[0].items[1].metadata).toEqual('metadata-for-model-b')
expect(@view._emitter.emit).toHaveBeenCalled()