packages feed

mergeful-persistent-0.0.0.0: test/Data/Mergeful/Persistent/TwoClientsSpec.hs

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}

module Data.Mergeful.Persistent.TwoClientsSpec
  ( spec,
  )
where

import Control.Monad.Reader
import qualified Data.Map as M
import Data.Mergeful
import qualified Data.Set as S
import Database.Persist.Sql
import Test.Hspec
import Test.Hspec.QuickCheck
import Test.Validity
import TestUtils

{-# ANN module ("HLint: ignore Reduce duplication" :: String) #-}

spec :: Spec
spec = modifyMaxShrinks (const 0) $ twoClientsSpec $ do
  describe "sanity" $ do
    describe "setupClient & clientGetStore" $ do
      it "roundtrips" $ \te -> forAllValid $ \cstore -> runTest te $ do
        setupClient A cstore
        cstore' <- clientGetStore A
        liftIO $ cstore' `shouldBe` cstore
    describe "setupServer & serverGetStore" $ do
      it "roundtrips" $ \te -> forAllValid $ \sstore -> runTest te $ do
        setupServer sstore
        sstore' <- serverGetStore
        liftIO $ sstore' `shouldBe` sstore
  describe "mergeFromServerStrategy" $ do
    let strat = mergeFromServerStrategy
    mergeFunctionSpec strat
    noDivergenceSpec strat
    xdescribe "Does not hold" $ noDataLossSpec strat
  describe "mergeFromClientStrategy" $ do
    let strat = mergeFromClientStrategy
    mergeFunctionSpec strat
    noDataLossSpec strat
    xdescribe "Does not hold" $ noDivergenceSpec strat
  describe "mergeUsingCRDTStrategy" $ do
    let strat = mergeUsingCRDTStrategy max
    mergeFunctionSpec strat
    noDataLossSpec strat
    noDivergenceSpec strat

mergeFunctionSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
mergeFunctionSpec strat = do
  let mergeFunc = clientMergeSyncResponse strat
  describe "Multiple clients" $ do
    describe "Single item" $ do
      it "successfully syncs an addition accross to a second client" $ \te -> forAllValid $ \k -> forAllValid $ \i -> runTest te $ do
        -- Client A has one item
        setupClient A $ initialClientStore {clientStoreAddedItems = M.singleton k i}
        -- Client B is empty
        setupClient B initialClientStore
        -- The server is empty
        setupServer initialServerStore
        -- Client A makes sync request 1
        req1 <- clientMakeSyncRequest A
        -- The server processes sync request 1
        resp1 <- serverProcessSync req1
        sstore2 <- serverGetStore
        let addedItems = syncResponseClientAdded resp1
        case M.toList addedItems of
          [(k', ClientAddition uuid st)] -> do
            lift $ k' `shouldBe` k
            let time = initialServerTime
            lift $ st `shouldBe` time
            let items = M.singleton uuid (Timed i st)
            lift $ sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
            -- Client A merges the response
            mergeFunc A resp1
            cAstore2 <- clientGetStore A
            lift $ cAstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
            -- Client B makes sync request 2
            req2 <- clientMakeSyncRequest B
            -- The server processes sync request 2
            resp2 <- serverProcessSync req2
            sstore3 <- serverGetStore
            lift $ do
              resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
              sstore3 `shouldBe` sstore2
            -- Client B merges the response
            mergeFunc B resp2
            cBstore2 <- clientGetStore B
            lift $ cBstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
            -- Client A and Client B now have the same store
            lift $ cAstore2 `shouldBe` cBstore2
          _ ->
            lift $
              expectationFailure
                "Should have found exactly one added item."
      it "successfully syncs a modification accross to a second client" $ \te -> forAllValid $ \uuid -> forAllValid $ \i -> forAllValid $ \j -> forAllValid $ \time1 ->
        runTest te $ do
          -- Client A has a synced item.
          setupClient A $
            initialClientStore
              { clientStoreSyncedItems = M.singleton uuid (Timed i time1)
              }
          -- Client B had synced that same item, but has since modified it
          setupClient B $
            initialClientStore
              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed j time1)
              }
          -- The server is has the item that both clients had before
          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
          -- Client B makes sync request 1
          req1 <- clientMakeSyncRequest B
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          let time2 = incrementServerTime time1
          lift $ do
            resp1
              `shouldBe` emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2}
            sstore2
              `shouldBe` ServerStore {serverStoreItems = M.singleton uuid (Timed j time2)}
          -- Client B merges the response
          mergeFunc B resp1
          cBstore2 <- clientGetStore B
          lift $
            cBstore2
              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed j time2)}
          -- Client A makes sync request 2
          req2 <- clientMakeSyncRequest A
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2
              `shouldBe` emptySyncResponse
                { syncResponseServerChanged = M.singleton uuid (Timed j time2)
                }
            sstore3 `shouldBe` sstore2
          -- Client A merges the response
          mergeFunc A resp2
          cAstore2 <- clientGetStore A
          lift $
            cAstore2
              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed j time2)}
          -- Client A and Client B now have the same store
          lift $ cAstore2 `shouldBe` cBstore2
      it "succesfully syncs a deletion across to a second client" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i ->
        runTest te $ do
          setupClient A $
            initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed i time1)}
          -- Client A has a synced item.
          -- Client B had synced that same item, but has since deleted it.
          setupClient B $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
          -- The server still has the undeleted item
          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
          -- Client B makes sync request 1
          req1 <- clientMakeSyncRequest B
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          lift $ do
            resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = S.singleton uuid}
            sstore2 `shouldBe` initialServerStore
          -- Client B merges the response
          mergeFunc B resp1
          cBstore2 <- clientGetStore B
          lift $ cBstore2 `shouldBe` initialClientStore
          -- Client A makes sync request 2
          req2 <- clientMakeSyncRequest A
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = S.singleton uuid}
            sstore3 `shouldBe` sstore2
          -- Client A merges the response
          mergeFunc A resp2
          cAstore2 <- clientGetStore A
          lift $ cAstore2 `shouldBe` initialClientStore
          -- Client A and Client B now have the same store
          lift $ cAstore2 `shouldBe` cBstore2
      it "does not run into a conflict if two clients both try to sync a deletion" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i ->
        runTest te $ do
          setupClient A $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
          -- Both client a and client b delete an item.
          setupClient B $ initialClientStore {clientStoreDeletedItems = M.singleton uuid time1}
          -- The server still has the undeleted item
          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i time1)}
          -- Client A makes sync request 1
          req1 <- clientMakeSyncRequest A
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          lift $ do
            resp1
              `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
            sstore2 `shouldBe` (ServerStore {serverStoreItems = M.empty})
          -- Client A merges the response
          mergeFunc A resp1
          cAstore2 <- clientGetStore A
          lift $ cAstore2 `shouldBe` initialClientStore
          -- Client B makes sync request 2
          req2 <- clientMakeSyncRequest B
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2
              `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
            sstore3 `shouldBe` sstore2
          -- Client B merges the response
          mergeFunc B resp2
          cBstore2 <- clientGetStore B
          lift $ do
            cBstore2 `shouldBe` initialClientStore
            -- Client A and Client B now have the same store
            cAstore2 `shouldBe` cBstore2
    describe "Multiple items" $ do
      it "successfully syncs additions accross to a second client" $ \te -> forAllValid $ \is ->
        runTest te $ do
          setupClient A $ initialClientStore {clientStoreAddedItems = is}
          -- Client B is empty
          setupClient B initialClientStore
          -- The server is empty
          setupServer initialServerStore
          -- Client A makes sync request 1
          req1 <- clientMakeSyncRequest A
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          let (rest, items) = mergeAddedItems is (syncResponseClientAdded resp1)
          lift $ do
            rest `shouldBe` M.empty
            sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
          -- Client A merges the response
          mergeFunc A resp1
          cAstore2 <- clientGetStore A
          lift $ cAstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
          -- Client B makes sync request 2
          req2 <- clientMakeSyncRequest B
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
            sstore3 `shouldBe` sstore2
          -- Client B merges the response
          mergeFunc B resp2
          cBstore2 <- clientGetStore B
          lift $ cBstore2 `shouldBe` (initialClientStore {clientStoreSyncedItems = items})
          -- Client A and Client B now have the same store
          lift $ cAstore2 `shouldBe` cBstore2
      it "succesfully syncs deletions across to a second client" $ \te -> forAllValid $ \items -> forAllValid $ \time1 ->
        runTest te $ do
          let syncedItems = M.map (\i -> Timed i time1) items
              itemTimes = M.map (const time1) items
              itemIds = M.keysSet items
          setupClient A $ initialClientStore {clientStoreSyncedItems = syncedItems}
          -- Client A has synced items
          -- Client B had synced the same items, but has since deleted them.
          setupClient B $ initialClientStore {clientStoreDeletedItems = itemTimes}
          -- The server still has the undeleted item
          setupServer $ ServerStore {serverStoreItems = syncedItems}
          -- Client B makes sync request 1
          req1 <- clientMakeSyncRequest B
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          lift $ do
            resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = itemIds}
            sstore2 `shouldBe` initialServerStore
          -- Client B merges the response
          mergeFunc B resp1
          cBstore2 <- clientGetStore B
          lift $ cBstore2 `shouldBe` initialClientStore
          -- Client A makes sync request 2
          req2 <- clientMakeSyncRequest A
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = itemIds}
            sstore3 `shouldBe` sstore2
          -- Client A merges the response
          mergeFunc A resp2
          cAstore2 <- clientGetStore A
          lift $ cAstore2 `shouldBe` initialClientStore
          -- Client A and Client B now have the same store
          lift $ cAstore2 `shouldBe` cBstore2
      it "does not run into a conflict if two clients both try to sync a deletion" $ \te -> forAllValid $ \items -> forAllValid $ \time1 ->
        runTest te $ do
          setupClient A $
            initialClientStore {clientStoreDeletedItems = M.map (const time1) items}
          -- Both client a and client b delete their items.
          setupClient B $
            initialClientStore {clientStoreDeletedItems = M.map (const time1) items}
          -- The server still has the undeleted items
          setupServer $ ServerStore {serverStoreItems = M.map (\i -> Timed i time1) items}
          -- Client A makes sync request 1
          req1 <- clientMakeSyncRequest A
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          lift $ do
            resp1 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = M.keysSet items})
            sstore2 `shouldBe` (ServerStore {serverStoreItems = M.empty}) -- TODO will probably need some sort of tombstoning.
                  -- Client A merges the response
          mergeFunc A resp1
          cAstore2 <- clientGetStore A
          lift $ cAstore2 `shouldBe` initialClientStore
          -- Client B makes sync request 2
          req2 <- clientMakeSyncRequest B
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          lift $ do
            resp2 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = M.keysSet items})
            sstore3 `shouldBe` sstore2
          -- Client B merges the response
          mergeFunc B resp2
          cBstore2 <- clientGetStore B
          lift $ do
            cBstore2 `shouldBe` initialClientStore
            -- Client A and Client B now have the same store
            cAstore2 `shouldBe` cBstore2

