packages feed

genvalidity-mergeful-0.3.0.1: test/Data/Mergeful/ItemSpec.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Data.Mergeful.ItemSpec
  ( spec,
  )
where

import Autodocodec
import Autodocodec.Yaml
import Data.Data
import Data.GenValidity.Mergeful.Item ()
import Data.Mergeful.Item
import Data.Mergeful.Timed
import Data.Word
import Test.QuickCheck
import Test.Syd hiding (Timed (..))
import Test.Syd.Validity
import Test.Syd.Validity.Aeson
import Test.Syd.Validity.Utils
import Text.Colour

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

forAllSubsequent :: Testable prop => ((ServerTime, ServerTime) -> prop) -> Property
forAllSubsequent func =
  forAllValid $ \st ->
    forAllShrink (genValid `suchThat` (> st)) (filter (> st) . shrinkValid) $ \st' -> func (st, st')

spec :: Spec
spec = do
  let yamlSchemaSpec :: forall a. (Typeable a, GenValid a, HasCodec a) => FilePath -> Spec
      yamlSchemaSpec filePath = do
        it ("outputs the same schema as before for " <> nameOf @a) $
          pureGoldenTextFile
            ("test_resources/item/" <> filePath <> ".txt")
            (renderChunksText With24BitColours $ schemaChunksViaCodec @a)
  describe "ClientItem" $ do
    genValidSpec @(ClientItem Word8)
    jsonSpec @(ClientItem Word8)
    yamlSchemaSpec @(ClientItem Word8) "client"
  describe "ServerItem" $ do
    genValidSpec @(ServerItem Word8)
    jsonSpec @(ServerItem Word8)
    yamlSchemaSpec @(ServerItem Word8) "server"
  describe "ItemSyncRequest" $ do
    genValidSpec @(ItemSyncRequest Word8)
    jsonSpec @(ItemSyncRequest Word8)
    yamlSchemaSpec @(ItemSyncRequest Word8) "request"
  describe "ItemSyncResponse" $ do
    genValidSpec @(ItemSyncResponse Word8)
    jsonSpec @(ItemSyncResponse Word8)
    yamlSchemaSpec @(ItemSyncResponse Word8) "response"
  describe "makeItemSyncRequest" $
    it "produces valid requests" $
      producesValid (makeItemSyncRequest @Int)
  describe "mergeItemSyncResponseRaw" $
    it "produces valid client stores" $
      producesValid2 (mergeItemSyncResponseRaw @Int)
  describe "mergeItemSyncResponseFromServer" $
    it "produces valid client stores" $
      producesValid2 (mergeItemSyncResponseFromServer @Int)
  describe "mergeItemSyncResponseFromClient" $
    it "produces valid client stores" $
      producesValid2 (mergeItemSyncResponseFromClient @Int)
  describe "mergeItemSyncResponseUsingCRDT" $
    it "produces valid client stores" $
      producesValid2 (mergeItemSyncResponseUsingCRDT @Int max)
  describe "processServerItemSync" $ do
    it "produces valid responses and stores" $ producesValid2 (processServerItemSync @Int)
    it "makes no changes if the sync request reflects the state of the empty server" $ do
      let store1 = ServerEmpty
          req = ItemSyncRequestPoll
      let (resp, store2) = processServerItemSync @Int store1 req
      store2 `shouldBe` store1
      resp `shouldBe` ItemSyncResponseInSyncEmpty
    it "makes no changes if the sync request reflects the state of the full server" $
      forAllValid $
        \i ->
          forAllValid $ \st -> do
            let store1 = ServerFull $ Timed i st
                req = ItemSyncRequestKnown st
            let (resp, store2) = processServerItemSync @Int store1 req
            store2 `shouldBe` store1
            resp `shouldBe` ItemSyncResponseInSyncFull
    describe "Client changes" $ do
      it "adds the item that the client tells the server to add" $
        forAllValid $
          \i -> do
            let store1 = ServerEmpty
                req = ItemSyncRequestNew i
            let (resp, store2) = processServerItemSync @Int store1 req
            let time = initialServerTime
            store2 `shouldBe` ServerFull (Timed i time)
            resp `shouldBe` ItemSyncResponseClientAdded time
      it "changes the item that the client tells the server to change" $
        forAllValid $
          \i ->
            forAllValid $ \j ->
              forAllValid $ \st -> do
                let store1 = ServerFull (Timed i st)
                    req = ItemSyncRequestKnownButChanged (Timed j st)
                let (resp, store2) = processServerItemSync @Int store1 req
                let time = incrementServerTime st
                store2 `shouldBe` ServerFull (Timed j time)
                resp `shouldBe` ItemSyncResponseClientChanged time
      it "deletes the item that the client tells the server to delete" $
        forAllValid $
          \i ->
            forAllValid $ \st -> do
              let store1 = ServerFull (Timed i st)
                  req = ItemSyncRequestDeletedLocally st
              let (resp, store2) = processServerItemSync @Int store1 req
              store2 `shouldBe` ServerEmpty
              resp `shouldBe` ItemSyncResponseClientDeleted
    describe "Server changes" $ do
      it "tells the client that there is a new item at the server side" $
        forAllValid $
          \i ->
            forAllValid $ \st -> do
              let store1 = ServerFull (Timed i st)
                  req = ItemSyncRequestPoll
              let (resp, store2) = processServerItemSync @Int store1 req
              store2 `shouldBe` store1
              resp `shouldBe` ItemSyncResponseServerAdded (Timed i st)
      it "tells the client that there is a modified item at the server side" $
        forAllValid $
          \i ->
            forAllSubsequent $ \(st, st') -> do
              let store1 = ServerFull (Timed i st')
                  req = ItemSyncRequestKnown st
              let (resp, store2) = processServerItemSync @Int store1 req
              store2 `shouldBe` store1
              resp `shouldBe` ItemSyncResponseServerChanged (Timed i st')
      it "tells the client that there is a deleted item at the server side" $
        forAllValid $
          \st -> do
            let store1 = ServerEmpty
                req = ItemSyncRequestKnown st
            let (resp, store2) = processServerItemSync @Int store1 req
            store2 `shouldBe` store1
            resp `shouldBe` ItemSyncResponseServerDeleted
    describe "Conflicts" $ do
      it "notices a conflict if the client and server are trying to sync different items" $
        forAllValid $
          \i ->
            forAllValid $ \j ->
              forAllSubsequent $ \(st, st') -> do
                let store1 = ServerFull (Timed i st')
                    req = ItemSyncRequestKnownButChanged (Timed j st)
                let (resp, store2) = processServerItemSync @Int store1 req
                store2 `shouldBe` store1
                resp `shouldBe` ItemSyncResponseConflict (Timed i st')
      it
        "notices a server-deleted-conflict if the client has a deleted item and server has a modified item"
        $ forAllValid
        $ \i ->
          forAllSubsequent $ \(st, st') -> do
            let store1 = ServerFull (Timed i st')
                req = ItemSyncRequestDeletedLocally st
            let (resp, store2) = processServerItemSync @Int store1 req
            store2 `shouldBe` store1
            resp `shouldBe` ItemSyncResponseConflictClientDeleted (Timed i st')
      it
        "notices a server-deleted-conflict if the client has a modified item and server has no item"
        $ forAllValid
        $ \i ->
          forAllValid $ \st -> do
            let store1 = ServerEmpty
                req = ItemSyncRequestKnownButChanged (Timed i st)
            let (resp, store2) = processServerItemSync @Int store1 req
            store2 `shouldBe` store1
            resp `shouldBe` ItemSyncResponseConflictServerDeleted
  describe "syncing" $ do
    describe "fromServer" $ do
      syncingSpec @Int mergeFromServer
      emptyResponseSpec @Int mergeFromServer
    describe "fromClient" $ do
      syncingSpec @Int mergeFromClient
      xdescribe "does not hold" $ emptyResponseSpec @Int mergeFromClient
    describe "gadt" $ do
      let merge = mergeUsingCRDT @Int max
      syncingSpec merge
      emptyResponseSpec merge

syncingSpec ::
  forall a.
  (Show a, Eq a, GenValid a) =>
  (ClientItem a -> ItemMergeResult a -> ClientItem a) ->
  Spec
syncingSpec mergeStrategy = do
  let mergeSyncResponse ci = mergeStrategy ci . mergeItemSyncResponseRaw ci
  it "it always possible to add an item from scratch" $
    forAllValid $
      \i -> do
        let cstore1 = ClientAdded i
        let sstore1 = ServerEmpty
        let req1 = makeItemSyncRequest cstore1
            (resp1, sstore2) = processServerItemSync sstore1 req1
            cstore2 = mergeSyncResponse cstore1 resp1
        let time = initialServerTime
        resp1 `shouldBe` ItemSyncResponseClientAdded time
        sstore2 `shouldBe` ServerFull (Timed i time)
        cstore2 `shouldBe` ClientItemSynced (Timed i time)
  it "succesfully syncs an addition across to a second client" $
    forAllValid $
      \i -> do
        let cAstore1 = ClientAdded i
        -- Client B is empty
        let cBstore1 = ClientEmpty
        -- The server is empty
        let sstore1 = ServerEmpty
        -- Client A makes sync request 1
        let req1 = makeItemSyncRequest cAstore1
        -- The server processes sync request 1
        let (resp1, sstore2) = processServerItemSync sstore1 req1
        let time = initialServerTime
        resp1 `shouldBe` ItemSyncResponseClientAdded time
        sstore2 `shouldBe` ServerFull (Timed i time)
        -- Client A merges the response
        let cAstore2 = mergeSyncResponse cAstore1 resp1
        cAstore2 `shouldBe` ClientItemSynced (Timed i time)
        -- Client B makes sync request 2
        let req2 = makeItemSyncRequest cBstore1
        -- The server processes sync request 2
        let (resp2, sstore3) = processServerItemSync sstore2 req2
        resp2 `shouldBe` ItemSyncResponseServerAdded (Timed i time)
        sstore3 `shouldBe` ServerFull (Timed i time)
        -- Client B merges the response
        let cBstore2 = mergeSyncResponse cBstore1 resp2
        cBstore2 `shouldBe` ClientItemSynced (Timed i time)
        -- Client A and Client B now have the same store
        cAstore2 `shouldBe` cBstore2
  it "succesfully syncs a modification across to a second client" $
    forAllValid $
      \time1 ->
        forAllValid $ \i ->
          forAllValid $ \j -> do
            let cAstore1 = ClientItemSynced (Timed i time1)
            -- Client B had synced that same item, but has since modified it
            let cBstore1 = ClientItemSyncedButChanged (Timed j time1)
            -- The server is has the item that both clients had before
            let sstore1 = ServerFull (Timed i time1)
            -- Client B makes sync request 1
            let req1 = makeItemSyncRequest cBstore1
            -- The server processes sync request 1
            let (resp1, sstore2) = processServerItemSync sstore1 req1
            let time2 = incrementServerTime time1
            resp1 `shouldBe` ItemSyncResponseClientChanged time2
            sstore2 `shouldBe` ServerFull (Timed j time2)
            -- Client B merges the response
            let cBstore2 = mergeSyncResponse cBstore1 resp1
            cBstore2 `shouldBe` ClientItemSynced (Timed j time2)
            -- Client A makes sync request 2
            let req2 = makeItemSyncRequest cAstore1
            -- The server processes sync request 2
            let (resp2, sstore3) = processServerItemSync sstore2 req2
            resp2 `shouldBe` ItemSyncResponseServerChanged (Timed j time2)
            sstore3 `shouldBe` ServerFull (Timed j time2)
            -- Client A merges the response
            let cAstore2 = mergeSyncResponse cAstore1 resp2
            cAstore2 `shouldBe` ClientItemSynced (Timed j time2)
            -- Client A and Client B now have the same store
            cAstore2 `shouldBe` cBstore2
  it "succesfully syncs a deletion across to a second client" $
    forAllValid $
      \time1 ->
        forAllValid $ \i -> do
          let cAstore1 = ClientItemSynced (Timed i time1)
          -- Client B had synced that same item, but has since deleted it
          let cBstore1 = ClientDeleted time1
          -- The server still has the undeleted item
          let sstore1 = ServerFull (Timed i time1)
          -- Client B makes sync request 1
          let req1 = makeItemSyncRequest cBstore1
          -- The server processes sync request 1
          let (resp1, sstore2) = processServerItemSync sstore1 req1
          resp1 `shouldBe` ItemSyncResponseClientDeleted
          sstore2 `shouldBe` ServerEmpty
          -- Client B merges the response
          let cBstore2 = mergeSyncResponse cBstore1 resp1
          cBstore2 `shouldBe` ClientEmpty
          -- Client A makes sync request 2
          let req2 = makeItemSyncRequest cAstore1
          -- The server processes sync request 2
          let (resp2, sstore3) = processServerItemSync sstore2 req2
          resp2 `shouldBe` ItemSyncResponseServerDeleted
          sstore3 `shouldBe` ServerEmpty
          -- Client A merges the response
          let cAstore2 = mergeSyncResponse cAstore1 resp2
          cAstore2 `shouldBe` ClientEmpty
          -- Client A and Client B now have the same store
          cAstore2 `shouldBe` cBstore2
  it "does not run into a conflict if two clients both try to sync a deletion" $
    forAllValid $
      \time1 ->
        forAllValid $ \i -> do
          let cAstore1 = ClientDeleted time1
          -- Both client a and client b delete an item.
          let cBstore1 = ClientDeleted time1
          -- The server still has the undeleted item
          let sstore1 = ServerFull (Timed i time1)
          -- Client A makes sync request 1
          let req1 = makeItemSyncRequest cAstore1
          -- The server processes sync request 1
          let (resp1, sstore2) = processServerItemSync sstore1 req1
          resp1 `shouldBe` ItemSyncResponseClientDeleted
          sstore2 `shouldBe` ServerEmpty
          -- Client A merges the response
          let cAstore2 = mergeSyncResponse cAstore1 resp1
          cAstore2 `shouldBe` ClientEmpty
          -- Client B makes sync request 2
          let req2 = makeItemSyncRequest cBstore1
          -- The server processes sync request 2
          let (resp2, sstore3) = processServerItemSync sstore2 req2
          resp2 `shouldBe` ItemSyncResponseClientDeleted
          sstore3 `shouldBe` ServerEmpty
          -- Client B merges the response
          let cBstore2 = mergeSyncResponse cBstore1 resp2
          cBstore2 `shouldBe` ClientEmpty
          -- Client A and Client B now have the same store
          cAstore2 `shouldBe` cBstore2
  it "is idempotent with one client" $
    forAllValid $
      \cstore1 ->
        forAllValid $ \sstore1 -> do
          let req1 = makeItemSyncRequest cstore1
              (resp1, sstore2) = processServerItemSync sstore1 req1
              cstore2 = mergeSyncResponse cstore1 resp1
              req2 = makeItemSyncRequest cstore2
              (resp2, sstore3) = processServerItemSync sstore2 req2
              cstore3 = mergeSyncResponse cstore2 resp2
          cstore3 `shouldBe` cstore2
          sstore3 `shouldBe` sstore2

emptyResponseSpec ::
  forall a.
  (Show a, Eq a, GenValid a) =>
  (ClientItem a -> ItemMergeResult a -> ClientItem a) ->
  Spec
emptyResponseSpec mergeStrategy = do
  let mergeSyncResponse ci = mergeStrategy ci . mergeItemSyncResponseRaw ci
  it "is returns an empty response on the second sync with no modifications" $
    forAllValid $
      \cstore1 ->
        forAllValid $ \sstore1 -> do
          let req1 = makeItemSyncRequest cstore1
              (resp1, sstore2) = processServerItemSync sstore1 req1
              cstore2 = mergeSyncResponse cstore1 resp1
              req2 = makeItemSyncRequest cstore2
              (resp2, _) = processServerItemSync sstore2 req2
          case resp2 of
            ItemSyncResponseInSyncEmpty -> pure ()
            ItemSyncResponseInSyncFull -> pure ()
            _ -> expectationFailure $ "Sync response was not empty: " <> show resp2