src/model/__tests__/ModelCollectionProperty.spec.js

Summary

Maintainability
D
2 days
Test Coverage
import fixtures from '../../__fixtures__/fixtures'
import Api from '../../api/Api'
import Model from '../Model'
import ModelDefinition from '../ModelDefinition'
import ModelCollectionProperty from '../ModelCollectionProperty'

describe('ModelCollectionProperty', () => {
    // let ModelCollectionProperty;
    let mockParentModel
    let mockModelDefinition
    let mcp
    let testModels = []

    beforeEach(() => {
        mockParentModel = {
            id: 'parentModelId',
            plural: 'notArealModel',
            href: 'my.dhis/instance',
            modelDefinition: {
                apiEndpoint: 'http://my.base.url/api/parentModelEndpoint',
            },
        }
        mockModelDefinition = ModelDefinition.createFromSchema(
            fixtures.get('/api/schemas/dataElement')
        )

        mcp = ModelCollectionProperty.create(
            mockParentModel,
            mockModelDefinition,
            'dataElementGroups',
            []
        )

        testModels.push(
            mockModelDefinition.create({ id: 'dataEleme01', name: 'Test' })
        )
        testModels.push(mockModelDefinition.create({ id: 'dataEleme02' }))
        testModels.push(mockModelDefinition.create({ id: 'dataEleme03' }))
    })

    afterEach(() => {
        testModels = []
    })

    it('Should be an object', () => {
        expect(ModelCollectionProperty).toBeInstanceOf(Object)
    })

    it('Should not be callable as a function', () => {
        expect(() => ModelCollectionProperty()).toThrowError()
    })

    describe('create()', () => {
        it('Supplies the default API implementation', () => {
            expect(mcp.api).toEqual(Api.getApi())
        })

        it('Sets the dirty flag to false', () => {
            expect(mcp.dirty).toBe(false)
        })

        it('Creates empty Sets for added and removed elements', () => {
            expect(mcp.added).toBeInstanceOf(Set)
            expect(mcp.removed).toBeInstanceOf(Set)
            expect(mcp.added.size).toBe(0)
            expect(mcp.removed.size).toBe(0)
        })

        it('Sets the correct parentModel', () => {
            expect(mcp.parentModel).toEqual(mockParentModel)
        })
    })

    describe('add()', () => {
        it('Registers added elements', () => {
            testModels.forEach((model) => mcp.add(model))
            expect(mcp.added.size).toBe(testModels.length)
        })

        it('Only registers each added element once', () => {
            testModels.forEach((model) => mcp.add(model))
            testModels.forEach((model) => mcp.add(model))
            expect(mcp.added.size).toBe(testModels.length)
        })

        it('Updates the dirty flag', () => {
            expect(mcp.dirty).toBe(false)
            mcp.add(testModels[0])
            expect(mcp.dirty).toBe(true)
        })

        it('Sets the dirty flag to false when an element is added and then removed', () => {
            expect(mcp.dirty).toBe(false)
            mcp.add(testModels[0])
            expect(mcp.dirty).toBe(true)
            mcp.remove(testModels[0])
            expect(mcp.dirty).toBe(false)
        })
    })

    describe('remove()', () => {
        beforeEach(() => {
            // Create a new ModelCollectionProperty with existing values
            mcp = ModelCollectionProperty.create(
                mockParentModel,
                mockModelDefinition,
                'dataElementGroups',
                testModels
            )
        })

        it('Registers removed elements', () => {
            expect(mcp.removed.size).toBe(0)
            mcp.remove(testModels[0])
            expect(mcp.removed.size).toBe(1)
            mcp.remove(testModels[1])
            expect(mcp.removed.size).toBe(2)
            mcp.remove(testModels[2])
            expect(mcp.removed.size).toBe(3)
        })

        it('Only registers each removed element once', () => {
            expect(mcp.removed.size).toBe(0)
            mcp.remove(testModels[0])
            expect(mcp.removed.size).toBe(1)
            mcp.remove(testModels[0])
            expect(mcp.removed.size).toBe(1)
        })

        it('Updates the dirty flag', () => {
            expect(mcp.dirty).toBe(false)
            mcp.remove(testModels[0])
            expect(mcp.dirty).toBe(true)
        })

        it('Sets the dirty flag to false when an element is removed and re-added', () => {
            expect(mcp.dirty).toBe(false)
            mcp.remove(testModels[0])
            expect(mcp.dirty).toBe(true)
            mcp.add(testModels[0])
            expect(mcp.dirty).toBe(false)
        })
    })

    describe('updateDirty()', () => {
        it('Updates the dirty flag correctly', () => {
            expect(mcp.updateDirty()).toBe(false)
            mcp.added.add({ id: 'not a real model' })
            expect(mcp.updateDirty()).toBe(true)
        })

        it('Returns the updated value of the dirty flag', () => {
            mcp.added.add({ id: 'not a real model' })
            expect(mcp.updateDirty()).toBe(mcp.dirty)
        })
    })

    describe('resetDirtyState()', () => {
        it('Clears all added and removed elements', () => {
            mcp.added.add(testModels[0])
            mcp.removed.add({ id: 'bah ' })
            expect(mcp.added.size).toBe(1)
            expect(mcp.removed.size).toBe(1)

            mcp.resetDirtyState()
            expect(mcp.added.size).toBe(0)
            expect(mcp.removed.size).toBe(0)
        })

        it('Sets the dirty flag to false', () => {
            expect(mcp.dirty).toBe(false)
            mcp.add(testModels[0])
            mcp.removed.add({ id: 'bah ' })
            expect(mcp.updateDirty()).toBe(true)
            mcp.resetDirtyState()
            expect(mcp.dirty).toBe(false)
        })
    })

    describe('isDirty()', () => {
        it('Returns the current value of the dirty flag', () => {
            expect(mcp.isDirty()).toBe(mcp.dirty)
            mcp.add(testModels[0])
            expect(mcp.isDirty()).toBe(true)
            expect(mcp.isDirty()).toBe(mcp.dirty)
        })

        it('Does not update the dirty flag', () => {
            expect(mcp.isDirty()).toBe(false)
            mcp.added.add(testModels[0])
            expect(mcp.isDirty()).toBe(false)
        })

        it('Should be dirty=true if any model has been edited by default', () => {
            expect(mcp.isDirty()).toBe(false)
            mcp.add(testModels[0])
            expect(mcp.isDirty()).toBe(true)
            mcp.resetDirtyState()

            expect(mcp.isDirty()).toBe(false)

            testModels[0].name = 'Raccoon'
            expect(testModels[0].isDirty()).toBe(true)
            expect(mcp.isDirty()).toBe(true)
        })

        it('Should be dirty=false if includeValues=false', () => {
            expect(mcp.isDirty()).toBe(false)
            mcp.add(testModels[0])
            expect(mcp.isDirty()).toBe(true)
            mcp.resetDirtyState()

            expect(mcp.isDirty()).toBe(false)

            testModels[0].name = 'Raccoon'
            expect(testModels[0].isDirty()).toBe(true)
            expect(mcp.isDirty(false)).toBe(false)
        })
    })

    describe('save()', () => {
        const api = {
            get: jest.fn().mockReturnValue(Promise.resolve()),
            post: jest.fn().mockReturnValue(Promise.resolve()),
        }

        beforeEach(() => {
            mcp = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                'dataElementGroups',
                [testModels[0]],
                api
            )
        })

        afterEach(() => {
            api.get.mockClear()
            api.post.mockClear()
        })

        it('Does nothing when the collection not dirty', () => {
            expect.assertions(1)

            return mcp.save().then(() => {
                expect(api.post).toHaveBeenCalledTimes(0)
            })
        })

        it('Sends additions and removals in a single request', () => {
            mcp.remove(testModels[0])
            mcp.add(testModels[1])

            expect.assertions(2)

            return mcp.save().then(() => {
                expect(api.get).not.toHaveBeenCalled()
                expect(api.post).toHaveBeenCalledTimes(1)
            })
        })

        it('Sends an API requests with the correct additions and removals, using the correct URL', () => {
            mcp.remove(testModels[0])
            mcp.add(testModels[1])

            expect.assertions(3)

            return mcp.save().then(() => {
                expect(api.get).not.toHaveBeenCalled()
                expect(api.post).toHaveBeenCalledTimes(1)
                expect(api.post).toBeCalledWith(
                    'my.dhis/instance/dataElements',
                    {
                        additions: [{ id: 'dataEleme02' }],
                        deletions: [{ id: 'dataEleme01' }],
                    }
                )
            })
        })

        it('Resets the dirty flag', () => {
            expect.assertions(2)

            mcp.remove(testModels[0])
            mcp.add(testModels[1])
            expect(mcp.dirty).toBe(true)

            return mcp.save().then(() => {
                expect(mcp.dirty).toBe(false)
            })
        })

        it('Rejects the promise when the API fails', () => {
            expect.assertions(2)

            api.post.mockReturnValue(Promise.reject())
            mcp.add(testModels[1])
            expect(mcp.dirty).toBe(true)

            return mcp.save().catch(() => {
                // TODO: useless assertion
                expect(true).toBe(true)
            })
        })
    })

    describe('load()', () => {
        let loadedWithValues
        let loadedWithoutValues
        let unloadedWithValues
        let unloadedWithoutValues
        let excludedByFieldFilters

        const api = {
            get: jest.fn().mockReturnValue(
                Promise.resolve({
                    dataElementGroups: [
                        { id: 'groupNo0001' },
                        { id: 'groupNo0002' },
                        { id: 'groupNo0003' },
                    ],
                })
            ),
        }

        const mockMcpPropName = 'dataElementGroups'

        beforeEach(() => {
            loadedWithValues = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                mockMcpPropName,
                [
                    // Loaded, actual values
                    mockModelDefinition.create({ id: 'groupNo0001' }),
                    mockModelDefinition.create({ id: 'groupNo0002' }),
                    mockModelDefinition.create({ id: 'groupNo0003' }),
                ],
                api
            )

            // A ModelCollectionProperty that has been fully loaded, but contains no values
            loadedWithoutValues = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                mockMcpPropName,
                [], // Loaded, no values
                api
            )

            // A ModelCollectionProperty that has not yet been loaded, but contains values that can be lazy loaded
            unloadedWithValues = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                mockMcpPropName,
                true, // Not loaded, has values (meaning the field was loaded with the '::isNotEmpty' transformer)
                api
            )

            unloadedWithoutValues = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                mockMcpPropName,
                false, // Not loaded, no values
                api
            )

            excludedByFieldFilters = new ModelCollectionProperty(
                mockParentModel,
                mockModelDefinition,
                mockMcpPropName,
                undefined, // This field was not included in the API query
                api
            )
        })

        afterEach(() => {
            api.get.mockClear()
        })

        it('Sets `hasUnloadedData` correctly', () => {
            expect(loadedWithValues.hasUnloadedData).toBe(false)
            expect(loadedWithoutValues.hasUnloadedData).toBe(false)
            expect(unloadedWithValues.hasUnloadedData).toBe(true)
            expect(unloadedWithoutValues.hasUnloadedData).toBe(false)
            expect(excludedByFieldFilters.hasUnloadedData).toBe(true)
        })

        it('does not query the API when there are no unloaded values', () => {
            expect.assertions(1)

            return Promise.all([
                loadedWithValues.load(),
                loadedWithoutValues.load(),
                unloadedWithoutValues.load(),
            ]).then(() => {
                expect(api.get).not.toHaveBeenCalled()
            })
        })

        it('performs the correct API call for lazy loading', () => {
            expect.assertions(1)

            return unloadedWithValues.load().then(() => {
                expect(api.get).toHaveBeenCalledWith(
                    [
                        mockParentModel.modelDefinition.apiEndpoint,
                        mockParentModel.id,
                    ].join('/'),
                    {
                        fields: 'dataElementGroups[:all]',
                        paging: false,
                    }
                )
            })
        })

        it('correctly merges request parameters when lazy loading', () => {
            expect.assertions(1)

            return unloadedWithValues
                .load({ paging: false, fields: 'id,displayName' })
                .then(() => {
                    expect(api.get).toHaveBeenCalledWith(
                        [
                            mockParentModel.modelDefinition.apiEndpoint,
                            mockParentModel.id,
                        ].join('/'),
                        {
                            fields: 'dataElementGroups[id,displayName]',
                            paging: false,
                        }
                    )
                })
        })

        it('updates hasUnloadedData when data has been lazy loaded', () => {
            expect.assertions(2)

            expect(unloadedWithValues.hasUnloadedData).toBe(true)

            return unloadedWithValues.load().then(() => {
                expect(unloadedWithValues.hasUnloadedData).toBe(false)
            })
        })

        it('creates models for lazy loaded objects', () => {
            expect.assertions(4)

            return unloadedWithValues.load().then(() => {
                expect(unloadedWithValues.valuesContainerMap.size).toBe(3)
                unloadedWithValues
                    .toArray()
                    .forEach((value) => expect(value).toBeInstanceOf(Model))
            })
        })

        it('supports lazy loading collection fields that were not included in the original API query', () => {
            expect.assertions(3)

            expect(excludedByFieldFilters.hasUnloadedData).toBe(true)

            return excludedByFieldFilters.load().then(() => {
                expect(api.get).toHaveBeenCalled()
                expect(excludedByFieldFilters.hasUnloadedData).toBe(false)
            })
        })
    })
})