noDataLossSpec ::
  ItemMergeStrategy Thing ->
  SpecWith TestEnv
noDataLossSpec strat = do
  let mergeFunc = clientMergeSyncResponse strat
  it "does not lose data after a conflict occurs" $ \te -> forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \i1 -> forAllValid $ \i2 -> forAllValid $ \i3 ->
    runTest te $ do
      setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed i1 time1)}
      -- The server has an item
      -- The first client has synced it, and modified it.
      setupClient A $
        initialClientStore
          { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i2 time1)
          }
      -- The second client has synced it too, and modified it too.
      setupClient B $
        initialClientStore
          { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i3 time1)
          }
      -- Client A makes sync request 1
      req1 <- clientMakeSyncRequest A
      -- The server processes sync request 1
      resp1 <- serverProcessSync req1
      sstore2 <- serverGetStore
      let time2 = incrementServerTime time1
      -- The server updates the item accordingly
      lift $ do
        resp1
          `shouldBe` (emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2})
        sstore2
          `shouldBe` (ServerStore {serverStoreItems = M.singleton uuid (Timed i2 time2)})
      -- Client A merges the response
      mergeFunc A resp1
      cAstore2 <- clientGetStore A
      lift $
        cAstore2
          `shouldBe` (initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed i2 time2)})
      -- Client B makes sync request 2
      req2 <- clientMakeSyncRequest B
      -- The server processes sync request 2
      resp2 <- serverProcessSync req2
      sstore3 <- serverGetStore
      -- The server reports a conflict and does not change its store
      lift $ do
        resp2
          `shouldBe` (emptySyncResponse {syncResponseConflicts = M.singleton uuid (Timed i2 time2)})
        sstore3 `shouldBe` sstore2
      -- Client B merges the response
      clientMergeSyncResponse mergeFromClientStrategy B resp2
      cBstore2 <- clientGetStore B
      -- Client does not update, but keeps its conflict
      -- Client A and Client B now *do not* have the same store
      lift $
        cBstore2
          `shouldBe` ( initialClientStore
                         { clientStoreSyncedButChangedItems = M.singleton uuid (Timed i3 time1)
                         }
                     )

