mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-05 20:57:44 +00:00
* core: cancel the chat callback on interrupt * badges: add multi-use codes * badges: add the managed group * badges: document multi-use codes and the group * core: sign test badges with whole-second expiry * badges: rework tracker and reply texts
1377 lines
67 KiB
Haskell
1377 lines
67 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
-- Members' messages reach a console over separate connections in no fixed order, so gating is asserted from the bot's store.
|
|
module Bots.BadgeService.GroupIntegrationTests (badgeGroupIntegrationTests) where
|
|
|
|
import BadgeService.Config (BadgeIssuerKey (..), GroupConfig (..))
|
|
import BadgeService.Group (GroupAction (..), GroupEvent (..), codeInTracker, ensureManagedGroup)
|
|
import BadgeService.Group.Command (maxUses)
|
|
import BadgeService.Options (BadgeServiceOpts (..))
|
|
import BadgeService.Service (ServiceState (..), badgeService, newServiceState)
|
|
import BadgeService.Store (ManagedGroup (..), getManagedGroup)
|
|
import Bots.BadgeService.BotTests (badgeBotName, badgeProfile, badgeTypeOf, credentialOf, entryOf, issueCode, issueRaw, mkBadgeServiceOpts, newPurchaseKeys, redeemWithQueue, requestObject, revokeRaw, serviceDbPrefix, shouldAnswerError, statementOf, stopBadgeService, testIssuerKeyIdx)
|
|
import ChatClient
|
|
import ChatTests.DBUtils
|
|
import ChatTests.Utils
|
|
import Control.Concurrent (threadDelay)
|
|
import Control.Concurrent.Async (Async, async, cancel, poll, waitCatchSTM)
|
|
import Control.Concurrent.STM (TQueue, atomically, newTQueueIO, orElse, readTMVar, readTQueue, readTVarIO, tryReadTMVar, writeTQueue)
|
|
import Control.Exception (IOException, bracket, catch, finally, throwIO, try)
|
|
import Control.Monad (forM_, guard, mfilter, replicateM_, unless, void, when)
|
|
import Data.Either (isRight)
|
|
import Data.Int (Int64)
|
|
import Data.List (isInfixOf)
|
|
import Data.List.NonEmpty (NonEmpty (..))
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import Data.Time.Calendar (fromGregorian)
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime)
|
|
import Network.Socket (Family (..), SockAddr (..), SocketType (..), close, connect, defaultProtocol, socket, tupleToHostAddress)
|
|
import Network.Wai.Handler.Warp (openFreePort)
|
|
import Simplex.Chat.Badges (BadgeType (..))
|
|
import Simplex.Chat.Badges.Code (formatBadgeCode, parseBadgeCode, randomBadgeCode)
|
|
import Simplex.Chat.Badges.Service
|
|
import Simplex.Chat.Controller (ChatCommand (APIDeleteChatItem), ChatConfig (..), ChatController (..), ChatResponse (CRChatItemsDeleted))
|
|
import Simplex.Chat.Core (sendChatCmd, sendChatCmdStr)
|
|
import Simplex.Chat.Messages (ChatRef (ChatRef), ChatType (CTGroup))
|
|
import Simplex.Chat.Messages.CIContent (CIDeleteMode (CIDMInternal))
|
|
import Simplex.Chat.Types (AgentInvId (..))
|
|
import Simplex.Chat.Types.Preferences (ChatBotCommand (..), FullDeleteGroupPreference (..), GroupFeatureEnabled (..), GroupFeatureI (..), GroupPreferences (..), HistoryGroupPreference (..), SGroupFeature (..), commands_, emptyGroupPrefs, getGroupPreference)
|
|
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
|
|
import Simplex.Messaging.Agent (disposeAgentClient)
|
|
import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), ConnShortLink (..), ContactConnType (..))
|
|
import Simplex.Messaging.Agent.Store.Common (withTransaction)
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import Simplex.Messaging.Crypto.BBS (bbsKeyGen)
|
|
import Simplex.Messaging.Encoding.String (strDecode)
|
|
import Simplex.Messaging.Util (tshow)
|
|
import System.Directory (createDirectoryIfMissing)
|
|
import System.Exit (ExitCode (..))
|
|
import System.FilePath ((</>))
|
|
import System.Timeout (timeout)
|
|
#if defined(dbPostgres)
|
|
import Database.PostgreSQL.Simple (FromRow, Only (..))
|
|
#else
|
|
import Database.SQLite.Simple (FromRow, Only (..))
|
|
#endif
|
|
import Test.Hspec hiding (it)
|
|
|
|
badgeGroupIntegrationTests :: SpecWith TestParams
|
|
badgeGroupIntegrationTests = do
|
|
it "creates the group and a join link on first start; on restart reuses it, re-advertises commands and keeps other preferences" testGroupCreateReuse
|
|
it "keeps the group's name and description when the configured ones change" testConfigNotApplied
|
|
it "promotes the first joiner to owner and leaves a later joiner a member" testFirstJoinerPromoted
|
|
it "promotes nobody after the first promotion failed" testFailedPromotionNotRetried
|
|
it "promotes the earliest member on start when their join was never handled" testMissedOwnerPromotedOnStart
|
|
it "promotes the earliest member, not a later joiner, when no owner was set up" testEarliestPromotedOnLaterJoin
|
|
it "skips a member still joining when promoting on start" testJoiningMemberSkippedOnStart
|
|
it "only records an owner made by hand on start, promoting nobody" testHandMadeOwnerKeptOnStart
|
|
it "promotes a member on start when the owner made by hand has left" testLeftOwnerReplacedOnStart
|
|
it "promotes nobody on start once the owner was set up, even with no owner left" testSetUpOwnerNotReplacedOnStart
|
|
it "does not serve a group its owner deleted" testDeletedGroupNotServed
|
|
it "does not serve a group the service itself deleted" testOwnDeletedGroupNotServed
|
|
it "drops the old group's trackers when it creates a new group" testReplacedGroupDropsTrackers
|
|
it "stops rather than create a second group when the group lookup fails" testGroupLookupFailureStops
|
|
it "stops the service's lanes when its runner is cancelled" testCancelStopsLanes
|
|
it "issues on a privileged member's command, ignores a plain member's" testRoleGatedIssue
|
|
it "ignores a command from a privileged member blocked for all" testBlockedMemberCommandIgnored
|
|
it "ignores a command sent in a member support scope" testSupportScopeIgnored
|
|
it "ignores a command sent as a live message" testLiveMessageIgnored
|
|
it "ignores an event for another group" testOtherGroupEventIgnored
|
|
it "issues a batch of redeemable single-use codes of the asked type and months on /bulk" testBulkIssue
|
|
it "issues an /issue code for the months the command asked for" testIssueMonths
|
|
it "answers a failed /issue or //issue without naming what failed in the store" testIssueFailureIsOpaque
|
|
it "lists the codes a /bulk issued before an insert failed" testBulkPartialFailureListsIssued
|
|
it "answers a failed /revoke without naming what failed in the store" testRevokeFailureIsOpaque
|
|
it "tracks a multi-use code in one message across redemptions and refuses it when used up" testMultiUseTracker
|
|
it "leaves a used-up code's tracker as it is on a stale refresh" testStaleRefreshIgnored
|
|
it "keeps each multi-use code's counter to its own claims" testCountersKeptApart
|
|
it "refreshes the tracker for a redemption served with no group lane" testLanelessRedeemRefreshesTracker
|
|
it "refreshes the tracker for a redemption from the service's request queue" testQueuedRequestRefreshesTracker
|
|
it "refreshes the tracker inline for a queued redemption after a restart without [group]" testQueuedRequestWithoutGroupConfig
|
|
it "reposts and re-anchors the tracker past the edit window" testTrackerRepost
|
|
it "reposts and re-anchors the tracker when the core refuses the edit" testRefusedEditReposted
|
|
it "keeps the tracker anchored when the repost cannot be sent" testFailedRepostDropped
|
|
it "never reposts a tracker deleted on the service's side, publishing the code no further" testDeletedTrackerNotReposted
|
|
it "never reposts a tracker an owner deleted for everyone" testModeratedTrackerNotReposted
|
|
it "revokes a code whose tracker was deleted on the service's side without reposting it" testDeletedTrackerNotRepostedOnRevoke
|
|
it "publishes nothing when a code whose tracker was deleted is used up" testDeletedTrackerUsedUp
|
|
it "revokes a code whose tracker an owner deleted for everyone without reposting it" testModeratedTrackerNotRepostedOnRevoke
|
|
it "revokes a code on /revoke, answers a repeat as already revoked and an unknown code as no such code" testGroupRevoke
|
|
it "retires the tracker message when a multi-use code is revoked" testRevokeRetiresTracker
|
|
it "retires the tracker message when the service command revokes the code" testServiceRevokeRetiresTracker
|
|
it "retires a tracker left behind when the code is revoked again" testGroupRevokeRepairsTracker
|
|
it "retires a tracker left behind when the service command revokes again" testServiceRevokeRepairsTracker
|
|
it "posts one retired tracker however often a revoke past the window repeats" testRevokeRepeatPastWindow
|
|
it "reconciles a used-up tracker whose refresh was lost, in place" testUsedUpTrackerReconciledOnRestart
|
|
it "reconciles a tracker with uses left whose refresh was lost" testStalledTrackerReconciledOnRestart
|
|
it "retires on restart a tracker whose revoke never reached it" testRevokedTrackerReconciledOnRestart
|
|
it "reconciles every stalled tracker on restart, not only the first" testEveryStalledTrackerReconciled
|
|
it "leaves a tracker it cannot edit alone rather than publishing the code again" testUneditableTrackerLeftAlone
|
|
|
|
groupName :: String
|
|
groupName = "testbadges"
|
|
|
|
groupDescr :: Text
|
|
groupDescr = "badge ops desk"
|
|
|
|
testGroupConfig :: GroupConfig
|
|
testGroupConfig = GroupConfig {gDisplayName = T.pack groupName, gDescription = Just groupDescr}
|
|
|
|
revokeReply :: Text -> Text -> Text
|
|
revokeReply code outcome = code <> ": " <> outcome
|
|
|
|
revokeCmd :: Text -> String
|
|
revokeCmd code = "/revoke " <> T.unpack code
|
|
|
|
revokeInGroup :: HasCallStack => ChatController -> TestCC -> Text -> Text -> IO ()
|
|
revokeInGroup cc member code outcome = do
|
|
sendGroupCmd member (revokeCmd code)
|
|
waitStoredItem cc (revokeReply code outcome)
|
|
|
|
-- The name is quoted because console output quotes a name with a space.
|
|
botName :: String
|
|
botName = "'" <> T.unpack badgeBotName <> "'"
|
|
|
|
data GroupSvc = GroupSvc
|
|
{ gsCfg :: ChatConfig,
|
|
gsKey :: BadgeIssuerKey,
|
|
gsStaticDir :: FilePath,
|
|
gsPs :: TestParams
|
|
}
|
|
|
|
prepareGroupService :: HasCallStack => TestParams -> IO GroupSvc
|
|
prepareGroupService ps@TestParams {tmpPath} =
|
|
bbsKeyGen >>= \case
|
|
Left e -> error $ "bbsKeyGen failed: " <> e
|
|
Right (pk, sk) -> do
|
|
let gsKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}
|
|
gsCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk}
|
|
gsStaticDir = tmpPath </> "badge_group_static"
|
|
createDirectoryIfMissing True gsStaticDir
|
|
withNewTestChatCfg ps gsCfg serviceDbPrefix badgeProfile $ \_ -> pure ()
|
|
pure GroupSvc {gsCfg, gsKey, gsStaticDir, gsPs = ps}
|
|
|
|
runGroupService :: HasCallStack => GroupSvc -> (ChatController -> ServiceState -> Int64 -> IO a) -> IO a
|
|
runGroupService svc = runGroupServiceAs svc (Just testGroupConfig)
|
|
|
|
runGroupServiceAs :: HasCallStack => GroupSvc -> Maybe GroupConfig -> (ChatController -> ServiceState -> Int64 -> IO a) -> IO a
|
|
runGroupServiceAs svc groupCfg action = do
|
|
(t, cc, env, port) <- startGroupService svc groupCfg
|
|
-- The web listener starts after group start-up, so the stop cannot land inside start-up's database calls.
|
|
let started = pollUntilTrue (listening port) >> pollUntil (getStoredGroup cc)
|
|
-- A stop inside a lane's database call leaves a statement open, and closing the store then fails.
|
|
settle gid = when (isJust groupCfg) $ do
|
|
status <- memberStatus cc gid badgeBotName
|
|
forM_ status $ \s -> unless (s `elem` ["removed", "left", "deleted"]) $ settleLane cc env gid
|
|
(started >>= \ManagedGroup {mgGroupId} -> action cc env mgGroupId <* settle mgGroupId) `finally` stopGroupService t cc
|
|
|
|
-- It returns once chat has started, while group start-up may still run; the Int is its web listener's port.
|
|
startGroupService :: HasCallStack => GroupSvc -> Maybe GroupConfig -> IO (Async (), ChatController, ServiceState, Int)
|
|
startGroupService svc@GroupSvc {gsCfg} groupCfg = do
|
|
(opts, port) <- groupServiceOpts svc groupCfg
|
|
env <- newServiceState
|
|
t <- async $ badgeService opts gsCfg env
|
|
-- A service that ends before it is ready fails the test with its own error, not the timeout.
|
|
cc <-
|
|
timeout waitLimit (atomically $ (Right <$> readTMVar (serviceCC env)) `orElse` (Left <$> waitCatchSTM t)) >>= \case
|
|
Just (Right cc) -> pure cc
|
|
Just (Left ended) -> either throwIO (\_ -> error "badge service ended before it started") ended
|
|
Nothing -> cancel t >> error "badge service did not start"
|
|
pure (t, cc, env, port)
|
|
|
|
-- Cancelling ends the service lanes while chat still runs; stopping chat first would leave them running.
|
|
stopGroupService :: Async () -> ChatController -> IO ()
|
|
stopGroupService t cc = do
|
|
-- cancel discards how the service ended, so a crash is read first and rethrown after cleanup.
|
|
ended <- poll t
|
|
cancel t
|
|
stopChat cc
|
|
forM_ ended $ either throwIO pure
|
|
|
|
stopChat :: ChatController -> IO ()
|
|
stopChat cc = stopBadgeService cc >> disposeAgentClient (smpAgent cc)
|
|
|
|
-- It writes the service's ini on a free port; Nothing omits the [group] section.
|
|
groupServiceOpts :: GroupSvc -> Maybe GroupConfig -> IO (BadgeServiceOpts, Int)
|
|
groupServiceOpts GroupSvc {gsKey = BadgeIssuerKey {secretKey}, gsStaticDir, gsPs = ps@TestParams {tmpPath}} groupCfg = do
|
|
(port, sock) <- openFreePort
|
|
close sock
|
|
let iniPath = tmpPath </> "badge_group.ini"
|
|
writeFile iniPath $
|
|
unlines $
|
|
[ "[listener]",
|
|
"host = 127.0.0.1",
|
|
"port = " <> show port,
|
|
"static_dir = " <> gsStaticDir
|
|
]
|
|
<> maybe [] groupSection groupCfg
|
|
pure ((mkBadgeServiceOpts ps secretKey) {serviceConfigFile = Just iniPath, noAddress = True}, port)
|
|
where
|
|
groupSection GroupConfig {gDisplayName, gDescription} =
|
|
["", "[group]", "display_name = " <> T.unpack gDisplayName]
|
|
<> maybe [] (\d -> ["description = " <> T.unpack d]) gDescription
|
|
|
|
withGroupOwner :: HasCallStack => TestParams -> (BadgeIssuerKey -> ChatController -> ServiceState -> TestCC -> IO ()) -> IO ()
|
|
withGroupOwner ps action = do
|
|
svc@GroupSvc {gsKey} <- prepareGroupService ps
|
|
runWithOwner svc $ \cc env _ -> action gsKey cc env
|
|
|
|
runWithOwner :: HasCallStack => GroupSvc -> (ChatController -> ServiceState -> Int64 -> TestCC -> IO a) -> IO a
|
|
runWithOwner svc@GroupSvc {gsPs} action = runGroupService svc $ \cc env gid -> withOwnerJoined gsPs cc gid $ action cc env gid
|
|
|
|
withOwnerJoined :: HasCallStack => TestParams -> ChatController -> Int64 -> (TestCC -> IO a) -> IO a
|
|
withOwnerJoined ps cc gid action =
|
|
withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
joinGroup cc alice
|
|
waitMemberRole cc gid "alice" "owner"
|
|
r <- action alice
|
|
drainConsole alice
|
|
pure r
|
|
|
|
-- Bob joins after the first member and is connected to her.
|
|
withMemberJoined :: HasCallStack => TestParams -> ChatController -> TestCC -> (TestCC -> IO a) -> IO a
|
|
withMemberJoined ps cc alice action =
|
|
withNewTestChat ps "bob" bobProfile $ \bob -> do
|
|
joinGroup cc bob
|
|
drainUntil alice ["#" <> groupName <> ": new member bob is connected"]
|
|
r <- action bob
|
|
drainConsole bob
|
|
pure r
|
|
|
|
joinGroup :: HasCallStack => ChatController -> TestCC -> IO ()
|
|
joinGroup cc member = groupLink cc >>= send member . ("/c " <>)
|
|
|
|
sendGroupCmd :: TestCC -> String -> IO ()
|
|
sendGroupCmd member cmd = send member ("#" <> groupName <> " " <> cmd)
|
|
|
|
issueTracked :: HasCallStack => ChatController -> TestCC -> String -> Int -> IO (Int64, Text, Text)
|
|
issueTracked cc member badgeType uses = do
|
|
sendGroupCmd member ("/issue " <> badgeType <> " uses " <> show uses)
|
|
(citemId, body) <- waitTrackerItemOf cc uses
|
|
pure (citemId, body, extractCode body)
|
|
|
|
testGroupCreateReuse :: HasCallStack => TestParams -> IO ()
|
|
testGroupCreateReuse ps = do
|
|
svc <- prepareGroupService ps
|
|
runGroupService svc $ \cc _ gid -> do
|
|
ManagedGroup {mgGroupLink} <- storedGroup cc
|
|
shortLinkContactType mgGroupLink `shouldBe` Right CCTGroup
|
|
groupCount cc `shouldReturn` 1
|
|
advertisedCommands cc gid `shouldReturn` Just expectedCommands
|
|
profileDescription cc gid `shouldReturn` Just groupDescr
|
|
profileFullNameShortDescr cc gid `shouldReturn` ("", Nothing)
|
|
groupFeaturePreference cc gid SGFHistory `shouldReturn` HistoryGroupPreference {enable = FEOff}
|
|
-- The first run never rewrites the profile, so only the restart can restore what is blanked here.
|
|
clearCommandsSettingFullDelete cc gid
|
|
advertisedAt <- runGroupService svc $ \cc _ gid -> do
|
|
-- Commands are advertised as the group is resolved, so once they are back this run has found the stored group.
|
|
waitAdvertisedCommands cc gid
|
|
groupCount cc `shouldReturn` 1
|
|
profileDisplayName cc gid `shouldReturn` T.pack groupName
|
|
profileDescription cc gid `shouldReturn` Just groupDescr
|
|
profileFullNameShortDescr cc gid `shouldReturn` ("", Nothing)
|
|
groupFeaturePreference cc gid SGFFullDelete `shouldReturn` FullDeleteGroupPreference {enable = FEOn, role = Nothing}
|
|
profileUpdatedAt cc gid
|
|
-- The lane drains only after the group is resolved, so the promotion proves the start-up sync has run.
|
|
runWithOwner svc $ \cc _ gid _ -> profileUpdatedAt cc gid `shouldReturn` advertisedAt
|
|
|
|
testConfigNotApplied :: HasCallStack => TestParams -> IO ()
|
|
testConfigNotApplied ps = do
|
|
svc <- prepareGroupService ps
|
|
-- Clearing the commands makes the restart rewrite the profile, the only path that could apply the new config.
|
|
runGroupService svc $ \cc _ gid -> clearCommandsSettingFullDelete cc gid
|
|
runGroupServiceAs svc (Just GroupConfig {gDisplayName = "renamedbadges", gDescription = Just "renamed desk"}) $ \cc env gid -> do
|
|
waitAdvertisedCommands cc gid
|
|
awaitLane cc env
|
|
groupCount cc `shouldReturn` 1
|
|
profileDisplayName cc gid `shouldReturn` T.pack groupName
|
|
profileDescription cc gid `shouldReturn` Just groupDescr
|
|
|
|
testMissedOwnerPromotedOnStart :: HasCallStack => TestParams -> IO ()
|
|
testMissedOwnerPromotedOnStart ps = do
|
|
svc <- prepareGroupService ps
|
|
-- A demoted owner with the flag cleared looks like a join the lane never handled.
|
|
withTwoMembersOwnerUnset svc $ \cc gid _ -> setMemberRole cc gid "alice" "member"
|
|
runGroupService svc $ \cc env gid -> do
|
|
waitMemberRole cc gid "alice" "owner"
|
|
awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")]
|
|
|
|
testEarliestPromotedOnLaterJoin :: HasCallStack => TestParams -> IO ()
|
|
testEarliestPromotedOnLaterJoin ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc env gid alice -> do
|
|
setMemberRole cc gid "alice" "member"
|
|
executeSql cc "UPDATE sx_badge_service_group SET owner_bootstrapped = 0"
|
|
withMemberJoined ps cc alice $ \_ -> do
|
|
waitMemberRole cc gid "alice" "owner"
|
|
awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")]
|
|
|
|
testJoiningMemberSkippedOnStart :: HasCallStack => TestParams -> IO ()
|
|
testJoiningMemberSkippedOnStart ps = do
|
|
svc <- prepareGroupService ps
|
|
withTwoMembersOwnerUnset svc $ \cc gid _ -> do
|
|
setMemberRole cc gid "alice" "member"
|
|
withTransaction (chatStore cc) $ \db ->
|
|
DB.execute db "UPDATE group_members SET member_status = 'accepted' WHERE group_id = ? AND local_display_name = 'alice'" (Only gid)
|
|
runGroupService svc $ \cc env gid -> do
|
|
waitMemberRole cc gid "bob" "owner"
|
|
awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "owner")]
|
|
|
|
testHandMadeOwnerKeptOnStart :: HasCallStack => TestParams -> IO ()
|
|
testHandMadeOwnerKeptOnStart ps = do
|
|
svc <- prepareGroupService ps
|
|
withTwoMembersOwnerUnset svc $ \cc gid _ -> do
|
|
setMemberRole cc gid "bob" "owner"
|
|
setMemberRole cc gid "alice" "member"
|
|
runGroupService svc $ \cc env gid -> do
|
|
awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "owner")]
|
|
fmap mgOwnerBootstrapped <$> getStoredGroup cc `shouldReturn` Just True
|
|
|
|
testDeletedGroupNotServed :: HasCallStack => TestParams -> IO ()
|
|
testDeletedGroupNotServed ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ gid alice -> do
|
|
drainConsole alice
|
|
alice ##> ("/d #" <> groupName)
|
|
alice <## ("#" <> groupName <> ": you deleted the group (signed)")
|
|
pollUntilTrue $ (== Just "deleted") <$> memberStatus cc gid badgeBotName
|
|
runGroupService svc $ \cc _ _ ->
|
|
ensureManagedGroup cc testGroupConfig `shouldReturn` Nothing
|
|
|
|
testOwnDeletedGroupNotServed :: HasCallStack => TestParams -> IO ()
|
|
testOwnDeletedGroupNotServed ps = do
|
|
svc <- prepareGroupService ps
|
|
runGroupService svc $ \cc env gid -> do
|
|
-- The harness cannot settle a deleted group, so the lane must be idle before the delete.
|
|
settleLane cc env gid
|
|
sendChatCmdStr cc ("/d #" <> groupName) >>= (`shouldSatisfy` isRight)
|
|
runGroupService svc $ \cc _ _ ->
|
|
ensureManagedGroup cc testGroupConfig `shouldReturn` Nothing
|
|
|
|
testReplacedGroupDropsTrackers :: HasCallStack => TestParams -> IO ()
|
|
testReplacedGroupDropsTrackers ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ _ alice -> do
|
|
void $ issueTracked cc alice "supporter" 3
|
|
executeSql cc "DELETE FROM sx_badge_service_group"
|
|
runGroupService svc $ \cc _ _ ->
|
|
countRows cc "sx_badge_service_badge_codes WHERE group_item_id IS NOT NULL OR group_item_sent_at IS NOT NULL" `shouldReturn` 0
|
|
|
|
testGroupLookupFailureStops :: HasCallStack => TestParams -> IO ()
|
|
testGroupLookupFailureStops ps = do
|
|
svc@GroupSvc {gsCfg} <- prepareGroupService ps
|
|
runGroupService svc $ \cc _ _ ->
|
|
executeSql cc "ALTER TABLE sx_badge_service_group RENAME TO sx_badge_service_group_hidden"
|
|
(opts, _) <- groupServiceOpts svc (Just testGroupConfig)
|
|
env <- newServiceState
|
|
-- Falling through to create would keep the service running, so the timeout would fire instead.
|
|
ended <- timeout waitLimit (try $ badgeService opts gsCfg env)
|
|
atomically (tryReadTMVar (serviceCC env)) >>= mapM_ stopChat
|
|
ended `shouldBe` Just (Left (ExitFailure 1))
|
|
|
|
-- The web listener is one of the service's lanes and only the cancel precedes the check,
|
|
-- so its port closing shows the core ended the lanes.
|
|
testCancelStopsLanes :: HasCallStack => TestParams -> IO ()
|
|
testCancelStopsLanes ps = do
|
|
svc <- prepareGroupService ps
|
|
(t, cc, _, port) <- startGroupService svc (Just testGroupConfig)
|
|
(pollUntilTrue (listening port) >> cancel t >> pollUntilTrue (not <$> listening port)) `finally` (cancel t >> stopChat cc)
|
|
|
|
listening :: Int -> IO Bool
|
|
listening port =
|
|
bracket (socket AF_INET Stream defaultProtocol) close $ \s ->
|
|
(True <$ connect s (SockAddrInet (fromIntegral port) (tupleToHostAddress (127, 0, 0, 1)))) `catch` \(_ :: IOException) -> pure False
|
|
|
|
testLeftOwnerReplacedOnStart :: HasCallStack => TestParams -> IO ()
|
|
testLeftOwnerReplacedOnStart ps = do
|
|
svc <- prepareGroupService ps
|
|
withTwoMembersOwnerUnset svc $ \cc gid bob -> do
|
|
setMemberRole cc gid "bob" "owner"
|
|
setMemberRole cc gid "alice" "member"
|
|
send bob ("/l " <> groupName)
|
|
pollUntilTrue $ (== Just "left") <$> memberStatus cc gid "bob"
|
|
runGroupService svc $ \cc _ gid -> waitMemberRole cc gid "alice" "owner"
|
|
|
|
testSetUpOwnerNotReplacedOnStart :: HasCallStack => TestParams -> IO ()
|
|
testSetUpOwnerNotReplacedOnStart ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ gid _ -> setMemberRole cc gid "alice" "member"
|
|
runGroupService svc $ \cc env gid -> do
|
|
awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "member")]
|
|
|
|
memberStatus :: ChatController -> Int64 -> Text -> IO (Maybe Text)
|
|
memberStatus cc gid name =
|
|
queryFirst cc $
|
|
"SELECT member_status FROM group_members WHERE group_id = "
|
|
<> show gid
|
|
<> " AND local_display_name = '"
|
|
<> T.unpack name
|
|
<> "'"
|
|
|
|
withTwoMembersOwnerUnset :: HasCallStack => GroupSvc -> (ChatController -> Int64 -> TestCC -> IO ()) -> IO ()
|
|
withTwoMembersOwnerUnset svc@GroupSvc {gsPs = ps} action =
|
|
runWithOwner svc $ \cc env gid alice ->
|
|
withMemberJoined ps cc alice $ \bob -> do
|
|
-- The lane must handle bob's join before the flag is cleared, or it would act on the cleared flag in this run.
|
|
awaitLane cc env
|
|
action cc gid bob
|
|
executeSql cc "UPDATE sx_badge_service_group SET owner_bootstrapped = 0"
|
|
|
|
testFirstJoinerPromoted :: HasCallStack => TestParams -> IO ()
|
|
testFirstJoinerPromoted ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ gid alice ->
|
|
withMemberJoined ps cc alice $ \bob -> do
|
|
waitMemberRole cc gid "bob" "member"
|
|
-- The bot queues bob's join before it introduces him, so alice's code proves the join was handled.
|
|
sendGroupCmd alice "/issue supporter"
|
|
waitCodeOfType cc "supporter"
|
|
memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")]
|
|
let codeReply = "#" <> groupName <> " " <> botName <> "> Code: SB-"
|
|
drainUntil alice [codeReply]
|
|
drainUntil bob [codeReply, "#" <> groupName <> ": member alice"]
|
|
|
|
testFailedPromotionNotRetried :: HasCallStack => TestParams -> IO ()
|
|
testFailedPromotionNotRetried ps = do
|
|
svc <- prepareGroupService ps
|
|
runGroupService svc $ \cc env gid -> do
|
|
-- An admin cannot make an owner, so the core refuses the promotion.
|
|
setBotRole cc "admin"
|
|
withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
joinGroup cc alice
|
|
pollUntilTrue $ mgOwnerBootstrapped <$> storedGroup cc
|
|
awaitLane cc env
|
|
setBotRole cc "owner"
|
|
withMemberJoined ps cc alice $ \_ -> awaitLane cc env
|
|
memberRoles cc gid `shouldReturn` [("alice", "member"), ("bob", "member")]
|
|
drainConsole alice
|
|
|
|
testRoleGatedIssue :: HasCallStack => TestParams -> IO ()
|
|
testRoleGatedIssue ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ gid alice ->
|
|
withNewTestChat ps "bob" bobProfile $ \bob ->
|
|
withNewTestChat ps "cath" cathProfile $ \cath -> do
|
|
let announced name = "#" <> groupName <> ": " <> botName <> " added " <> name
|
|
preMember name = "#" <> groupName <> ": member " <> name
|
|
newMember name = "#" <> groupName <> ": new member " <> name <> " is connected"
|
|
usageReply = "#" <> groupName <> " " <> botName <> "> Usage: /issue <type>"
|
|
joinGroup cc bob
|
|
waitMemberRole cc gid "bob" "member"
|
|
-- cath joins only after alice sees bob announced, or cath's console shows a different line.
|
|
drainUntil alice [announced "bob"]
|
|
joinGroup cc cath
|
|
waitMemberRole cc gid "cath" "member"
|
|
drainUntil cath [preMember "alice", preMember "bob"]
|
|
drainConsole cath
|
|
sendGroupCmd alice "/issue supporter"
|
|
getInAnyOrder
|
|
dropTime
|
|
cath
|
|
[ StartsWith ("#" <> groupName <> " alice> /issue supporter"),
|
|
StartsWith ("#" <> groupName <> " " <> botName <> "> Code: SB-")
|
|
]
|
|
codeCount cc `shouldReturn` 1
|
|
sendGroupCmd bob "/issue legend"
|
|
waitStoredItem cc "/issue legend"
|
|
ownerIssuesNext cc alice 2
|
|
sendGroupCmd alice "/issue supporter uses 0"
|
|
waitStoredItem cc "Usage: /issue <type> [months <M>] [uses <N>]"
|
|
codeCount cc `shouldReturn` 2
|
|
drainUntil alice [usageReply, newMember "bob", newMember "cath"]
|
|
drainUntil bob [usageReply, preMember "alice", newMember "cath"]
|
|
drainUntil cath [usageReply]
|
|
drainConsole bob
|
|
drainConsole cath
|
|
|
|
testBlockedMemberCommandIgnored :: HasCallStack => TestParams -> IO ()
|
|
testBlockedMemberCommandIgnored ps = do
|
|
svc <- prepareGroupService ps
|
|
runWithOwner svc $ \cc _ gid alice ->
|
|
withMemberJoined ps cc alice $ \bob -> do
|
|
waitMemberRole cc gid "bob" "member"
|
|
-- bob is made moderator so that only the block can refuse his command.
|
|
send alice ("/mr #" <> groupName <> " bob moderator")
|
|
waitMemberRole cc gid "bob" "moderator"
|
|
-- The block and the command travel over different connections, so the block is awaited first.
|
|
send alice ("/block for all #" <> groupName <> " bob")
|
|
waitMemberBlocked cc gid "bob"
|
|
sendGroupCmd bob "/bulk legend months 255 count 100"
|
|
(deleted, content) <- waitBlockedItem cc "/bulk legend months 255 count 100"
|
|
deleted `shouldBe` blockedByAdminMark
|
|
content `shouldSatisfy` T.isInfixOf "/bulk legend months 255 count 100"
|
|
ownerIssuesNext cc alice 1
|
|
|
|
-- The lane is a single FIFO drainer, so the owner's next code proves every earlier command was handled.
|
|
ownerIssuesNext :: HasCallStack => ChatController -> TestCC -> Int -> IO ()
|
|
ownerIssuesNext cc owner total = do
|
|
sendGroupCmd owner "/issue investor"
|
|
waitCodeOfType cc "investor"
|
|
codeCount cc `shouldReturn` total
|
|
|
|
-- This is the item_deleted value the core writes for a message from a member blocked for all.
|
|
blockedByAdminMark :: Int
|
|
blockedByAdminMark = 3
|
|
|
|
testSupportScopeIgnored :: HasCallStack => TestParams -> IO ()
|
|
testSupportScopeIgnored ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
send alice "/_send #1(_support) text /issue legend"
|
|
waitStoredItem cc "/issue legend"
|
|
ownerIssuesNext cc alice 1
|
|
|
|
testLiveMessageIgnored :: HasCallStack => TestParams -> IO ()
|
|
testLiveMessageIgnored ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
send alice ("/live #" <> groupName <> " /issue legend")
|
|
waitStoredItem cc "/issue legend"
|
|
ownerIssuesNext cc alice 1
|
|
|
|
testOtherGroupEventIgnored :: HasCallStack => TestParams -> IO ()
|
|
testOtherGroupEventIgnored ps = do
|
|
svc <- prepareGroupService ps
|
|
runGroupService svc $ \cc env gid -> do
|
|
atomically $ writeTQueue (groupEventQ env) (GEInGroup (gid + 1) (GACommand GROwner "/issue legend"))
|
|
awaitLane cc env
|
|
queryColumn cc "SELECT badge_type FROM sx_badge_service_badge_codes" `shouldReturn` ["supporter" :: Text]
|
|
|
|
testBulkIssue :: HasCallStack => TestParams -> IO ()
|
|
testBulkIssue ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
codes <- map extractCode . T.lines <$> replyTo cc alice "/bulk legend months 2 count 3"
|
|
length codes `shouldBe` 3
|
|
codeCount cc `shouldReturn` 3
|
|
singleUseCodeCount cc `shouldReturn` 3
|
|
freeCodeCount cc `shouldReturn` 3
|
|
forM_ codes $ \code -> do
|
|
r <- redeemViaService gsKey cc env code
|
|
badgeTypeOf r `shouldBe` Just BTLegend
|
|
map (\e -> let (c, m, _) = entryOf e in (c, m)) (fst $ statementOf r) `shouldBe` [(2, 2), (-1, 1)]
|
|
forM_ codes $ \code -> redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeUsed)
|
|
countRows cc "sx_badge_service_badge_codes WHERE group_item_id IS NOT NULL" `shouldReturn` 0
|
|
|
|
testIssueMonths :: HasCallStack => TestParams -> IO ()
|
|
testIssueMonths ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
sendGroupCmd alice "/issue legend months 12"
|
|
waitCodeMonths cc "legend" `shouldReturn` 12
|
|
freeCodeCount cc `shouldReturn` 1
|
|
|
|
testBulkPartialFailureListsIssued :: HasCallStack => TestParams -> IO ()
|
|
testBulkPartialFailureListsIssued ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
codeLines <- withCodeTableCapped cc 2 $ do
|
|
(codeLines, summary) <- splitAt 2 . T.lines <$> replyTo cc alice "/bulk supporter count 3"
|
|
summary `shouldBe` ["Issued 2 of 3 codes. The remaining codes could not be issued."]
|
|
pure codeLines
|
|
codeCount cc `shouldReturn` 2
|
|
forM_ codeLines $ redeemOk gsKey cc env . extractCode
|
|
|
|
testIssueFailureIsOpaque :: HasCallStack => TestParams -> IO ()
|
|
testIssueFailureIsOpaque ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
withCodeTableHidden cc $ do
|
|
replyTo cc alice "/issue supporter" `shouldReturn` "The code could not be issued."
|
|
issueRaw cc "supporter" `shouldReturn` Left "The code could not be issued."
|
|
void $ replyTo cc alice "/issue supporter"
|
|
codeCount cc `shouldReturn` 1
|
|
|
|
testRevokeFailureIsOpaque :: HasCallStack => TestParams -> IO ()
|
|
testRevokeFailureIsOpaque ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
code <- extractCode <$> replyTo cc alice "/issue supporter"
|
|
withCodeTableHidden cc $
|
|
replyTo cc alice (revokeCmd code) `shouldReturn` revokeReply code "The code could not be revoked."
|
|
revokeInGroup cc alice code "Revoked."
|
|
|
|
withCodeTableHidden :: ChatController -> IO a -> IO a
|
|
withCodeTableHidden cc action = rename codeTable hidden >> (action `finally` rename hidden codeTable)
|
|
where
|
|
codeTable = "sx_badge_service_badge_codes"
|
|
hidden = codeTable <> "_hidden"
|
|
rename from to = executeSql cc $ "ALTER TABLE " <> from <> " RENAME TO " <> to
|
|
|
|
-- A test-only trigger fails every insert once the code table holds cap rows.
|
|
withCodeTableCapped :: ChatController -> Int -> IO a -> IO a
|
|
withCodeTableCapped cc cap action = mapM_ (executeSql cc) createCap >> (action `finally` mapM_ (executeSql cc) dropCap)
|
|
where
|
|
#if defined(dbPostgres)
|
|
createCap =
|
|
[ "CREATE FUNCTION sx_badge_service_test_cap() RETURNS trigger AS $$ BEGIN IF (SELECT COUNT(*) FROM sx_badge_service_badge_codes) >= "
|
|
<> show cap
|
|
<> " THEN RAISE EXCEPTION 'code table full'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql",
|
|
"CREATE TRIGGER sx_badge_service_test_cap BEFORE INSERT ON sx_badge_service_badge_codes FOR EACH ROW EXECUTE FUNCTION sx_badge_service_test_cap()"
|
|
]
|
|
dropCap = ["DROP FUNCTION sx_badge_service_test_cap() CASCADE"]
|
|
#else
|
|
createCap =
|
|
[ "CREATE TRIGGER sx_badge_service_test_cap BEFORE INSERT ON sx_badge_service_badge_codes WHEN (SELECT COUNT(*) FROM sx_badge_service_badge_codes) >= "
|
|
<> show cap
|
|
<> " BEGIN SELECT RAISE(ABORT, 'code table full'); END"
|
|
]
|
|
dropCap = ["DROP TRIGGER sx_badge_service_test_cap"]
|
|
#endif
|
|
|
|
testMultiUseTracker :: HasCallStack => TestParams -> IO ()
|
|
testMultiUseTracker ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
tracker0 `shouldSatisfy` T.isInfixOf "2 of 2 uses remaining"
|
|
redeemOk gsKey cc env code
|
|
t1 <- waitItemText cc trackerItemId "1 of 2 uses remaining"
|
|
t1 `shouldSatisfy` T.isInfixOf "last used"
|
|
redeemOk gsKey cc env code
|
|
usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed"
|
|
sentItemsWithCode cc code `shouldReturn` [usedUp]
|
|
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeUsed)
|
|
|
|
testStaleRefreshIgnored :: HasCallStack => TestParams -> IO ()
|
|
testStaleRefreshIgnored ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
|
|
redeemOk gsKey cc env code
|
|
redeemOk gsKey cc env code
|
|
usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed"
|
|
replayRefresh cc env code 2
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` [usedUp]
|
|
|
|
testCountersKeptApart :: HasCallStack => TestParams -> IO ()
|
|
testCountersKeptApart ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(firstItemId, _, firstCode) <- issueTracked cc alice "supporter" 5
|
|
replicateM_ 3 (redeemOk gsKey cc env firstCode)
|
|
void $ waitItemText cc firstItemId "2 of 5 uses remaining"
|
|
(secondItemId, _, secondCode) <- issueTracked cc alice "legend" 3
|
|
replicateM_ 3 (redeemOk gsKey cc env secondCode)
|
|
void $ waitItemText cc secondItemId "All 3 uses redeemed"
|
|
readItemText cc firstItemId >>= (`shouldSatisfy` T.isInfixOf "2 of 5 uses remaining")
|
|
|
|
testLanelessRedeemRefreshesTracker :: HasCallStack => TestParams -> IO ()
|
|
testLanelessRedeemRefreshesTracker ps =
|
|
withGroupOwner ps $ \gsKey cc _ alice -> do
|
|
offsetCodeIds cc
|
|
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
|
|
redeemOkWith gsKey cc Nothing code
|
|
-- The refresh runs before the response, so the counter is already written here.
|
|
t1 <- readItemText cc trackerItemId
|
|
t1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining"
|
|
t1 `shouldSatisfy` T.isInfixOf "last used"
|
|
redeemOkWith gsKey cc Nothing code
|
|
readItemText cc trackerItemId >>= (`shouldSatisfy` T.isInfixOf "All 2 uses redeemed")
|
|
redeemedTrackerCount cc `shouldReturn` 1
|
|
|
|
testQueuedRequestRefreshesTracker :: HasCallStack => TestParams -> IO ()
|
|
testQueuedRequestRefreshesTracker ps =
|
|
withGroupOwner ps $ \_ cc env alice -> do
|
|
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
|
|
queueRedemption cc env code
|
|
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
|
|
|
|
testQueuedRequestWithoutGroupConfig :: HasCallStack => TestParams -> IO ()
|
|
testQueuedRequestWithoutGroupConfig ps = do
|
|
svc <- prepareGroupService ps
|
|
(trackerItemId, code) <- runWithOwner svc $ \cc _ _ alice ->
|
|
(\(i, _, c) -> (i, c)) <$> issueTracked cc alice "supporter" 2
|
|
runGroupServiceAs svc Nothing $ \cc env _ -> do
|
|
queueRedemption cc env code
|
|
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
|
|
|
|
testTrackerRepost :: HasCallStack => TestParams -> IO ()
|
|
testTrackerRepost ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
backdated <- backdateTracker cc 2
|
|
redeemOk gsKey cc env code
|
|
(_, tracker1) <- waitTrackerRepost cc 2 itemId0
|
|
tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining"
|
|
readItemText cc itemId0 `shouldReturn` tracker0
|
|
sentAt <- trackerSentAt cc 2
|
|
sentAt `shouldSatisfy` (> backdated)
|
|
|
|
testRefusedEditReposted :: HasCallStack => TestParams -> IO ()
|
|
testRefusedEditReposted ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
backdateTrackerItem cc itemId0
|
|
redeemOk gsKey cc env code
|
|
(itemId1, tracker1) <- waitTrackerRepost cc 2 itemId0
|
|
tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining"
|
|
redeemOk gsKey cc env code
|
|
usedUp <- waitItemText cc itemId1 "All 2 uses redeemed"
|
|
sentItemsWithCode cc code `shouldReturn` [tracker0, usedUp]
|
|
|
|
testFailedRepostDropped :: HasCallStack => TestParams -> IO ()
|
|
testFailedRepostDropped ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
void $ backdateTracker cc 2
|
|
setBotRole cc "observer"
|
|
redeemOk gsKey cc env code
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` [tracker0]
|
|
readItemText cc itemId0 `shouldReturn` tracker0
|
|
trackerAnchor cc 2 `shouldReturn` Just itemId0
|
|
setBotRole cc "owner"
|
|
redeemOk gsKey cc env code
|
|
(_, tracker1) <- waitTrackerRepost cc 2 itemId0
|
|
tracker1 `shouldSatisfy` T.isInfixOf "All 2 uses redeemed"
|
|
|
|
testDeletedTrackerNotReposted :: HasCallStack => TestParams -> IO ()
|
|
testDeletedTrackerNotReposted ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, _, code) <- issueTracked cc alice "supporter" 2
|
|
deleteItem cc itemId0
|
|
readItemText cc itemId0 `shouldReturn` ""
|
|
trackerNeverReposted gsKey cc env code itemId0 []
|
|
|
|
testDeletedTrackerNotRepostedOnRevoke :: HasCallStack => TestParams -> IO ()
|
|
testDeletedTrackerNotRepostedOnRevoke ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
(itemId0, _, code) <- issueTracked cc alice "supporter" 2
|
|
deleteItem cc itemId0
|
|
-- Past the edit window the revoke goes straight to the repost path and its guard.
|
|
void $ backdateTracker cc 2
|
|
revokeNeverReposts cc alice code []
|
|
|
|
-- Both claims fall inside the edit window, so each edit fails because the message is gone.
|
|
testDeletedTrackerUsedUp :: HasCallStack => TestParams -> IO ()
|
|
testDeletedTrackerUsedUp ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, _, code) <- issueTracked cc alice "supporter" 2
|
|
deleteItem cc itemId0
|
|
replicateM_ 2 $ redeemOk gsKey cc env code
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` []
|
|
|
|
testModeratedTrackerNotRepostedOnRevoke :: HasCallStack => TestParams -> IO ()
|
|
testModeratedTrackerNotRepostedOnRevoke ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
moderateBotItem alice tracker0
|
|
void $ waitModeratedItem cc itemId0
|
|
revokeNeverReposts cc alice code [tracker0]
|
|
|
|
testModeratedTrackerNotReposted :: HasCallStack => TestParams -> IO ()
|
|
testModeratedTrackerNotReposted ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
moderateBotItem alice tracker0
|
|
(deleted, byMember, content) <- waitModeratedItem cc itemId0
|
|
deleted `shouldBe` moderatedMark
|
|
byMember `shouldSatisfy` isJust
|
|
content `shouldSatisfy` T.isInfixOf code
|
|
readItemText cc itemId0 `shouldReturn` tracker0
|
|
trackerNeverReposted gsKey cc env code itemId0 [tracker0]
|
|
|
|
-- This is the item_deleted value the core writes for a message a member deleted for everyone.
|
|
moderatedMark :: Int
|
|
moderatedMark = 1
|
|
|
|
-- The code must be the 2-use tracked one: two claims, the second past the edit window and using it up,
|
|
-- leave the code's bot items as they were.
|
|
trackerNeverReposted :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> Int64 -> [Text] -> IO ()
|
|
trackerNeverReposted key cc env code itemId0 codeItems = do
|
|
redeemOk key cc env code
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` codeItems
|
|
trackerAnchor cc 2 `shouldReturn` Just itemId0
|
|
void $ backdateTracker cc 2
|
|
redeemOk key cc env code
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` codeItems
|
|
trackerAnchor cc 2 `shouldReturn` Just itemId0
|
|
|
|
revokeNeverReposts :: HasCallStack => ChatController -> TestCC -> Text -> [Text] -> IO ()
|
|
revokeNeverReposts cc member code codeItems = do
|
|
revokeInGroup cc member code "Revoked."
|
|
sentItemsWithCode cc code `shouldReturn` codeItems <> [revokeReply code "Revoked."]
|
|
revokedTrackerCount cc `shouldReturn` 0
|
|
|
|
testGroupRevoke :: HasCallStack => TestParams -> IO ()
|
|
testGroupRevoke ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
code <- extractCode <$> replyTo cc alice "/issue supporter"
|
|
other <- extractCode <$> replyTo cc alice "/issue legend"
|
|
revokeInGroup cc alice code "Revoked."
|
|
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid)
|
|
redeemOk gsKey cc env other
|
|
revokeInGroup cc alice code "Already revoked."
|
|
-- The reply follows the tracker step, so the revoke has already passed it here.
|
|
revokedTrackerCount cc `shouldReturn` 0
|
|
unissued <- formatBadgeCode <$> randomBadgeCode (random cc)
|
|
revokeInGroup cc alice unissued "No such code."
|
|
|
|
testGroupRevokeRepairsTracker :: HasCallStack => TestParams -> IO ()
|
|
testGroupRevokeRepairsTracker ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
offsetCodeIds cc
|
|
(trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
revokeInStore cc 2
|
|
readItemText cc trackerItemId `shouldReturn` tracker0
|
|
sendGroupCmd alice (revokeCmd code)
|
|
retired <- waitItemText cc trackerItemId "Revoked"
|
|
retired `shouldBe` retiredText code
|
|
waitStoredItem cc (revokeReply code "Already revoked.")
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
|
|
testRevokeRepeatPastWindow :: HasCallStack => TestParams -> IO ()
|
|
testRevokeRepeatPastWindow ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
offsetCodeIds cc
|
|
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
void $ backdateTracker cc 2
|
|
revokeInGroup cc alice code "Revoked."
|
|
(itemId1, retired) <- waitTrackerRepost cc 2 itemId0
|
|
retired `shouldBe` retiredText code
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
void $ backdateTracker cc 2
|
|
revokeInGroup cc alice code "Already revoked."
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
trackerAnchor cc 2 `shouldReturn` Just itemId1
|
|
readItemText cc itemId1 `shouldReturn` retiredText code
|
|
readItemText cc itemId0 `shouldReturn` tracker0
|
|
|
|
testUsedUpTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
|
|
testUsedUpTrackerReconciledOnRestart ps = do
|
|
svc@GroupSvc {gsKey} <- prepareGroupService ps
|
|
(lostItemId, lostCode) <- runWithOwner svc $ \cc env _ alice -> do
|
|
(doneItemId, _, doneCode) <- issueTracked cc alice "supporter" 2
|
|
redeemOk gsKey cc env doneCode
|
|
redeemOk gsKey cc env doneCode
|
|
void $ waitItemText cc doneItemId "All 2 uses redeemed"
|
|
(lostItemId, lost0, lostCode) <- issueTracked cc alice "legend" 3
|
|
replicateM_ 3 (redeemLosingRefresh gsKey cc lostCode)
|
|
readItemText cc lostItemId `shouldReturn` lost0
|
|
trackerAnchor cc 2 `shouldReturn` Just doneItemId
|
|
dateRedemption cc 3 lostRedeemedAt
|
|
pure (lostItemId, lostCode)
|
|
runGroupService svc $ \cc env _ -> do
|
|
corrected <- waitItemText cc lostItemId "All 3 uses redeemed"
|
|
corrected `shouldBe` (lostCode <> "\nAll 3 uses redeemed, last used 2026-02-28 23:50 UTC")
|
|
awaitLane cc env
|
|
sentItemsWithCode cc lostCode `shouldReturn` [corrected]
|
|
redeemedTrackerCount cc `shouldReturn` 2
|
|
|
|
-- This date is far from any day the test runs on, so a body dated now cannot match it.
|
|
lostRedeemedAt :: UTCTime
|
|
lostRedeemedAt = UTCTime (fromGregorian 2026 2 28) (23 * 3600 + 50 * 60)
|
|
|
|
testStalledTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
|
|
testStalledTrackerReconciledOnRestart ps = do
|
|
svc@GroupSvc {gsKey} <- prepareGroupService ps
|
|
(stalledItemId, code) <- runWithOwner svc $ \cc env _ alice -> do
|
|
(stalledItemId, _, code) <- issueTracked cc alice "supporter" 5
|
|
redeemOk gsKey cc env code
|
|
redeemOk gsKey cc env code
|
|
awaitLane cc env
|
|
stalled <- waitItemText cc stalledItemId "3 of 5 uses remaining"
|
|
redeemLosingRefresh gsKey cc code
|
|
readItemText cc stalledItemId `shouldReturn` stalled
|
|
dateRedemption cc 5 lostRedeemedAt
|
|
pure (stalledItemId, code)
|
|
runGroupService svc $ \cc env _ -> do
|
|
corrected <- waitItemText cc stalledItemId "2 of 5 uses remaining"
|
|
corrected `shouldBe` ("!2 " <> code <> "!\n2 of 5 uses remaining, last used 2026-02-28 23:50 UTC")
|
|
awaitLane cc env
|
|
sentItemsWithCode cc code `shouldReturn` [corrected]
|
|
|
|
testRevokedTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
|
|
testRevokedTrackerReconciledOnRestart ps = do
|
|
svc <- prepareGroupService ps
|
|
(trackerItemId, code) <- runWithOwner svc $ \cc _ _ alice -> do
|
|
(trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
revokeInStore cc 2
|
|
readItemText cc trackerItemId `shouldReturn` tracker0
|
|
pure (trackerItemId, code)
|
|
runGroupService svc $ \cc env _ -> do
|
|
retired <- waitItemText cc trackerItemId "Revoked"
|
|
retired `shouldBe` retiredText code
|
|
awaitLane cc env
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
|
|
testEveryStalledTrackerReconciled :: HasCallStack => TestParams -> IO ()
|
|
testEveryStalledTrackerReconciled ps = do
|
|
svc@GroupSvc {gsKey} <- prepareGroupService ps
|
|
(firstItemId, secondItemId) <- runWithOwner svc $ \cc _ _ alice -> do
|
|
(firstItemId, first0, firstCode) <- issueTracked cc alice "supporter" 5
|
|
replicateM_ 2 (redeemLosingRefresh gsKey cc firstCode)
|
|
(secondItemId, second0, secondCode) <- issueTracked cc alice "legend" 3
|
|
redeemLosingRefresh gsKey cc secondCode
|
|
readItemText cc firstItemId `shouldReturn` first0
|
|
readItemText cc secondItemId `shouldReturn` second0
|
|
pure (firstItemId, secondItemId)
|
|
runGroupService svc $ \cc env _ -> do
|
|
first1 <- waitItemText cc firstItemId "3 of 5 uses remaining"
|
|
first1 `shouldSatisfy` T.isInfixOf "last used "
|
|
second1 <- waitItemText cc secondItemId "2 of 3 uses remaining"
|
|
second1 `shouldSatisfy` T.isInfixOf "last used "
|
|
awaitLane cc env
|
|
redeemedTrackerCount cc `shouldReturn` 2
|
|
|
|
testUneditableTrackerLeftAlone :: HasCallStack => TestParams -> IO ()
|
|
testUneditableTrackerLeftAlone ps = do
|
|
svc@GroupSvc {gsKey} <- prepareGroupService ps
|
|
(staleItemId, stale0, staleCode, liveItemId, liveCode) <- runWithOwner svc $ \cc _ _ alice -> do
|
|
(staleItemId, stale0, staleCode) <- issueTracked cc alice "supporter" 2
|
|
redeemLosingRefresh gsKey cc staleCode
|
|
backdateTrackerItem cc staleItemId
|
|
readItemText cc staleItemId `shouldReturn` stale0
|
|
-- The lane drains only after the pass returns, so this code's update marks the pass as done.
|
|
(liveItemId, _, liveCode) <- issueTracked cc alice "legend" 3
|
|
pure (staleItemId, stale0, staleCode, liveItemId, liveCode)
|
|
runGroupService svc $ \cc env _ -> do
|
|
redeemOk gsKey cc env liveCode
|
|
void $ waitItemText cc liveItemId "2 of 3 uses remaining"
|
|
readItemText cc staleItemId `shouldReturn` stale0
|
|
sentItemsWithCode cc staleCode `shouldReturn` [stale0]
|
|
trackerAnchor cc 2 `shouldReturn` Just staleItemId
|
|
|
|
testRevokeRetiresTracker :: HasCallStack => TestParams -> IO ()
|
|
testRevokeRetiresTracker ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
offsetCodeIds cc
|
|
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
|
|
redeemOk gsKey cc env code
|
|
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
|
|
revokeInGroup cc alice code "Revoked."
|
|
retired <- waitItemText cc trackerItemId "Revoked"
|
|
retired `shouldBe` retiredText code
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
replayRefresh cc env code 2
|
|
awaitLane cc env
|
|
readItemText cc trackerItemId `shouldReturn` retired
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid)
|
|
|
|
testServiceRevokeRetiresTracker :: HasCallStack => TestParams -> IO ()
|
|
testServiceRevokeRetiresTracker ps =
|
|
withGroupOwner ps $ \gsKey cc env alice -> do
|
|
offsetCodeIds cc
|
|
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
|
|
redeemOk gsKey cc env code
|
|
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
|
|
revokeRaw cc code `shouldReturn` Right "Revoked."
|
|
readItemText cc trackerItemId `shouldReturn` retiredText code
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
revokeRaw cc code `shouldReturn` Right "Already revoked."
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
single <- extractCode <$> replyTo cc alice "/issue legend"
|
|
revokeRaw cc single `shouldReturn` Right "Revoked."
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
|
|
testServiceRevokeRepairsTracker :: HasCallStack => TestParams -> IO ()
|
|
testServiceRevokeRepairsTracker ps =
|
|
withGroupOwner ps $ \_ cc _ alice -> do
|
|
offsetCodeIds cc
|
|
(trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2
|
|
revokeInStore cc 2
|
|
readItemText cc trackerItemId `shouldReturn` tracker0
|
|
revokeRaw cc code `shouldReturn` Right "Already revoked."
|
|
readItemText cc trackerItemId `shouldReturn` retiredText code
|
|
revokedTrackerCount cc `shouldReturn` 1
|
|
|
|
-- A discarded code makes later code ids differ from the group id, so swapped arguments fail.
|
|
offsetCodeIds :: HasCallStack => ChatController -> IO ()
|
|
offsetCodeIds cc = void $ issueCode cc BTSupporter 1
|
|
|
|
redeemOk :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> IO ()
|
|
redeemOk key cc env = redeemOkWith key cc (Just (groupEventQ env))
|
|
|
|
redeemOkWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Text -> IO ()
|
|
redeemOkWith key cc trackerQ_ code = do
|
|
r <- redeemWithQueue key cc trackerQ_ code
|
|
credentialOf r `shouldSatisfy` isJust
|
|
|
|
redeemViaService :: HasCallStack => BadgeIssuerKey -> ChatController -> ServiceState -> Text -> IO BadgeServiceResponse
|
|
redeemViaService key cc env = redeemWithQueue key cc (Just (groupEventQ env))
|
|
|
|
-- The reply to the made-up request id fails after the redemption, and the service only logs it.
|
|
queueRedemption :: HasCallStack => ChatController -> ServiceState -> Text -> IO ()
|
|
queueRedemption cc env codeText = do
|
|
user <- readTVarIO (currentUser cc) >>= maybe (error "no current user") pure
|
|
(purchaseKey, masterKey) <- newPurchaseKeys
|
|
let request = requestObject purchaseKey BSCRedeemBadgeCode {masterKey, code = codeText}
|
|
atomically $ writeTQueue (serviceRequestQ env) (user, AgentInvId "test-request", Just purchaseKey, request)
|
|
|
|
redeemLosingRefresh :: HasCallStack => BadgeIssuerKey -> ChatController -> Text -> IO ()
|
|
redeemLosingRefresh key cc codeText = newTQueueIO >>= \q -> redeemOkWith key cc (Just q) codeText
|
|
|
|
replayRefresh :: HasCallStack => ChatController -> ServiceState -> Text -> Int -> IO ()
|
|
replayRefresh cc env codeText uses = case parseBadgeCode codeText of
|
|
Nothing -> error $ "not a badge code: " <> T.unpack codeText
|
|
Just code -> do
|
|
badgeCodeId <- trackedCodeId cc uses
|
|
atomically $ writeTQueue (groupEventQ env) (GETracker badgeCodeId code)
|
|
|
|
getStoredGroup :: ChatController -> IO (Maybe ManagedGroup)
|
|
getStoredGroup cc = withTransaction (chatStore cc) getManagedGroup
|
|
|
|
shortLinkContactType :: Text -> Either String ContactConnType
|
|
shortLinkContactType linkText = case strDecode (encodeUtf8 linkText) of
|
|
Right (ACSL _ (CSLContact _ ct _ _)) -> Right ct
|
|
Right (ACSL _ CSLInvitation {}) -> Left "invitation short link"
|
|
Left e -> Left e
|
|
|
|
groupLink :: HasCallStack => ChatController -> IO String
|
|
groupLink cc = T.unpack . mgGroupLink <$> storedGroup cc
|
|
|
|
storedGroup :: HasCallStack => ChatController -> IO ManagedGroup
|
|
storedGroup cc = getStoredGroup cc >>= maybe (error "no managed group recorded") pure
|
|
|
|
groupCount :: ChatController -> IO Int
|
|
groupCount cc = countRows cc "groups"
|
|
|
|
-- The lane runs events in order, so once this command's code exists every event queued before it is done.
|
|
awaitLane :: HasCallStack => ChatController -> ServiceState -> IO ()
|
|
awaitLane cc env = do
|
|
ManagedGroup {mgGroupId} <- storedGroup cc
|
|
issued <- codeCount cc
|
|
atomically $ writeTQueue (groupEventQ env) (GEInGroup mgGroupId (GACommand GROwner "/issue supporter"))
|
|
pollUntilTrue $ (> issued) <$> codeCount cc
|
|
|
|
-- Saving a tracker's item id is the lane's last write for its command, so the lane is idle after it.
|
|
settleLane :: HasCallStack => ChatController -> ServiceState -> Int64 -> IO ()
|
|
settleLane cc env gid = do
|
|
anchoredBefore <- anchored
|
|
atomically $ writeTQueue (groupEventQ env) (GEInGroup gid (GACommand GROwner ("/issue supporter uses " <> tshow settleUses)))
|
|
pollUntilTrue $ (> anchoredBefore) <$> anchored
|
|
where
|
|
anchored = countRows cc ("sx_badge_service_badge_codes WHERE redeem_limit = " <> show settleUses <> " AND group_item_id IS NOT NULL")
|
|
|
|
-- No test tracks a code with this many uses, so the settling codes never match a test's lookup.
|
|
settleUses :: Int
|
|
settleUses = maxUses
|
|
|
|
codeCount :: ChatController -> IO Int
|
|
codeCount cc = countRows cc "sx_badge_service_badge_codes"
|
|
|
|
freeCodeCount :: ChatController -> IO Int
|
|
freeCodeCount cc = countRows cc "sx_badge_service_badge_codes WHERE code_payment_status = 'free'"
|
|
|
|
singleUseCodeCount :: ChatController -> IO Int
|
|
singleUseCodeCount cc = countRows cc "sx_badge_service_badge_codes WHERE redeem_limit = 1"
|
|
|
|
countRows :: ChatController -> String -> IO Int
|
|
countRows cc fromWhere = fromMaybe 0 <$> queryFirst cc ("SELECT COUNT(*) FROM " <> fromWhere)
|
|
|
|
-- A test finds each tracked code by its number of uses, so no two of its codes may share one.
|
|
trackedCodeColumn :: (HasCallStack, DB.FromField a) => ChatController -> Int -> String -> IO a
|
|
trackedCodeColumn cc uses column =
|
|
queryOne cc ("no code issued with " <> show uses <> " uses") $
|
|
"SELECT " <> column <> " FROM sx_badge_service_badge_codes WHERE redeem_limit = " <> show uses
|
|
|
|
trackedCodeId :: HasCallStack => ChatController -> Int -> IO Int64
|
|
trackedCodeId cc uses = trackedCodeColumn cc uses "badge_code_id"
|
|
|
|
queryRows :: FromRow r => ChatController -> String -> IO [r]
|
|
queryRows cc sql = withTransaction (chatStore cc) (\db -> DB.query_ db (fromString sql))
|
|
|
|
executeSql :: ChatController -> String -> IO ()
|
|
executeSql cc sql = withTransaction (chatStore cc) (\db -> DB.execute_ db (fromString sql))
|
|
|
|
queryColumn :: DB.FromField a => ChatController -> String -> IO [a]
|
|
queryColumn cc sql = map fromOnly <$> queryRows cc sql
|
|
|
|
queryFirst :: DB.FromField a => ChatController -> String -> IO (Maybe a)
|
|
queryFirst cc sql = listToMaybe <$> queryColumn cc sql
|
|
|
|
queryOne :: (HasCallStack, DB.FromField a) => ChatController -> String -> String -> IO a
|
|
queryOne cc err sql = queryColumn cc sql >>= maybe (error err) pure . singleRow sql
|
|
|
|
singleRow :: HasCallStack => String -> [a] -> Maybe a
|
|
singleRow sql = \case
|
|
[] -> Nothing
|
|
[r] -> Just r
|
|
_ -> error $ "more than one row from: " <> sql
|
|
|
|
-- The list is spelled out rather than taken from the service, so a change there fails here.
|
|
expectedCommands :: [ChatBotCommand]
|
|
expectedCommands =
|
|
[ CBCCommand "issue" "Generate a badge code" (Just "<type> [months <M>] [uses <N>]"),
|
|
CBCCommand "bulk" "Generate many single-use codes" (Just "<type> [months <M>] count <B>"),
|
|
CBCCommand "revoke" "Revoke a code" (Just "<code>")
|
|
]
|
|
|
|
advertisedCommands :: HasCallStack => ChatController -> Int64 -> IO (Maybe [ChatBotCommand])
|
|
advertisedCommands cc gid =
|
|
(>>= commands_)
|
|
<$> (queryOne cc "no group profile to read commands from" (groupProfileSql "p.preferences" gid) :: IO (Maybe GroupPreferences))
|
|
|
|
profileDisplayName :: HasCallStack => ChatController -> Int64 -> IO Text
|
|
profileDisplayName cc gid =
|
|
queryOne cc "no group profile to read a display name from" (groupProfileSql "p.display_name" gid)
|
|
|
|
profileFullNameShortDescr :: HasCallStack => ChatController -> Int64 -> IO (Text, Maybe Text)
|
|
profileFullNameShortDescr cc gid =
|
|
queryRows cc sql >>= maybe (error "no group profile to read a full name from") pure . singleRow sql
|
|
where
|
|
sql = groupProfileSql "p.full_name, p.short_descr" gid
|
|
|
|
profileDescription :: HasCallStack => ChatController -> Int64 -> IO (Maybe Text)
|
|
profileDescription cc gid = queryOne cc "no group profile to read a description from" (groupProfileSql "p.description" gid)
|
|
|
|
profileUpdatedAt :: HasCallStack => ChatController -> Int64 -> IO UTCTime
|
|
profileUpdatedAt cc gid =
|
|
queryOne cc "no group profile to read an update time from" (groupProfileSql "p.updated_at" gid)
|
|
|
|
groupFeaturePreference :: HasCallStack => ChatController -> Int64 -> SGroupFeature f -> IO (GroupFeaturePreference f)
|
|
groupFeaturePreference cc gid feature =
|
|
getGroupPreference feature
|
|
<$> (queryOne cc "no group profile to read preferences from" (groupProfileSql "p.preferences" gid) :: IO (Maybe GroupPreferences))
|
|
|
|
groupProfileSql :: String -> Int64 -> String
|
|
groupProfileSql columns gid =
|
|
"SELECT "
|
|
<> columns
|
|
<> " FROM group_profiles p JOIN groups g ON g.group_profile_id = p.group_profile_id WHERE g.group_id = "
|
|
<> show gid
|
|
|
|
clearCommandsSettingFullDelete :: ChatController -> Int64 -> IO ()
|
|
clearCommandsSettingFullDelete cc gid =
|
|
withTransaction (chatStore cc) $ \db ->
|
|
DB.execute
|
|
db
|
|
"UPDATE group_profiles SET preferences = ? WHERE group_profile_id IN (SELECT group_profile_id FROM groups WHERE group_id = ?)"
|
|
(fullDeleteOnly, gid)
|
|
where
|
|
fullDeleteOnly =
|
|
emptyGroupPrefs {fullDelete = Just FullDeleteGroupPreference {enable = FEOn, role = Nothing}} :: GroupPreferences
|
|
|
|
revokeInStore :: HasCallStack => ChatController -> Int -> IO ()
|
|
revokeInStore cc uses = getCurrentTime >>= setTrackedCodeTime cc uses "revoked_at"
|
|
|
|
backdateTracker :: HasCallStack => ChatController -> Int -> IO UTCTime
|
|
backdateTracker cc uses = do
|
|
past <- addUTCTime pastEditWindow <$> getCurrentTime
|
|
setTrackedCodeTime cc uses "group_item_sent_at" past
|
|
pure past
|
|
|
|
-- This is an hour past the core's 24-hour edit window.
|
|
pastEditWindow :: NominalDiffTime
|
|
pastEditWindow = -25 * 3600
|
|
|
|
-- The code row keeps its sent time, so the service still tries an edit the core refuses.
|
|
backdateTrackerItem :: ChatController -> Int64 -> IO ()
|
|
backdateTrackerItem cc citemId = do
|
|
past <- addUTCTime pastEditWindow <$> getCurrentTime
|
|
withTransaction (chatStore cc) $ \db ->
|
|
DB.execute db "UPDATE chat_items SET item_ts = ? WHERE chat_item_id = ?" (past, citemId)
|
|
|
|
-- Below author the core refuses every send from the bot.
|
|
setBotRole :: ChatController -> Text -> IO ()
|
|
setBotRole cc role =
|
|
withTransaction (chatStore cc) $ \db ->
|
|
DB.execute db "UPDATE group_members SET member_role = ? WHERE member_category = ?" (role, "user" :: Text)
|
|
|
|
dateRedemption :: HasCallStack => ChatController -> Int -> UTCTime -> IO ()
|
|
dateRedemption cc uses = setTrackedCodeTime cc uses "redeemed_at"
|
|
|
|
setTrackedCodeTime :: HasCallStack => ChatController -> Int -> String -> UTCTime -> IO ()
|
|
setTrackedCodeTime cc uses column ts = do
|
|
badgeCodeId <- trackedCodeId cc uses
|
|
withTransaction (chatStore cc) $ \db ->
|
|
DB.execute db (fromString $ "UPDATE sx_badge_service_badge_codes SET " <> column <> " = ? WHERE badge_code_id = ?") (ts, badgeCodeId)
|
|
|
|
-- Member messages are excluded because a /revoke names the code too.
|
|
sentItemsWithCode :: ChatController -> Text -> IO [Text]
|
|
sentItemsWithCode cc code =
|
|
queryColumn cc $
|
|
"SELECT item_text FROM chat_items WHERE item_sent = 1 AND item_text LIKE '%"
|
|
<> T.unpack code
|
|
<> "%' ORDER BY chat_item_id"
|
|
|
|
-- It reads the code row, not a join, so it still answers after the message is deleted.
|
|
trackerAnchor :: HasCallStack => ChatController -> Int -> IO (Maybe Int64)
|
|
trackerAnchor cc uses = trackedCodeColumn cc uses "group_item_id"
|
|
|
|
trackerSentAt :: HasCallStack => ChatController -> Int -> IO UTCTime
|
|
trackerSentAt cc uses = trackedCodeColumn cc uses "group_item_sent_at"
|
|
|
|
deleteItem :: HasCallStack => ChatController -> Int64 -> IO ()
|
|
deleteItem cc citemId = do
|
|
ManagedGroup {mgGroupId} <- storedGroup cc
|
|
sendChatCmd cc (APIDeleteChatItem (ChatRef CTGroup mgGroupId Nothing) (citemId :| []) CIDMInternal) >>= \case
|
|
Right CRChatItemsDeleted {} -> pure ()
|
|
r -> error $ "deleting chat item " <> show citemId <> " failed: " <> show r
|
|
|
|
-- The member must have received the message before the command can find it.
|
|
moderateBotItem :: HasCallStack => TestCC -> Text -> IO ()
|
|
moderateBotItem member body = do
|
|
void (pollUntil (queryFirst (chatController member) received) :: IO Int64)
|
|
send member ("\\\\ #" <> groupName <> " @" <> botName <> " " <> T.unpack header)
|
|
where
|
|
header = T.takeWhile (/= '\n') body
|
|
received = "SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND item_text LIKE '" <> T.unpack header <> "%'"
|
|
|
|
waitModeratedItem :: HasCallStack => ChatController -> Int64 -> IO (Int, Maybe Int64, Text)
|
|
waitModeratedItem cc citemId =
|
|
pollUntil $
|
|
mfilter (\(deleted, _, _) -> deleted /= 0) . listToMaybe
|
|
<$> queryRows cc ("SELECT item_deleted, item_deleted_by_group_member_id, item_content FROM chat_items WHERE chat_item_id = " <> show citemId)
|
|
|
|
memberRoles :: ChatController -> Int64 -> IO [(Text, Text)]
|
|
memberRoles cc gid =
|
|
filter ((/= badgeBotName) . fst)
|
|
<$> queryRows cc ("SELECT local_display_name, member_role FROM group_members WHERE group_id = " <> show gid <> " ORDER BY group_member_id")
|
|
|
|
readItemText :: ChatController -> Int64 -> IO Text
|
|
readItemText cc citemId = fromMaybe "" <$> firstText cc ("chat_item_id = " <> show citemId)
|
|
|
|
-- A line left unread when the test ends fails the per-core teardown check.
|
|
drainUntil :: HasCallStack => TestCC -> [String] -> IO ()
|
|
drainUntil cc markers =
|
|
timeout waitLimit (go markers) >>= \case
|
|
Just () -> pure ()
|
|
Nothing -> error $ "drainUntil: console never showed " <> show markers
|
|
where
|
|
go [] = pure ()
|
|
go unseen = do
|
|
l <- atomically $ readTQueue (termQ cc)
|
|
go $ filter (not . (`isInfixOf` l)) unseen
|
|
|
|
drainConsole :: TestCC -> IO ()
|
|
drainConsole cc =
|
|
timeout quietPeriod (atomically (readTQueue (termQ cc))) >>= \case
|
|
Just _ -> drainConsole cc
|
|
Nothing -> pure ()
|
|
|
|
-- A console silent this long is taken as drained.
|
|
quietPeriod :: Int
|
|
quietPeriod = 500000
|
|
|
|
pollInterval :: Int
|
|
pollInterval = 200000
|
|
|
|
maxPolls :: Int
|
|
maxPolls = 150
|
|
|
|
waitLimit :: Int
|
|
waitLimit = maxPolls * pollInterval
|
|
|
|
pollUntil :: HasCallStack => IO (Maybe a) -> IO a
|
|
pollUntil act = go maxPolls
|
|
where
|
|
go n =
|
|
act >>= \case
|
|
Just a -> pure a
|
|
Nothing
|
|
| n <= (0 :: Int) -> error "pollUntil: timed out waiting for chat state"
|
|
| otherwise -> threadDelay pollInterval >> go (n - 1)
|
|
|
|
pollUntilTrue :: HasCallStack => IO Bool -> IO ()
|
|
pollUntilTrue cond = pollUntil (guard <$> cond)
|
|
|
|
waitMemberRole :: HasCallStack => ChatController -> Int64 -> Text -> Text -> IO ()
|
|
waitMemberRole cc gid name role = pollUntilTrue $ elem (name, role) <$> memberRoles cc gid
|
|
|
|
setMemberRole :: HasCallStack => ChatController -> Int64 -> Text -> Text -> IO ()
|
|
setMemberRole cc gid name role = do
|
|
sendChatCmdStr cc ("/mr #" <> groupName <> " " <> T.unpack name <> " " <> T.unpack role) >>= (`shouldSatisfy` isRight)
|
|
waitMemberRole cc gid name role
|
|
|
|
waitMemberBlocked :: HasCallStack => ChatController -> Int64 -> Text -> IO ()
|
|
waitMemberBlocked cc gid name =
|
|
pollUntilTrue $ (== Just (Just ("blocked" :: Text))) <$> queryFirst cc restrictionSql
|
|
where
|
|
restrictionSql =
|
|
"SELECT member_restriction FROM group_members WHERE group_id = "
|
|
<> show gid
|
|
<> " AND local_display_name = '"
|
|
<> T.unpack name
|
|
<> "'"
|
|
|
|
waitBlockedItem :: HasCallStack => ChatController -> Text -> IO (Int, Text)
|
|
waitBlockedItem cc txt =
|
|
pollUntil $
|
|
mfilter ((/= 0) . fst) . listToMaybe
|
|
<$> queryRows cc ("SELECT item_deleted, item_content FROM chat_items WHERE item_sent = 0 AND item_text = '" <> T.unpack txt <> "'")
|
|
|
|
waitAdvertisedCommands :: HasCallStack => ChatController -> Int64 -> IO ()
|
|
waitAdvertisedCommands cc gid = pollUntilTrue $ (== Just expectedCommands) <$> advertisedCommands cc gid
|
|
|
|
waitStoredItem :: HasCallStack => ChatController -> Text -> IO ()
|
|
waitStoredItem cc txt = void . pollUntil $ firstText cc ("item_text = '" <> T.unpack txt <> "'")
|
|
|
|
-- The reply is the first item the service sends after the command arrives.
|
|
replyTo :: HasCallStack => ChatController -> TestCC -> String -> IO Text
|
|
replyTo cc member cmd = do
|
|
lastId <- maxChatItemId cc
|
|
sendGroupCmd member cmd
|
|
waitReceivedItemId cc lastId (T.pack cmd) >>= waitReplyAfter cc
|
|
|
|
maxChatItemId :: ChatController -> IO Int64
|
|
maxChatItemId cc = fromMaybe 0 <$> queryFirst cc "SELECT COALESCE(MAX(chat_item_id), 0) FROM chat_items"
|
|
|
|
waitReceivedItemId :: HasCallStack => ChatController -> Int64 -> Text -> IO Int64
|
|
waitReceivedItemId cc afterId txt =
|
|
pollUntil . queryFirst cc $
|
|
"SELECT chat_item_id FROM chat_items WHERE item_sent = 0 AND chat_item_id > " <> show afterId <> " AND item_text = '" <> T.unpack txt <> "'"
|
|
|
|
waitReplyAfter :: HasCallStack => ChatController -> Int64 -> IO Text
|
|
waitReplyAfter cc afterId =
|
|
pollUntil . firstText cc $ "item_sent = 1 AND chat_item_id > " <> show afterId <> " ORDER BY chat_item_id LIMIT 1"
|
|
|
|
waitItemText :: HasCallStack => ChatController -> Int64 -> Text -> IO Text
|
|
waitItemText cc citemId marker =
|
|
pollUntil $ mfilter (marker `T.isInfixOf`) . Just <$> readItemText cc citemId
|
|
|
|
waitCodeOfType :: HasCallStack => ChatController -> Text -> IO ()
|
|
waitCodeOfType cc badgeType =
|
|
pollUntilTrue $ (> 0) <$> countRows cc ("sx_badge_service_badge_codes WHERE badge_type = '" <> T.unpack badgeType <> "'")
|
|
|
|
waitCodeMonths :: HasCallStack => ChatController -> Text -> IO Int
|
|
waitCodeMonths cc badgeType =
|
|
pollUntil $ do
|
|
months <- queryColumn cc $ "SELECT months FROM sx_badge_service_badge_codes WHERE badge_type = '" <> T.unpack badgeType <> "'"
|
|
pure $ case months of
|
|
[m] -> Just m
|
|
_ -> Nothing
|
|
|
|
firstText :: ChatController -> String -> IO (Maybe Text)
|
|
firstText cc cond = queryFirst cc ("SELECT item_text FROM chat_items WHERE " <> cond)
|
|
|
|
trackerItem :: HasCallStack => ChatController -> Int -> IO (Maybe (Int64, Text))
|
|
trackerItem cc uses = singleRow sql <$> queryRows cc sql
|
|
where
|
|
sql =
|
|
"SELECT c.group_item_id, ci.item_text "
|
|
<> "FROM sx_badge_service_badge_codes c "
|
|
<> "JOIN chat_items ci ON ci.chat_item_id = c.group_item_id "
|
|
<> "WHERE c.redeem_limit = "
|
|
<> show uses
|
|
|
|
waitTrackerItemOf :: HasCallStack => ChatController -> Int -> IO (Int64, Text)
|
|
waitTrackerItemOf cc = pollUntil . trackerItem cc
|
|
|
|
waitTrackerRepost :: HasCallStack => ChatController -> Int -> Int64 -> IO (Int64, Text)
|
|
waitTrackerRepost cc uses itemId0 = pollUntil $ mfilter ((/= itemId0) . fst) <$> trackerItem cc uses
|
|
|
|
redeemedTrackerCount :: ChatController -> IO Int
|
|
redeemedTrackerCount cc = countRows cc "chat_items WHERE item_text LIKE '%last used%'"
|
|
|
|
revokedTrackerCount :: ChatController -> IO Int
|
|
revokedTrackerCount cc = countRows cc ("chat_items WHERE item_text LIKE '" <> T.unpack (retiredText "SB-%") <> "'")
|
|
|
|
retiredText :: Text -> Text
|
|
retiredText code = code <> "\nRevoked, can no longer be redeemed"
|
|
|
|
extractCode :: HasCallStack => Text -> Text
|
|
extractCode t = maybe (error ("no badge code in: " <> T.unpack t)) formatBadgeCode (codeInTracker t)
|