packages feed

sofetch-0.1.0.0: src/Fetch/Mock.hs

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE KindSignatures #-}

module Fetch.Mock
  ( -- * Mock fetch
    MockFetch
  , runMockFetch
  , ResultMap
  , mockData
  , emptyMockData
    -- * Mock mutations
  , MockMutate
  , runMockMutate
  , MutationHandlers
  , mockMutation
  , emptyMutationHandlers
  , RecordedMutation(..)
  ) where

import Data.Kind (Type)

import Fetch.Class
import Fetch.Mutate (MonadMutate(..))
import Fetch.IVar (FetchError(..))

import Control.Exception (toException, try, evaluate, throw, throwIO)
import Control.Monad.Catch (MonadThrow(..), MonadCatch(..))
import Data.Dynamic
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HM
import Data.IORef
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Type.Reflection (SomeTypeRep(..), someTypeRep, typeOf)

-- | Pre-built result data for testing. Each entry wraps a
-- @HashMap k (Result k)@ as a Dynamic, keyed by source type.
newtype ResultMap = ResultMap (Map SomeTypeRep Dynamic)

instance Semigroup ResultMap where
  ResultMap a <> ResultMap b = ResultMap (Map.union a b)

instance Monoid ResultMap where
  mempty = ResultMap Map.empty

-- | Create mock data for a specific key type.
--
-- @mockData \@UserId [(UserId 1, testUser), (UserId 2, otherUser)]@
mockData :: forall k. (FetchKey k, Typeable (Result k))
         => [(k, Result k)] -> ResultMap
mockData pairs = ResultMap $
  Map.singleton
    (someTypeRep (Proxy @k))
    (toDyn (HM.fromList pairs :: HashMap k (Result k)))

-- | Empty mock data.
emptyMockData :: ResultMap
emptyMockData = mempty

-- | Look up a key in the mock data.
lookupMock :: forall k. (FetchKey k, Typeable (Result k))
           => ResultMap -> k -> Either SomeException (Result k)
lookupMock (ResultMap m) k =
  case Map.lookup (someTypeRep (Proxy @k)) m >>= fromDynamic of
    Just (hm :: HashMap k (Result k)) ->
      case HM.lookup k hm of
        Just v  -> Right v
        Nothing -> Left $ toException $ FetchError $
          "Mock data missing key: " <> show k
    Nothing -> Left $ toException $ FetchError $
      "No mock data for source: " <> show (someTypeRep (Proxy @k))

-- | A test-oriented MonadFetch that reads from a pre-built result map.
-- No IO, no batching, no caching. Just direct lookups.
--
-- The @m@ phantom type parameter means the same @MonadFetch m n@
-- constraints work in both production and test code. Users specify
-- @m@ at the call site (e.g. @runMockFetch \@AppM mocks action@).
-- | @m@ is a phantom type representing the source monad (for instance selection).
newtype MockFetch (m :: Type -> Type) n a = MockFetch { unMockFetch :: ResultMap -> n a }

instance Functor n => Functor (MockFetch m n) where
  fmap f (MockFetch g) = MockFetch (fmap f . g)

instance Applicative n => Applicative (MockFetch m n) where
  pure a = MockFetch $ \_ -> pure a
  MockFetch ff <*> MockFetch fx = MockFetch $ \rm ->
    ff rm <*> fx rm

instance Monad n => Monad (MockFetch m n) where
  MockFetch ma >>= f = MockFetch $ \rm -> do
    a <- ma rm
    unMockFetch (f a) rm

-- | Note: the 'DataSource m k' constraint is still required for
-- type inference, but the batchFetch is never used.
-- Only the ResultMap is consulted.
instance Monad n => MonadFetch m (MockFetch m n) where
  fetch k = MockFetch $ \rm ->
    case lookupMock rm k of
      Right v -> pure v
      Left ex -> throw ex

  tryFetch k = MockFetch $ \rm ->
    pure (lookupMock rm k)

  primeCache _ _ = pure ()

instance MonadFail n => MonadFail (MockFetch m n) where
  fail msg = MockFetch $ \_ -> fail msg

instance MonadThrow n => MonadThrow (MockFetch m n) where
  throwM e = MockFetch $ \_ -> throwM e

instance MonadCatch n => MonadCatch (MockFetch m n) where
  catch (MockFetch f) handler = MockFetch $ \rm ->
    catch (f rm) (\e -> unMockFetch (handler e) rm)

-- | Run a computation against mock data.
--
-- @
-- testGetUserFeed :: IO ()
-- testGetUserFeed = do
--   let mocks = mockData \@UserId [(UserId 1, testUser)]
--            <> mockData \@PostsByAuthor [(PostsByAuthor 1, [testPost])]
--   feed <- runMockFetch \@AppM mocks (getUserFeed (UserId 1))
--   assertEqual (feedUser feed) testUser
-- @
runMockFetch :: ResultMap -> MockFetch m n a -> n a
runMockFetch rm (MockFetch f) = f rm

-- ──────────────────────────────────────────────
-- Mock mutation support
-- ──────────────────────────────────────────────

-- | A map of mutation handlers, keyed by mutation key type.
-- Each handler is a @Dynamic@-wrapped @k -> MutationResult k@.
newtype MutationHandlers = MutationHandlers (Map SomeTypeRep Dynamic)