noDivergenceSpec :: ItemMergeStrategy Thing -> SpecWith TestEnv
noDivergenceSpec strat = do
  let mergeFunc = clientMergeSyncResponse strat
  it "does not diverge after a conflict occurs" $ \te ->
    forAllValid $ \uuid -> forAllValid $ \time1 -> forAllValid $ \iS -> forAllValid $ \iA ->
      forAllValid $ \iB ->
        runTest te $ do
          setupServer $ ServerStore {serverStoreItems = M.singleton uuid (Timed iS time1)}
          -- The server has an item
          -- The first client has synced it, and modified it.
          setupClient A $
            initialClientStore
              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed iA time1)
              }
          -- The second client has synced it too, and modified it too.
          setupClient B $
            initialClientStore
              { clientStoreSyncedButChangedItems = M.singleton uuid (Timed iB time1)
              }
          -- Client A makes sync request 1
          req1 <- clientMakeSyncRequest A
          -- The server processes sync request 1
          resp1 <- serverProcessSync req1
          sstore2 <- serverGetStore
          let time2 = incrementServerTime time1
          -- The server updates the item accordingly
          lift $ do
            resp1
              `shouldBe` (emptySyncResponse {syncResponseClientChanged = M.singleton uuid time2})
            sstore2
              `shouldBe` (ServerStore {serverStoreItems = M.singleton uuid (Timed iA time2)})
          -- Client A merges the response
          mergeFunc A resp1
          cAstore2 <- clientGetStore A
          -- Client A has the item from the server because there was no conflict.
          lift $
            cAstore2
              `shouldBe` initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed iA time2)}
          -- Client B makes sync request 2
          req2 <- clientMakeSyncRequest B
          -- The server processes sync request 2
          resp2 <- serverProcessSync req2
          sstore3 <- serverGetStore
          -- The server reports a conflict and does not change its store
          lift $ do
            resp2
              `shouldBe` (emptySyncResponse {syncResponseConflicts = M.singleton uuid (Timed iA time2)})
            sstore3 `shouldBe` sstore2
          -- Client B merges the response
          mergeFunc B resp2
          cBstore2 <- clientGetStore B
          lift $ do
            let expected = case itemMergeStrategyMergeChangeConflict strat iB iA of
                  KeepLocal -> initialClientStore {clientStoreSyncedButChangedItems = M.singleton uuid (Timed iB time1)}
                  TakeRemote -> initialClientStore {clientStoreSyncedItems = M.singleton uuid (Timed iA time2)}
                  Merged im -> initialClientStore {clientStoreSyncedButChangedItems = M.singleton uuid (Timed im time2)}
            cBstore2
              `shouldBe` expected
          -- In case of a previous merge, the synced item will still be changed, so we need to sync again with B and then with A
          req3 <- clientMakeSyncRequest B
          resp3 <- serverProcessSync req3
          mergeFunc B resp3
          cBstore3 <- clientGetStore B
          req4 <- clientMakeSyncRequest A
          resp4 <- serverProcessSync req4
          mergeFunc A resp4
          cAstore3 <- clientGetStore A
          lift $
            cBstore3 `shouldBe` cAstore3

