packages feed

genvalidity-mergeless-0.2.0.0: test/Data/Mergeless/CollectionSpec.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Data.Mergeless.CollectionSpec
  ( spec,
  )
where

import Control.Monad.State
import Data.GenValidity.Mergeless.Collection ()
import Data.GenValidity.UUID ()
import Data.Int (Int)
import Data.List
import qualified Data.Map.Strict as M
import Data.Mergeless.Collection
import Data.Ord
import qualified Data.Set as S
import Data.UUID
import GHC.Generics (Generic)
import System.Random
import Test.Hspec
import Test.QuickCheck
import Test.Validity
import Test.Validity.Aeson

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

spec :: Spec
spec = do
  genValidSpec @(ClientStore ClientId Int Int)
  jsonSpecOnValid @(ClientStore ClientId Int Int)
  genValidSpec @(SyncRequest ClientId Int Int)
  jsonSpecOnValid @(SyncRequest ClientId Int Int)
  genValidSpec @(SyncResponse ClientId Int Int)
  jsonSpecOnValid @(SyncResponse ClientId Int Int)
  genValidSpec @(ServerStore Int Int)
  jsonSpecOnValid @(ServerStore Int Int)
  describe "emptyStore" $ it "is valid" $ shouldBeValid (emptyClientStore @Int @Int @Int)
  describe "storeSize" $ do
    it "does not crash" $ producesValidsOnValids (storeSize @Int @Int @Int)
    specify "adding an item makes the store bigger"
      $ forAllValid
      $ \store ->
        forAllValid $ \added -> do
          let size1 = storeSize (store :: ClientStore Int Int Int)
          let store' = addItemToClientStore added store
          let size2 = storeSize store'
          size2 `shouldBe` (size1 + 1)
    specify "deleting an unsynced item after adding it leaves the store with the original size"
      $ forAllValid
      $ \store ->
        forAllValid $ \added ->
          let size1 = storeSize (store :: ClientStore Int Int Int)
              store' = addItemToClientStore added store
           in case sortOn (Down . fst) $ M.toList (clientStoreAdded store') of
                [] -> expectationFailure "Expected a nonempty list"
                (i, _) : _ ->
                  let store'' = deleteUnsyncedFromClientStore i store'
                      size2 = storeSize store''
                   in size2 `shouldBe` size1
    specify "deleting a synced item after adding it leaves the store with the original size"
      $ forAllValid
      $ \store ->
        forAll (genValid `suchThat` (\uuid -> not $ M.member uuid $ clientStoreSynced store)) $ \uuid ->
          forAllValid $ \synced ->
            let size1 = storeSize (store :: ClientStore Int Int Int)
                store' = store {clientStoreSynced = M.insert uuid synced $ clientStoreSynced store}
                store'' = deleteSyncedFromClientStore uuid store'
                size2 = storeSize store''
             in size2 `shouldBe` size1
  describe "addItemToClientStore"
    $ it "produces valid stores"
    $ producesValidsOnValids2 (addItemToClientStore @Int @Int @Int)
  describe "deleteUnsyncedFromClientStore"
    $ it "produces valid stores"
    $ producesValidsOnValids2 (deleteUnsyncedFromClientStore @Int @Int @Int)
  describe "deleteSyncedFromClientStore"
    $ it "produces valid stores"
    $ producesValidsOnValids2 (deleteSyncedFromClientStore @Int @Int @Int)
  describe "emptySyncRequest"
    $ it "is valid"
    $ shouldBeValid (emptySyncRequest @Int @Int @Int)
  describe "makeSyncRequest"
    $ it "produces valid sync requests"
    $ producesValidsOnValids (makeSyncRequest @Int @Int @Int)
  describe "mergeSyncResponse" $ do
    it "produces valid sync stores" $ producesValidsOnValids2 (mergeSyncResponse @Int @Int @Int)
    it "adds the single item that the server tells it to add to an empty client store"
      $ forAllValid
      $ \cid ->
        forAllValid $ \a ->
          forAllValid $ \u -> do
            let cstore1 = emptyClientStore {clientStoreAdded = M.singleton (cid :: ClientId) (a :: Int)}
                resp = emptySyncResponse {syncResponseClientAdded = M.singleton cid (u :: Int)}
                cstore2 = mergeSyncResponse cstore1 resp
            clientStoreSynced cstore2 `shouldBe` M.singleton u a
    it "deletes items that the server instructed to be deleted"
      $ forAllValid
      $ \cs ->
        forAllValid $ \sr -> do
          let cs' = mergeSyncResponse @ClientId @Int @Int cs sr
          clientStoreDeleted cs'
            `shouldBe` (clientStoreDeleted cs `S.difference` syncResponseClientDeleted sr)
  describe "processServerSync"
    $ describe "deterministic UUIDs"
    $ serverSyncSpec @Int evalDM
    $ processServerSync genD

serverSyncSpec ::
  forall a si m.
  (Show si, Ord si, GenValid si, Show a, Ord a, GenValid a, MonadIO m) =>
  (forall r. m r -> IO r) ->
  (ServerStore si a -> SyncRequest ClientId si a -> m (SyncResponse ClientId si a, ServerStore si a)) ->
  Spec
serverSyncSpec eval func = do
  describe "Single client" $ do
    describe "Single-item" $ do
      it "succesfully downloads a single item from the server for an empty client"
        $ forAllValid
        $ \u ->
          forAllValid $ \i ->
            eval $ do
              let sstore1 = emptyServerStore {serverStoreItems = M.singleton u i}
              let cstore1 = emptyClientStore
              let req = makeSyncRequest cstore1
              (resp, sstore2) <- func sstore1 req
              let cstore2 = mergeSyncResponse cstore1 resp
              liftIO $ do
                sstore2 `shouldBe` sstore1
                clientStoreSynced cstore2 `shouldBe` serverStoreItems sstore2
      it "succesfully uploads a single item to the server for an empty server"
        $ forAllValid
        $ \c ->
          forAllValid $ \i ->
            eval $ do
              let cstore1 = emptyClientStore {clientStoreAdded = M.singleton c i}
              let sstore1 = emptyServerStore
              let req = makeSyncRequest cstore1
              (resp, sstore2) <- func sstore1 req
              let cstore2 = mergeSyncResponse cstore1 resp
              liftIO $ do
                clientStoreSynced cstore2 `shouldBe` serverStoreItems sstore2
                sort (M.elems (clientStoreSynced cstore2))
                  `shouldBe` sort (M.elems $ M.singleton c i)
    describe "Multi-item" $ do
      it "succesfully downloads everything from the server for an empty client"
        $ forAllValid
        $ \sstore1 ->
          eval $ do
            let cstore1 = emptyClientStore
            let req = makeSyncRequest cstore1
            (resp, sstore2) <- func sstore1 req
            let cstore2 = mergeSyncResponse cstore1 resp
            liftIO $ do
              sstore2 `shouldBe` sstore1
              clientStoreSynced cstore2 `shouldBe` serverStoreItems sstore2
      it "succesfully uploads everything to the server for an empty server"
        $ forAllValid
        $ \items ->
          eval $ do
            let cstore1 = emptyClientStore {clientStoreAdded = items}
            let sstore1 = emptyServerStore
            let req = makeSyncRequest cstore1
            (resp, sstore2) <- func sstore1 req
            let cstore2 = mergeSyncResponse cstore1 resp
            liftIO $ do
              clientStoreSynced cstore2 `shouldBe` serverStoreItems sstore2
              sort (M.elems (clientStoreSynced cstore2)) `shouldBe` sort (M.elems items)
      it "is idempotent with one client"
        $ forAllValid
        $ \cstore1 ->
          forAllValid $ \sstore1 ->
            eval $ do
              let req1 = makeSyncRequest cstore1
              (resp1, sstore2) <- func sstore1 req1
              let cstore2 = mergeSyncResponse cstore1 resp1
                  req2 = makeSyncRequest cstore2
              (resp2, sstore3) <- func sstore2 req2
              let cstore3 = mergeSyncResponse cstore2 resp2
              liftIO $ do
                cstore2 `shouldBe` cstore3
                sstore2 `shouldBe` sstore3
  describe "Multiple clients" $ do
    describe "Single-item" $ do
      it "successfully syncs an addition accross to a second client"
        $ forAllValid
        $ \i ->
          eval $ do
            let cAstore1 = emptyClientStore {clientStoreAdded = M.singleton (ClientId 0) i}
            -- Client B is empty
            let cBstore1 = emptyClientStore
            -- The server is empty
            let sstore1 = emptyServerStore
            -- Client A makes sync request 1
            let req1 = makeSyncRequest cAstore1
            -- The server processes sync request 1
            (resp1, sstore2) <- func sstore1 req1
            let addedItems = syncResponseClientAdded resp1
            case M.toList addedItems of
              [(ClientId 0, clientAdditionId)] -> do
                let items = M.singleton clientAdditionId i
                liftIO $ sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
                -- Client A merges the response
                let cAstore2 = mergeSyncResponse cAstore1 resp1
                liftIO $ cAstore2 `shouldBe` (emptyClientStore {clientStoreSynced = items})
                -- Client B makes sync request 2
                let req2 = makeSyncRequest cBstore1
                -- The server processes sync request 2
                (resp2, sstore3) <- func sstore2 req2
                liftIO $ do
                  resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
                  sstore3 `shouldBe` sstore2
                --  pPrint cBstore2
                -- Client B merges the response
                let cBstore2 = mergeSyncResponse cBstore1 resp2
                liftIO $ cBstore2 `shouldBe` (emptyClientStore {clientStoreSynced = items})
                -- Client A and Client B now have the same store
                liftIO $ cAstore2 `shouldBe` cBstore2
              _ -> liftIO $ expectationFailure "Should have found exactly one added item."
      it "succesfully syncs a deletion across to a second client"
        $ forAllValid
        $ \uuid ->
          forAllValid $ \i ->
            eval $ do
              let cAstore1 = emptyClientStore {clientStoreSynced = M.singleton uuid i}
              -- Client A has a synced item.
              -- Client B had synced that same item, but has since deleted it.
              let cBstore1 = emptyClientStore {clientStoreDeleted = S.singleton uuid}
              -- The server still has the undeleted item
              let sstore1 = ServerStore {serverStoreItems = M.singleton uuid i}
              -- Client B makes sync request 1
              let req1 = makeSyncRequest cBstore1
              -- The server processes sync request 1
              (resp1, sstore2) <- func sstore1 req1
              liftIO $ do
                resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = S.singleton uuid}
                sstore2 `shouldBe` emptyServerStore
              -- Client B merges the response
              let cBstore2 = mergeSyncResponse cBstore1 resp1
              liftIO $ cBstore2 `shouldBe` emptyClientStore
              -- Client A makes sync request 2
              let req2 = makeSyncRequest cAstore1
              -- The server processes sync request 2
              (resp2, sstore3) <- func sstore2 req2
              liftIO $ do
                resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = S.singleton uuid}
                sstore3 `shouldBe` sstore2
              -- Client A merges the response
              let cAstore2 = mergeSyncResponse cAstore1 resp2
              liftIO $ cAstore2 `shouldBe` emptyClientStore
              -- Client A and Client B now have the same store
              liftIO $ cAstore2 `shouldBe` cBstore2
      it "does not run into a conflict if two clients both try to sync a deletion"
        $ forAllValid
        $ \uuid ->
          forAllValid $ \i ->
            eval $ do
              let cAstore1 = emptyClientStore {clientStoreDeleted = S.singleton uuid}
              -- Both client a and client b delete an item.
              let cBstore1 = emptyClientStore {clientStoreDeleted = S.singleton uuid}
              -- The server still has the undeleted item
              let sstore1 = ServerStore {serverStoreItems = M.singleton uuid i}
              -- Client A makes sync request 1
              let req1 = makeSyncRequest cAstore1
              -- The server processes sync request 1
              (resp1, sstore2) <- func sstore1 req1
              liftIO $ do
                resp1 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
                sstore2 `shouldBe` (ServerStore {serverStoreItems = M.empty})
              -- Client A merges the response
              let cAstore2 = mergeSyncResponse cAstore1 resp1
              liftIO $ cAstore2 `shouldBe` emptyClientStore
              -- Client B makes sync request 2
              let req2 = makeSyncRequest cBstore1
              -- The server processes sync request 2
              (resp2, sstore3) <- func sstore2 req2
              liftIO $ do
                resp2 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = S.singleton uuid})
                sstore3 `shouldBe` sstore2
              -- Client B merges the response
              let cBstore2 = mergeSyncResponse cBstore1 resp2
              liftIO $ do
                cBstore2 `shouldBe` emptyClientStore
                -- Client A and Client B now have the same store
                cAstore2 `shouldBe` cBstore2
    describe "Multiple items" $ do
      it
        "makes no change if the sync request reflects the same local state with an empty sync response"
        $ forAllValid
        $ \sis -> do
          let cs = ServerStore sis
          (sr, cs') <-
            eval
              $ func cs
              $ SyncRequest
                { syncRequestAdded = M.empty,
                  syncRequestSynced = M.keysSet sis,
                  syncRequestDeleted = S.empty
                }
          cs' `shouldBe` cs
          sr
            `shouldBe` SyncResponse
              { syncResponseClientAdded = M.empty,
                syncResponseClientDeleted = S.empty,
                syncResponseServerAdded = M.empty,
                syncResponseServerDeleted = S.empty
              }
      it "successfully syncs additions accross to a second client"
        $ forAllValid
        $ \is ->
          eval $ do
            let cAstore1 = emptyClientStore {clientStoreAdded = is}
            -- Client B is empty
            let cBstore1 = emptyClientStore
            -- The server is empty
            let sstore1 = emptyServerStore
            -- Client A makes sync request 1
            let req1 = makeSyncRequest cAstore1
            -- The server processes sync request 1
            (resp1, sstore2) <- func sstore1 req1
            -- Client A merges the response
            let cAstore2 = mergeSyncResponse cAstore1 resp1
            let items = clientStoreSynced cAstore2
            liftIO $ do
              clientStoreAdded cAstore2 `shouldBe` M.empty
              sstore2 `shouldBe` (ServerStore {serverStoreItems = items})
            liftIO $ cAstore2 `shouldBe` (emptyClientStore {clientStoreSynced = items})
            -- Client B makes sync request 2
            let req2 = makeSyncRequest cBstore1
            -- The server processes sync request 2
            (resp2, sstore3) <- func sstore2 req2
            liftIO $ do
              resp2 `shouldBe` (emptySyncResponse {syncResponseServerAdded = items})
              sstore3 `shouldBe` sstore2
            -- Client B merges the response
            let cBstore2 = mergeSyncResponse cBstore1 resp2
            liftIO $ cBstore2 `shouldBe` (emptyClientStore {clientStoreSynced = items})
            -- Client A and Client B now have the same store
            liftIO $ cAstore2 `shouldBe` cBstore2
      it "succesfully syncs deletions across to a second client"
        $ forAllValid
        $ \syncedItems ->
          eval $ do
            let itemIds = M.keysSet syncedItems
            let cAstore1 = emptyClientStore {clientStoreSynced = syncedItems}
            -- Client A has synced items
            -- Client B had synced the same items, but has since deleted them.
            let cBstore1 = emptyClientStore {clientStoreDeleted = itemIds}
            -- The server still has the undeleted item
            let sstore1 = ServerStore {serverStoreItems = syncedItems}
            -- Client B makes sync request 1
            let req1 = makeSyncRequest cBstore1
            -- The server processes sync request 1
            (resp1, sstore2) <- func sstore1 req1
            liftIO $ do
              resp1 `shouldBe` emptySyncResponse {syncResponseClientDeleted = itemIds}
              sstore2 `shouldBe` emptyServerStore
            -- Client B merges the response
            let cBstore2 = mergeSyncResponse cBstore1 resp1
            liftIO $ cBstore2 `shouldBe` emptyClientStore
            -- Client A makes sync request 2
            let req2 = makeSyncRequest cAstore1
            -- The server processes sync request 2
            (resp2, sstore3) <- func sstore2 req2
            liftIO $ do
              resp2 `shouldBe` emptySyncResponse {syncResponseServerDeleted = itemIds}
              sstore3 `shouldBe` sstore2
            -- Client A merges the response
            let cAstore2 = mergeSyncResponse cAstore1 resp2
            liftIO $ cAstore2 `shouldBe` emptyClientStore
            -- Client A and Client B now have the same store
            liftIO $ cAstore2 `shouldBe` cBstore2
      it "does not run into a conflict if two clients both try to sync a deletion"
        $ forAllValid
        $ \items ->
          eval $ do
            let cAstore1 = emptyClientStore {clientStoreDeleted = M.keysSet items}
            -- Both client a and client b delete their items.
            let cBstore1 = emptyClientStore {clientStoreDeleted = M.keysSet items}
            -- The server still has the undeleted items
            let sstore1 = ServerStore {serverStoreItems = items}
            -- Client A makes sync request 1
            let req1 = makeSyncRequest cAstore1
            -- The server processes sync request 1
            (resp1, sstore2) <- func sstore1 req1
            liftIO $ 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
            let cAstore2 = mergeSyncResponse cAstore1 resp1
            liftIO $ cAstore2 `shouldBe` emptyClientStore
            -- Client B makes sync request 2
            let req2 = makeSyncRequest cBstore1
            -- The server processes sync request 2
            (resp2, sstore3) <- func sstore2 req2
            liftIO $ do
              resp2 `shouldBe` (emptySyncResponse {syncResponseClientDeleted = M.keysSet items})
              sstore3 `shouldBe` sstore2
            -- Client B merges the response
            let cBstore2 = mergeSyncResponse cBstore1 resp2
            liftIO $ do
              cBstore2 `shouldBe` emptyClientStore
              -- Client A and Client B now have the same store
              cAstore2 `shouldBe` cBstore2
  describe "General properties" $ do
    it "produces valid results"
      $ forAllValid
      $ \cs ->
        forAllValid $ \sr -> do
          res <- eval $ func cs sr
          shouldBeValid res
    it "successfully syncs two clients using a central store"
      $ forAllValid
      $ \store1 ->
        eval $ do
          let central = ServerStore M.empty
          let store2 = emptyClientStore
          let sreq1 = makeSyncRequest store1
          (sresp1, central') <- func central sreq1
          let store1' = mergeSyncResponse store1 sresp1
          let sreq2 = makeSyncRequest store2
          (sresp2, central'') <- func central' sreq2
          let store2' = mergeSyncResponse store2 sresp2
          let sreq3 = makeSyncRequest store1'
          (sresp3, _) <- func central'' sreq3
          let store1'' = mergeSyncResponse store1' sresp3
          liftIO $ store1'' `shouldBe` store2'
    it "ensures that syncing is idempotent"
      $ forAllValid
      $ \central1 ->
        forAllValid $ \local1 ->
          eval $ do
            let sreq1 = makeSyncRequest local1
            (sresp1, central2) <- func central1 sreq1
            let local2 = mergeSyncResponse local1 sresp1
            let sreq2 = makeSyncRequest local2
            (sresp2, central3) <- func central2 sreq2
            let local3 = mergeSyncResponse local2 sresp2
            liftIO $ do
              local2 `shouldBe` local3
              central2 `shouldBe` central3

newtype D m a
  = D
      { unD :: StateT StdGen m a
      }
  deriving (Generic, Functor, Applicative, Monad, MonadState StdGen, MonadTrans, MonadIO)

evalDM :: Functor m => D m a -> m a
evalDM d = fst <$> runDM d (mkStdGen 42)

runDM :: D m a -> StdGen -> m (a, StdGen)
runDM = runStateT . unD

genD :: Monad m => D m UUID
genD = do
  r <- get
  let (u, r') = random r
  put r'
  pure u