instance Semigroup MutationHandlers where
  MutationHandlers a <> MutationHandlers b = MutationHandlers (Map.union a b)

instance Monoid MutationHandlers where
  mempty = MutationHandlers Map.empty

-- | Empty mutation handlers.
emptyMutationHandlers :: MutationHandlers
emptyMutationHandlers = mempty

-- | Register a mock handler for a mutation key type.
--
-- @mockMutation \@UpdateUser (\\(UpdateUser uid name) -> User uid name)@
mockMutation :: forall k. (MutationKey k, Typeable (MutationResult k))
             => (k -> MutationResult k) -> MutationHandlers
mockMutation handler = MutationHandlers $
  Map.singleton
    (someTypeRep (Proxy @k))
    (toDyn handler)

-- | Look up a mutation handler.
lookupMutationHandler :: forall k. (MutationKey k, Typeable (MutationResult k))
                      => MutationHandlers -> k -> Either SomeException (MutationResult k)
lookupMutationHandler (MutationHandlers m) k =
  case Map.lookup (someTypeRep (Proxy @k)) m >>= fromDynamic of
    Just (handler :: k -> MutationResult k) -> Right (handler k)
    Nothing -> Left $ toException $ FetchError $
      "No mock mutation handler for: " <> show (someTypeRep (Proxy @k))

-- | A recorded mutation: captures the type and the existentially-wrapped
-- key for assertions. Pattern match on 'recordedMutationKey' when you
-- need to inspect the concrete key value.
data RecordedMutation = forall k. MutationKey k
  => RecordedMutation
       { recordedMutationType :: !SomeTypeRep
       , recordedMutationKey  :: !k
       }

instance Show RecordedMutation where
  show (RecordedMutation tr _) =
    "RecordedMutation " <> show tr

-- | A test-oriented monad that supports both fetches (from 'ResultMap')
-- and mutations (from 'MutationHandlers'), recording all mutations
-- for assertions.
-- | @m@ is a phantom type representing the source monad (for instance selection).
newtype MockMutate (m :: Type -> Type) n a = MockMutate
  { unMockMutate :: ResultMap -> MutationHandlers -> IORef [RecordedMutation] -> n a }

instance Functor n => Functor (MockMutate m n) where
  fmap f (MockMutate g) = MockMutate $ \rm mh ref -> fmap f (g rm mh ref)

instance Applicative n => Applicative (MockMutate m n) where
  pure a = MockMutate $ \_ _ _ -> pure a
  MockMutate ff <*> MockMutate fx = MockMutate $ \rm mh ref ->
    ff rm mh ref <*> fx rm mh ref

instance Monad n => Monad (MockMutate m n) where
  MockMutate ma >>= f = MockMutate $ \rm mh ref -> do
    a <- ma rm mh ref
    unMockMutate (f a) rm mh ref

instance (Monad n, n ~ IO) => MonadFetch m (MockMutate m n) where
  fetch k = MockMutate $ \rm _ _ ->
    case lookupMock rm k of
      Right v -> pure v
      Left ex -> throwIO ex
  tryFetch k = MockMutate $ \rm _ _ ->
    pure (lookupMock rm k)
  primeCache _ _ = pure ()

instance (Monad n, n ~ IO) => MonadMutate m (MockMutate m n) where
  mutate k = MockMutate $ \_ mh ref ->
    case lookupMutationHandler mh k of
      Right v -> do
        let tr = SomeTypeRep (typeOf k)
        atomicModifyIORef' ref $ \recs ->
          (recs <> [RecordedMutation tr k], ())
        pure v
      Left ex -> throwIO ex

  tryMutate k = MockMutate $ \_ mh ref -> do
    result <- try $ evaluate $ lookupMutationHandler mh k
    case result of
      Right (Right v) -> do
        let tr = SomeTypeRep (typeOf k)
        atomicModifyIORef' ref $ \recs ->
          (recs <> [RecordedMutation tr k], ())
        pure (Right v)
      Right (Left ex) -> pure (Left ex)
      Left ex -> pure (Left ex)

instance MonadFail n => MonadFail (MockMutate m n) where
  fail msg = MockMutate $ \_ _ _ -> fail msg

instance MonadThrow n => MonadThrow (MockMutate m n) where
  throwM e = MockMutate $ \_ _ _ -> throwM e

instance MonadCatch n => MonadCatch (MockMutate m n) where
  catch (MockMutate f) handler = MockMutate $ \rm mh ref ->
    catch (f rm mh ref) (\e -> unMockMutate (handler e) rm mh ref)

-- | Run a computation against mock data and mutation handlers.
-- Returns the result and a list of recorded mutations.
--
-- @
-- let mocks = mockData \@UserId [(UserId 1, User 1 "alice")]
--     handlers = mockMutation \@UpdateUser (\\(UpdateUser uid name) -> User uid name)
-- (result, mutations) <- runMockMutate \@AppM mocks handlers myAction
-- length mutations \`shouldBe\` 1
-- @
runMockMutate :: ResultMap -> MutationHandlers -> MockMutate m IO a -> IO (a, [RecordedMutation])
runMockMutate rm mh (MockMutate f) = do
  ref <- newIORef []
  a <- f rm mh ref
  mutations <- readIORef ref
  pure (a, mutations)