type T a = ReaderT TestEnv IO a

runTest :: TestEnv -> T a -> IO a
runTest = flip runReaderT

runClientDB :: Client -> SqlPersistT IO a -> T a
runClientDB num func = do
  pool <- asks $ case num of
    A -> testEnvClient1Pool
    B -> testEnvClient2Pool
  liftIO $ runSqlPool func pool

runServerDB :: SqlPersistT IO a -> T a
runServerDB func = do
  pool <- asks testEnvServerPool
  liftIO $ runSqlPool func pool

type CS = ClientStore ClientThingId ServerThingId Thing

type SReq = SyncRequest ClientThingId ServerThingId Thing

type SS = ServerStore ServerThingId Thing

type SResp = SyncResponse ClientThingId ServerThingId Thing

setupClient :: Client -> CS -> T ()
setupClient client = runClientDB client . setupClientThingQuery

setupServer :: SS -> T ()
setupServer = runServerDB . setupServerThingQuery

clientGetStore :: Client -> T CS
clientGetStore client = runClientDB client clientGetStoreThingQuery

clientMakeSyncRequest :: Client -> T SReq
clientMakeSyncRequest client = runClientDB client clientMakeSyncRequestThingQuery

serverGetStore :: T SS
serverGetStore = runServerDB serverGetStoreThingQuery

serverProcessSync :: SReq -> T SResp
serverProcessSync = runServerDB . serverProcessSyncThingQuery

clientMergeSyncResponse :: ItemMergeStrategy Thing -> Client -> SResp -> T ()
clientMergeSyncResponse strat client = runClientDB client . clientMergeSyncResponseThingQuery strat

data Client = A | B
  deriving (Show, Eq)

data TestEnv
  = TestEnv
      { testEnvServerPool :: ConnectionPool,
        testEnvClient1Pool :: ConnectionPool,
        testEnvClient2Pool :: ConnectionPool
      }

twoClientsSpec :: SpecWith TestEnv -> Spec
twoClientsSpec = around withTestEnv

withTestEnv :: (TestEnv -> IO a) -> IO a
withTestEnv func =
  withServerPool $ \serverPool ->
    withClientPool $ \client1Pool ->
      withClientPool $ \client2Pool -> do
        let tenv =
              TestEnv
                { testEnvServerPool = serverPool,
                  testEnvClient1Pool = client1Pool,
                  testEnvClient2Pool = client2Pool
                }
        liftIO $ func tenv