packages feed

espial-0.0.41: test/Model/NotesPagingSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}

module Model.NotesPagingSpec (spec) where

import Data.Time (addUTCTime)
import Model.Custom (hashPasswordBCryptWithPolicy, mkNtSlug)
import TestImport
import Types (DB)

t0 :: UTCTime
t0 = UTCTime (fromGregorian 2024 1 1) 0

createTestUser :: DB (Key User)
createTestUser = do
  pwHash <- liftIO $ hashPasswordBCryptWithPolicy bcryptTestPolicy "pass"
  insert $ User "testuser" pwHash Nothing False False True True False False True Nothing

createNoteAt :: Key User -> Text -> UTCTime -> DB (Key Note)
createNoteAt uid title t = do
  slug <- liftIO mkNtSlug
  insert $ Note uid slug (length title) title "text" False False t t

queryDir :: SortDirection -> Key User -> Maybe NotePagingCursor -> Limit -> DB ([Key Note], Bool, Bool)
queryDir dir uid mcursor lim = do
  (_, rows, hasEarlier, hasLater) <- getNoteList uid Nothing SharedAll (PageByCursor dir mcursor) lim
  return (map entityKey rows, hasEarlier, hasLater)

queryWithCursor :: Key User -> Maybe NotePagingCursor -> DB [Key Note]
queryWithCursor uid mcursor = do
  (nids, _, _) <- queryDir SortDesc uid mcursor 100
  return nids

beforeT :: UTCTime -> Maybe NotePagingCursor
beforeT t = Just (PagingCursorBefore (NoteCursor t Nothing))

afterT :: UTCTime -> Maybe NotePagingCursor
afterT t = Just (PagingCursorAfter (NoteCursor t Nothing))

spec :: Spec
spec = withApp $ do
  describe "getNoteList paging cursor" $ do

    -- ─── Cursor boundaries ─────────────────────────────────────────────────

    describe "cursor boundaries" $ do
      it "PagingCursorBefore excludes a note at exactly the cursor time" $ do
        (uid, nid) <- runDB $ do
          uid <- createTestUser
          nid <- createNoteAt uid "a" t0
          return (uid, nid)
        nids <- runDB $ queryWithCursor uid (beforeT t0)
        liftIO $ nids `shouldNotContain` [nid]

      it "PagingCursorBefore includes a note strictly before the cursor time" $ do
        (uid, nid) <- runDB $ do
          uid <- createTestUser
          nid <- createNoteAt uid "a" t0
          return (uid, nid)
        nids <- runDB $ queryWithCursor uid (beforeT (addUTCTime 1 t0))
        liftIO $ nids `shouldContain` [nid]

      it "PagingCursorAfter excludes a note at exactly the cursor time" $ do
        (uid, nid) <- runDB $ do
          uid <- createTestUser
          nid <- createNoteAt uid "a" t0
          return (uid, nid)
        nids <- runDB $ queryWithCursor uid (afterT t0)
        liftIO $ nids `shouldNotContain` [nid]

      it "PagingCursorAfter includes a note strictly after the cursor time" $ do
        (uid, nid) <- runDB $ do
          uid <- createTestUser
          nid <- createNoteAt uid "a" (addUTCTime 1 t0)
          return (uid, nid)
        nids <- runDB $ queryWithCursor uid (afterT t0)
        liftIO $ nids `shouldContain` [nid]

    -- ─── Composite (time, id) keyset tie-breaking ──────────────────────────

    describe "composite (time, id) cursor tie-breaking" $ do
      it "PagingCursorBefore keeps only same-time rows with a smaller id" $ do
        (uid, n1, n2, _) <- runDB $ do
          uid <- createTestUser
          n1 <- createNoteAt uid "1" t0
          n2 <- createNoteAt uid "2" t0
          n3 <- createNoteAt uid "3" t0
          return (uid, n1, n2, n3)
        nids <- runDB $ queryWithCursor uid (Just (PagingCursorBefore (NoteCursor t0 (Just n2))))
        liftIO $ nids `shouldBe` [n1]

      it "PagingCursorAfter keeps only same-time rows with a larger id" $ do
        (uid, n2, n3) <- runDB $ do
          uid <- createTestUser
          _ <- createNoteAt uid "1" t0
          n2 <- createNoteAt uid "2" t0
          n3 <- createNoteAt uid "3" t0
          return (uid, n2, n3)
        nids <- runDB $ queryWithCursor uid (Just (PagingCursorAfter (NoteCursor t0 (Just n2))))
        liftIO $ nids `shouldBe` [n3]

      it "a time-only cursor still uses the pure time boundary" $ do
        (uid, nOld) <- runDB $ do
          uid <- createTestUser
          nOld <- createNoteAt uid "old" (addUTCTime (-1) t0)
          _ <- createNoteAt uid "tied" t0
          return (uid, nOld)
        nids <- runDB $ queryWithCursor uid (beforeT t0)
        liftIO $ nids `shouldBe` [nOld]

      it "pages through rows sharing one timestamp without skips or repeats" $ do
        (uid, allNids) <- runDB $ do
          uid <- createTestUser
          nids <- mapM (\n -> createNoteAt uid (tshow (n :: Int)) t0) [1 .. 5]
          return (uid, nids)
        -- newest-first display of same-time rows falls back to id desc
        let walk acc mcursor = do
              (nids, _, _) <- runDB $ queryDir SortDesc uid mcursor 2
              case lastMay nids of
                Nothing -> pure acc
                Just lastNid -> walk (acc <> nids) (Just (PagingCursorBefore (NoteCursor t0 (Just lastNid))))
        collected <- walk [] Nothing
        liftIO $ collected `shouldBe` reverse allNids

      it "pages forward through rows sharing one timestamp without skips or repeats" $ do
        (uid, allNids) <- runDB $ do
          uid <- createTestUser
          nids <- mapM (\n -> createNoteAt uid (tshow (n :: Int)) t0) [1 .. 5]
          return (uid, nids)
        -- oldest-first display of same-time rows falls back to id asc
        let walk acc mcursor = do
              (nids, _, _) <- runDB $ queryDir SortAsc uid mcursor 2
              case lastMay nids of
                Nothing -> pure acc
                Just lastNid -> walk (acc <> nids) (Just (PagingCursorAfter (NoteCursor t0 (Just lastNid))))
        collected <- walk [] Nothing
        liftIO $ collected `shouldBe` allNids