/**
 * Copyright (c), FinancialForce.com, inc
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification, 
 *   are permitted provided that the following conditions are met:
 *
 * - Redistributions of source code must retain the above copyright notice, 
 *      this list of conditions and the following disclaimer.
 * - Redistributions in binary form must reproduce the above copyright notice, 
 *      this list of conditions and the following disclaimer in the documentation 
 *      and/or other materials provided with the distribution.
 * - Neither the name of the FinancialForce.com, inc nor the names of its contributors 
 *      may be used to endorse or promote products derived from this software without 
 *      specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 
 *  ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 
 *  OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL 
 *  THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
 *  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 *  OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 *  OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 *  ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
**/

@IsTest
private with sharing class fflib_SObjectUnitOfWorkTest
{
    // SObjects (in order of dependency) used by UnitOfWork in tests bellow
    private static List<Schema.SObjectType> MY_SOBJECTS =
        new Schema.SObjectType[] {
            Product2.SObjectType,
            PricebookEntry.SObjectType,
            Opportunity.SObjectType,
            OpportunityLineItem.SObjectType };

    @isTest
    private static void testUnitOfWorkEmail()
    {
        string testRecordName = 'UoW Test Name 1';

        Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
        email.setToAddresses(new list<String>{ 'foobar@test.com' });
        email.setPlainTextBody('See Spot run.');

        fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);

        uow.m_emailWork = new Mock_SendEmailWork();

        Opportunity opp = new Opportunity();
        opp.Name = testRecordName;
        opp.StageName = 'Open';
        opp.CloseDate = System.today();
        uow.registerNew( opp );

        uow.registerEmail( email );

        uow.registerRelationship( email, opp );

        uow.commitWork();

        List<Opportunity> opps = [select Id, Name, (Select Id from OpportunityLineItems) from Opportunity where Name = :testRecordName order by Name];

        // assert that mock email functionality was called
        system.assert(((Mock_SendEmailWork) uow.m_emailWork).doWorkWasCalled);

        System.assertEquals(1, opps.size());
    }

    @isTest
    private static void testUnitOfWorkNewDirtyDelete()
    {
        // Insert Opportunities with UnitOfWork
        {
            fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
            for(Integer o=0; o<10; o++)
            {
                Opportunity opp = new Opportunity();
                opp.Name = 'UoW Test Name ' + o;
                opp.StageName = 'Open';
                opp.CloseDate = System.today();
                uow.registerNew(new List<SObject>{opp});
                for(Integer i=0; i<o+1; i++)
                {
                    Product2 product = new Product2();
                    product.Name = opp.Name + ' : Product : ' + i;
                    uow.registerNew(new List<SObject>{product});
                    PricebookEntry pbe = new PricebookEntry();
                    pbe.UnitPrice = 10;
                    pbe.IsActive = true;
                    pbe.UseStandardPrice = false;
                    pbe.Pricebook2Id = Test.getStandardPricebookId();
                    uow.registerNew(pbe, PricebookEntry.Product2Id, product);
                    OpportunityLineItem oppLineItem = new OpportunityLineItem();
                    oppLineItem.Quantity = 1;
                    oppLineItem.TotalPrice = 10;
                    uow.registerRelationship(oppLineItem, OpportunityLineItem.PricebookEntryId, pbe);
                    uow.registerNew(oppLineItem, OpportunityLineItem.OpportunityId, opp);
                }
            }

            uow.commitWork();
        }

        // Assert Results
        assertResults('UoW');
        // TODO: Need to re-instate this check with a better approach, as it is not possible when
        //       product triggers contribute to DML (e.g. in sample app Opportunity trigger)
        // System.assertEquals(5 /* Oddly a setSavePoint consumes a DML */, Limits.getDmlStatements());

        // Records to update
        List<Opportunity> opps = [select Id, Name, (Select Id from OpportunityLineItems) from Opportunity where Name like 'UoW Test Name %' order by Name];

        // Update some records with UnitOfWork
        {
            fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
            Opportunity opp = opps[0];
            opp.Name = opp.Name + ' Changed';
            uow.registerDirty(new List<SObject>{opp});
            Product2 product = new Product2();
            product.Name = opp.Name + ' : New Product';
            uow.registerNew(new List<SObject>{product});
            PricebookEntry pbe = new PricebookEntry();
            pbe.UnitPrice = 10;
            pbe.IsActive = true;
            pbe.UseStandardPrice = false;
            pbe.Pricebook2Id = Test.getStandardPricebookId();
            uow.registerNew(pbe, PricebookEntry.Product2Id, product);
            OpportunityLineItem newOppLineItem = new OpportunityLineItem();
            newOppLineItem.Quantity = 1;
            newOppLineItem.TotalPrice = 10;
            uow.registerRelationship(newOppLineItem, OpportunityLineItem.PricebookEntryId, pbe);
            uow.registerNew(newOppLineItem, OpportunityLineItem.OpportunityId, opp);
            OpportunityLineItem existingOppLine = opp.OpportunityLineItems[0];
            // Test that operations on the same object can be daisy chained, and the same object registered as dirty more than once
            // This verifies that using a Map to back the dirty records collection prevents duplicate registration.
            existingOppLine.Quantity = 2;
            uow.registerDirty(new List<SObject>{existingOppLine});
            existingOppLine.TotalPrice = 20;
            uow.registerDirty(new List<SObject>{existingOppLine});
            uow.commitWork();
        }

        // Assert Results
        // TODO: Need to re-instate this check with a better approach, as it is not possible when
        //       product triggers contribute to DML (e.g. in sample app Opportunity trigger)
        // System.assertEquals(11, Limits.getDmlStatements());
        opps = [select Id, Name, (Select Id, PricebookEntry.Product2.Name, Quantity, TotalPrice from OpportunityLineItems Order By PricebookEntry.Product2.Name) from Opportunity where Name like 'UoW Test Name %' order by Name];
        System.assertEquals(10, opps.size());
        System.assertEquals('UoW Test Name 0 Changed', opps[0].Name);
        System.assertEquals(2, opps[0].OpportunityLineItems.size());
        // Verify that both fields were updated properly
        System.assertEquals(2, opps[0].OpportunityLineItems[0].Quantity);
        System.assertEquals(20, opps[0].OpportunityLineItems[0].TotalPrice);
        System.assertEquals('UoW Test Name 0 Changed : New Product', opps[0].OpportunityLineItems[1].PricebookEntry.Product2.Name);

        // Delete some records with the UnitOfWork
        {
            fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1].PricebookEntry}); // Delete PricebookEntry
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1]}); // Delete OpportunityLine Item
            // Register the same deletions more than once.
            // This verifies that using a Map to back the deleted records collection prevents duplicate registration.
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1].PricebookEntry.Product2}); // Delete PricebookEntry Product
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1].PricebookEntry}); // Delete PricebookEntry
            uow.registerDeleted(new List<SObject>{opps[0].OpportunityLineItems[1]}); // Delete OpportunityLine Item
            uow.commitWork();
        }

        // Assert Results
        // TODO: Need to re-instate this check with a better approach, as it is not possible when
        //       product triggers contribute to DML (e.g. in sample app Opportunity trigger)
        // System.assertEquals(15, Limits.getDmlStatements());
        opps = [select Id, Name, (Select Id, PricebookEntry.Product2.Name, Quantity from OpportunityLineItems Order By PricebookEntry.Product2.Name) from Opportunity where Name like 'UoW Test Name %' order by Name];
        List<Product2> prods = [Select Id from Product2 where Name = 'UoW Test Name 0 Changed : New Product'];
        System.assertEquals(10, opps.size());
        System.assertEquals('UoW Test Name 0 Changed', opps[0].Name);
        System.assertEquals(1, opps[0].OpportunityLineItems.size()); // Should have deleted OpportunityLineItem added above
        System.assertEquals(0, prods.size()); // Should have deleted Product added above
    }

    private static void assertResults(String prefix)
    {
        // Standard Assertions on tests data inserted by tests
        String filter = prefix + ' Test Name %';
        List<Opportunity> opps = [select Id, Name, (Select Id from OpportunityLineItems) from Opportunity where Name like :filter order by Name];
        System.assertEquals(10, opps.size());
        System.assertEquals(1, opps[0].OpportunityLineItems.size());
        System.assertEquals(2, opps[1].OpportunityLineItems.size());
        System.assertEquals(3, opps[2].OpportunityLineItems.size());
        System.assertEquals(4, opps[3].OpportunityLineItems.size());
        System.assertEquals(5, opps[4].OpportunityLineItems.size());
        System.assertEquals(6, opps[5].OpportunityLineItems.size());
        System.assertEquals(7, opps[6].OpportunityLineItems.size());
        System.assertEquals(8, opps[7].OpportunityLineItems.size());
        System.assertEquals(9, opps[8].OpportunityLineItems.size());
        System.assertEquals(10, opps[9].OpportunityLineItems.size());
    }

    /**
     * Create uow with new records and commit
     *
     *	Testing:
     *
     *		- Correct events are fired when commitWork completes successfully
     *
     */
    @isTest
    private static void testDerivedUnitOfWork_CommitSuccess()
    {
        // Insert Opporunities with UnitOfWork
        DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS);
        for(Integer o=0; o<10; o++)
        {
            Opportunity opp = new Opportunity();
            opp.Name = 'UoW Test Name ' + o;
            opp.StageName = 'Open';
            opp.CloseDate = System.today();
            uow.registerNew(new List<SObject>{opp});
            for(Integer i=0; i<o+1; i++)
            {
                Product2 product = new Product2();
                product.Name = opp.Name + ' : Product : ' + i;
                uow.registerNew(new List<SObject>{product});
                PricebookEntry pbe = new PricebookEntry();
                pbe.UnitPrice = 10;
                pbe.IsActive = true;
                pbe.UseStandardPrice = false;
                pbe.Pricebook2Id = Test.getStandardPricebookId();
                uow.registerNew(pbe, PricebookEntry.Product2Id, product);
                OpportunityLineItem oppLineItem = new OpportunityLineItem();
                oppLineItem.Quantity = 1;
                oppLineItem.TotalPrice = 10;
                uow.registerRelationship(oppLineItem, OpportunityLineItem.PricebookEntryId, pbe);
                uow.registerNew(oppLineItem, OpportunityLineItem.OpportunityId, opp);
            }
        }
        uow.commitWork();

        // Assert Results
        assertResults('UoW');

        assertEvents(new List<String> {
                'onCommitWorkStarting'
                , 'onPublishBeforeEventsStarting'
                , 'onPublishBeforeEventsFinished'
                , 'onDMLStarting'
                , 'onDMLFinished'
                , 'onDoWorkStarting'
                , 'onDoWorkFinished'
                , 'onCommitWorkFinishing'
                , 'onPublishAfterSuccessEventsStarting'
                , 'onPublishAfterSuccessEventsFinished'
                , 'onCommitWorkFinished - true'
            }
            , uow.getCommitWorkEventsFired(), new Set<Schema.SObjectType>(MY_SOBJECTS), uow.getRegisteredTypes());
    }

    /**
     * Create uow with data that results in DML Exception
     *
     *	Testing:
     *
     *		- Correct events are fired when commitWork fails during DML processing
     *
     */
    @isTest
    private static void testDerivedUnitOfWork_CommitDMLFail()
    {
        // Insert Opporunities with UnitOfWork forcing a failure on DML by not setting 'Name' field
        DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS);
        Opportunity opp = new Opportunity();
        uow.registerNew(new List<SObject>{opp});
        Boolean didFail = false;
        System.DmlException caughtEx = null;

        try {
            uow.commitWork();
        }
        catch (System.DmlException dmlex) {
            didFail = true;
            caughtEx = dmlex;
        }

        // Assert Results
        System.assertEquals(didFail, true, 'didFail');
        System.assert(caughtEx.getMessage().contains('REQUIRED_FIELD_MISSING'), String.format('Exception message was ', new List<String> { caughtEx.getMessage() }));

        assertEvents(new List<String> {
                'onCommitWorkStarting'
                , 'onPublishBeforeEventsStarting'
                , 'onPublishBeforeEventsFinished'
                , 'onDMLStarting'
                , 'onPublishAfterFailureEventsStarting'
                , 'onPublishAfterFailureEventsFinished'
                , 'onCommitWorkFinished - false'
            }
            , uow.getCommitWorkEventsFired(), new Set<Schema.SObjectType>(MY_SOBJECTS), uow.getRegisteredTypes());
    }

    /**
     * Create uow with work that fails
     *
     *	Testing:
     *
     *		- Correct events are fired when commitWork fails during DoWork processing
     *
     */
    @isTest
    private static void testDerivedUnitOfWork_CommitDoWorkFail()
    {
        // Insert Opporunities with UnitOfWork
        DerivedUnitOfWork uow = new DerivedUnitOfWork(MY_SOBJECTS);
        Opportunity opp = new Opportunity();
        opp.Name = 'UoW Test Name 1';
        opp.StageName = 'Open';
        opp.CloseDate = System.today();
        uow.registerNew(new List<SObject>{opp});

        // register work that will fail during processing
        FailDoingWork fdw = new FailDoingWork();
        uow.registerWork(fdw);

        Boolean didFail = false;
        FailDoingWorkException caughtEx = null;

        try {
            uow.commitWork();
        }
        catch (FailDoingWorkException fdwe) {
            didFail = true;
            caughtEx = fdwe;
        }

        // Assert Results
        System.assertEquals(didFail, true, 'didFail');
        System.assert(caughtEx.getMessage().contains('Work failed.'), String.format('Exception message was ', new List<String> { caughtEx.getMessage() }));

        assertEvents(new List<String> {
                'onCommitWorkStarting'
                , 'onPublishBeforeEventsStarting'
                , 'onPublishBeforeEventsFinished'
                , 'onDMLStarting'
                , 'onDMLFinished'
                , 'onDoWorkStarting'
                , 'onPublishAfterFailureEventsStarting'
                , 'onPublishAfterFailureEventsFinished'
                , 'onCommitWorkFinished - false'
            }
            , uow.getCommitWorkEventsFired(), new Set<Schema.SObjectType>(MY_SOBJECTS), uow.getRegisteredTypes());
    }

    /**
     * Try registering two instances of the same record as dirty. Second register should overwrite first.
     *
     *  Testing:
     *
     *      - Exception is thrown stopping second registration
     */
    @isTest
    private static void testRegisterDirty_ExpectReplacement() {
        final Opportunity insertedOpp = new Opportunity(Name = 'Original', StageName = 'Open', CloseDate = System.today());
        insert insertedOpp;

        Opportunity opp = new Opportunity(Id = insertedOpp.Id, Name = 'Never');
        Opportunity opp2 = new Opportunity(Id = insertedOpp.Id, Name = 'Expected');

        fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
        uow.registerDirty(opp);
        uow.registerDirty(opp2);
        uow.commitWork();

        Opportunity updated = [select Id, Name from Opportunity where Id = :insertedOpp.Id];
        System.assertEquals('Expected', updated.Name);
    }

    /**
     * Try registering a single field as dirty.
     *
     *  Testing:
     *
     *      - field is updated
     */
    @isTest
    private static void testRegisterDirty_field() {
        Opportunity opp = new Opportunity(Name = 'test name', StageName = 'Open', CloseDate = System.today());
        insert opp;

        Opportunity nameUpdate = new Opportunity(Id = opp.Id, Name = 'UpdateName');
        Opportunity amountUpdate = new Opportunity(Id = opp.Id, Amount = 250);
        fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
        uow.registerDirty(nameUpdate);
        uow.registerDirty(amountUpdate, new List<SObjectField> { Opportunity.Amount } );
        uow.commitWork();

        opp = [select Name, Amount from Opportunity where Id = :opp.Id];
        System.assertEquals(opp.Name, nameUpdate.Name);
        System.assertEquals(opp.Amount, amountUpdate.Amount);
    }

    /**
     * Try registering a single field as dirty on multiple records.
     *
     */
    @isTest
    private static void testRegisterDirtyRecordsWithDirtyFields()
    {
        // GIVEN a list of existing records
        Opportunity opportunityA = new Opportunity(
                Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
                Name = 'test name A',
                StageName = 'Open',
                CloseDate = System.today());
        Opportunity opportunityB = new Opportunity(
                Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
                Name = 'test name B',
                StageName = 'Open',
                CloseDate = System.today());

        MockDML mockDML = new MockDML();
        fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML);
        uow.registerDirty(new List<Opportunity>{ opportunityA, opportunityB });

        // WHEN we register the records again with different fields updated
        List<Opportunity> recordsWithStageUpdate = new List<Opportunity>
        {
                new Opportunity(Id = opportunityA.Id, StageName = 'Closed'),
                new Opportunity(Id = opportunityB.Id, StageName = 'Closed')
        };
        List<Opportunity> recordsWithAmountUpdate = new List<Opportunity>
        {
                new Opportunity(Id = opportunityA.Id, Amount = 250),
                new Opportunity(Id = opportunityB.Id, Amount = 250)
        };
        uow.registerDirty(recordsWithStageUpdate, new List<SObjectField> { Opportunity.StageName });
        uow.registerDirty(recordsWithAmountUpdate, new List<SObjectField> { Opportunity.Amount });
        uow.commitWork();

        // THEN the records should be registered with both changed values for Amount and StageName
        System.assert(
                new fflib_MatcherDefinitions.SObjectsWith(
                        new List<Map<SObjectField, Object>>{
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityA.Id,
                                     Opportunity.Amount => 250,
                                     Opportunity.StageName => 'Closed'
                                },
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityB.Id,
                                     Opportunity.Amount => 250,
                                     Opportunity.StageName => 'Closed'
                                }
                        }
                )
                        .matches(mockDML.recordsForUpdate),
                'Records not registered with the correct values'
        );
    }    /**
     * Try registering a single field as dirty on multiple records.
     *
     */
    @isTest
    private static void testRegisterDirtyRecordsWithDirtyFields_failing()
    {
        // GIVEN a list of existing records
        Opportunity opportunityA = new Opportunity(
                Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
                Name = 'test name A',
                StageName = 'Open',
                CloseDate = System.today());
        Opportunity opportunityB = new Opportunity(
                Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
                Name = 'test name B',
                StageName = 'Open',
                CloseDate = System.today());

        MockDML mockDML = new MockDML();
        fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML);
        uow.registerDirty(new List<Opportunity>{ opportunityA, opportunityB });

        // WHEN we register the records again with different fields updated
        List<Opportunity> recordsWithStageUpdate = new List<Opportunity>
        {
                new Opportunity(Id = opportunityA.Id, StageName = 'Closed'),
                new Opportunity(Id = opportunityB.Id, StageName = 'Closed')
        };
        List<Opportunity> recordsWithAmountUpdate = new List<Opportunity>
        {
                new Opportunity(Id = opportunityA.Id, Amount = 250),
                new Opportunity(Id = opportunityB.Id, Amount = 250)
        };
        uow.registerDirty(recordsWithStageUpdate, new List<SObjectField> { Opportunity.StageName });
        uow.registerDirty(recordsWithAmountUpdate, new List<SObjectField> { Opportunity.Amount });
        uow.registerDirty( // Register again the original record, should overwrite the one with the dirty fields
                new Opportunity(
                Id = OpportunityB.Id,
                Name = 'test name B',
                StageName = 'Open',
                CloseDate = System.today())
        ); 
        uow.commitWork();

        // THEN only the first record should be registered with both changed values for Amount and StageName and the second should be the original
        System.assert(
                !new fflib_MatcherDefinitions.SObjectsWith(
                        new List<Map<SObjectField, Object>>{
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityA.Id,
                                     Opportunity.Amount => 250,
                                     Opportunity.StageName => 'Closed'
                                },
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityB.Id,
                                     Opportunity.Amount => 250,
                                     Opportunity.StageName => 'Closed'
                                }
                        }
                )
                        .matches(mockDML.recordsForUpdate),
                'Not all records should not be registered with the dirty values'
        );
        System.assert(
                new fflib_MatcherDefinitions.SObjectsWith(
                        new List<Map<SObjectField, Object>>{
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityA.Id,
                                     Opportunity.Amount => 250,
                                     Opportunity.StageName => 'Closed'
                                },
                                new Map<SObjectField, Object>
                                {
                                     Opportunity.Id => opportunityB.Id,
                                     Opportunity.StageName => 'Open'
                                }
                        }
                )
                        .matches(mockDML.recordsForUpdate),
                'The second record should be registered with the original values'
        );
    }

    @isTest
    private static void testRegisterUpsert() {
        Opportunity existingOpp = new Opportunity(Name = 'Existing Opportunity', StageName = 'Open', CloseDate = System.today());
        insert existingOpp;

        existingOpp.StageName = 'Closed';

        Opportunity newOpportunity = new Opportunity(Name = 'New Opportunity', StageName = 'Closed', CloseDate = System.today());

        System.assertEquals(1, [SELECT COUNT() FROM Opportunity]);
        System.assertEquals(0, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']);
        
        Test.startTest();
            fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS);
            uow.registerUpsert(new List<Opportunity>{existingOpp, newOpportunity});
            uow.commitWork();
        Test.stopTest();

        System.assertEquals(2, [SELECT COUNT() FROM Opportunity]);
        System.assertEquals(2, [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed']);
    }

    /**
     * Assert that actual events exactly match expected events (size, order and name)
     * and types match expected types
     */
    private static void assertEvents(List<String> expectedEvents, List<String> actualEvents, Set<Schema.SObjectType> expectedTypes, Set<Schema.SObjectType> actualTypes)
    {
        // assert that events match
        System.assertEquals(expectedEvents.size(), actualEvents.size(), 'events size');
        for (Integer i = 0; i < expectedEvents.size(); i++)
        {
            System.assertEquals(expectedEvents[i], actualEvents[i], String.format('Event {0} was not fired in order expected.', new List<String> { expectedEvents[i] }));
        }

        // assert that types match
        System.assertEquals(expectedTypes.size(), actualTypes.size(), 'types size');
        for (Schema.SObjectType sObjectType :expectedTypes)
        {
            System.assertEquals(true, actualTypes.contains(sObjectType), String.format('Type {0} was not registered.', new List<String> { sObjectType.getDescribe().getName() }));
        }
    }

    /**
     * DoWork implementation that throws exception during processing
     */
    private class FailDoingWork implements fflib_SObjectUnitOfWork.IDoWork
    {
        public void doWork()
        {
            throw new FailDoingWorkException('Work failed.');
        }
    }

    /**
     * Derived unit of work that tracks event notifications and handle registration of type
     */
    private class DerivedUnitOfWork extends fflib_SObjectUnitOfWork
    {
        private List<String> m_commitWorkEventsFired = new List<String>();
        private Set<Schema.SObjectType> m_registeredTypes = new Set<Schema.SObjectType>();

        public List<String> getCommitWorkEventsFired()
        {
            return m_commitWorkEventsFired.clone();
        }

        public Set<Schema.SObjectType> getRegisteredTypes()
        {
            return m_registeredTypes.clone();
        }

        public DerivedUnitOfWork(List<Schema.SObjectType> sObjectTypes)
        {
            super(sObjectTypes);
        }

        public DerivedUnitOfWork(List<Schema.SObjectType> sObjectTypes, IDML dml)
        {
            super(sObjectTypes, dml);
        }

        private void addEvent(String event)
        {
            // events should only be fired one time
            // ensure that this event has not been fired already
            for (String eventName :m_commitWorkEventsFired)
            {
                if (event == eventName)
                {
                    throw new DerivedUnitOfWorkException(String.format('Event {0} has already been fired.', new List<String> { event }));
                }
            }
            m_commitWorkEventsFired.add(event);
        }

        public override void onRegisterType(Schema.SObjectType sObjectType)
        {
            if (m_registeredTypes.contains(sObjectType))
            {
                throw new DerivedUnitOfWorkException(String.format('Type {0} has already been registered.', new List<String> { sObjectType.getDescribe().getName() }));
            }
            m_registeredTypes.add(sObjectType);
        }

        public override void onCommitWorkStarting()
        {
            addEvent('onCommitWorkStarting');
        }

        public override void onPublishBeforeEventsStarting()
        {
            addEvent('onPublishBeforeEventsStarting');
        }

        public override void onPublishBeforeEventsFinished()
        {
            addEvent('onPublishBeforeEventsFinished');
        }

        public override void onDMLStarting()
        {
            addEvent('onDMLStarting');
        }

        public override void onDMLFinished()
        {
            addEvent('onDMLFinished');
        }

        public override void onDoWorkStarting()
        {
            addEvent('onDoWorkStarting');
        }

        public override void onDoWorkFinished()
        {
            addEvent('onDoWorkFinished');
        }

        public override void onCommitWorkFinishing()
        {
            addEvent('onCommitWorkFinishing');
        }

        public override void onPublishAfterSuccessEventsStarting()
        {
            addEvent('onPublishAfterSuccessEventsStarting');
        }

        public override void onPublishAfterSuccessEventsFinished()
        {
            addEvent('onPublishAfterSuccessEventsFinished');
        }

        public override void onPublishAfterFailureEventsStarting()
        {
            addEvent('onPublishAfterFailureEventsStarting');
        }

        public override void onPublishAfterFailureEventsFinished()
        {
            addEvent('onPublishAfterFailureEventsFinished');
        }

        public override void onCommitWorkFinished(Boolean wasSuccessful)
        {
            addEvent('onCommitWorkFinished - ' + wasSuccessful);
        }
    }

    /**
     * Mock implementation of fflib_SObjectUnitOfWork.SendEmailWork
     **/
    private class Mock_SendEmailWork implements fflib_SObjectUnitOfWork.IEmailWork
    {
        public Mock_SendEmailWork()
        {
        }

        public void registerEmail(Messaging.Email email)
        {
        }

        public void doWork()
        {
            doWorkWasCalled = true;
            // The code in the fflib_SObjectUnitOfWork class
            // causes unit test failures in Orgs that do not
            // have email enabled.
        }

        private boolean doWorkWasCalled = false;
    }

    private class MockDML implements fflib_SObjectUnitOfWork.IDML
    {
        public List<SObject> recordsForInsert = new List<SObject>();
        public List<SObject> recordsForUpdate = new List<SObject>();
        public List<SObject> recordsForDelete = new List<SObject>();
        public List<SObject> recordsForRecycleBin = new List<SObject>();
        public List<SObject> recordsForEventPublish = new List<SObject>();

        public void dmlInsert(List<SObject> objList)
        {
            this.recordsForInsert.addAll(objList);
        }

        public void dmlUpdate(List<SObject> objList)
        {
            this.recordsForUpdate.addAll(objList);
        }

        public void dmlDelete(List<SObject> objList)
        {
            this.recordsForDelete.addAll(objList);
        }

        public void eventPublish(List<SObject> objList)
        {
            this.recordsForEventPublish.addAll(objList);
        }

        public void emptyRecycleBin(List<SObject> objList)
        {
            this.recordsForRecycleBin.addAll(objList);
        }
    }

    public class DerivedUnitOfWorkException extends Exception {}
    public class FailDoingWorkException extends Exception {}
}