badges: rework tracker and reply texts

This commit is contained in:
shum
2026-09-28 15:47:40 +00:00
parent 6b1e2bbd19
commit 1eef5dc7ad
10 changed files with 192 additions and 247 deletions
+5 -4
View File
@@ -255,7 +255,8 @@ and above, `/revoke <code>` for admins and owners. `months` is 1 to 255, `uses`
`count` 1 to 100; a value outside these gets the usage reply. A member's role is checked as the
service last saw it, so a command sent by a moderator just demoted or removed can still run if it
reaches the service first; revoke any code the service posts for them after the change. `uses`
above 1 makes a multi-use code, tracked by a group message counting what is left of it. Every reply carrying a code is read by every member,
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,
since the group has no private lane, so a code issued there is only as private as its least trusted
member.
Those replies are also kept as plain text in the service's chat database, so a copy of the database
@@ -265,9 +266,9 @@ carries the code, and every redemption edits it or, after a day, posts it again;
current member receives it, so a member who joined after the code was issued gets the code while it
still has uses left. A message replaced by a new post stays in the group with its old count. Keep
disappearing messages off in the group and set no message TTL for the service's chats: a code's
message that expires is treated as deleted and never posted again, so its counter and its "fully
redeemed" notice stop.
Every member can see when a multi-use code's message was edited, which is when each use was redeemed.
message that expires is treated as deleted and never posted again, so its counter stops.
Every member can see when each use of a multi-use code was redeemed: the message shows the time of
the last one, and its edit times show the rest.
`/revoke <code>` names the code in an ordinary group message, so every member holds it before the
service reads the command, and the code stays redeemable until the service acts on it — for the
whole of any downtime. Revoke a code that is not already public in the group, a refunded one above
@@ -30,7 +30,7 @@ revokeBadgeCode cc code = do
-- | The group is joined through a bearer link, so this reply names no database error.
issueFailedText :: Text
issueFailedText = "issuing the code failed"
issueFailedText = "The code could not be issued."
-- | The code table keeps only the hash, so the caller must deliver the code.
issueOneCode :: ChatController -> BadgeType -> Int -> BadgeCodePaymentStatus -> Int -> IO (Either String (BadgeCode, Int64))
@@ -31,7 +31,7 @@ import BadgeService.Log (logError, logInfo, logWarn)
import BadgeService.Store (CodeTracker (..), ManagedGroup (..), RevokeResult (..), clearCodeGroupItems, getCodeTracker, getEditableTrackers, getManagedGroup, insertManagedGroup, markOwnerBootstrapped, setCodeGroupItem)
import BadgeService.Store.Invoices (truncateToSecond)
import Control.Concurrent.STM (TQueue, atomically, flushTQueue, readTQueue, readTVarIO, writeTQueue)
import Control.Monad (forM_, forever, mfilter, replicateM, unless, void, when)
import Control.Monad (forM_, forever, mfilter, replicateM, unless, void)
import Control.Monad.Except (runExceptT)
import Data.Either (partitionEithers)
import Data.Functor (($>), (<&>))
@@ -40,6 +40,7 @@ import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
import qualified Data.Set as S
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay)
@@ -161,10 +162,9 @@ groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt (simplexChatContact cReq
strEncodeTxt :: StrEncoding a => a -> Text
strEncodeTxt = safeDecodeUtf8 . strEncode
-- GETracker carries the redeem count its claim reached.
data GroupEvent
= GEInGroup GroupId GroupAction
| GETracker Int64 BadgeCode Int
| GETracker Int64 BadgeCode
deriving (Eq, Show)
data GroupAction
@@ -181,16 +181,15 @@ groupEvent = \case
| isNothing scope && isNothing itemDeleted && itemLive /= Just True -> Just $ GEInGroup groupId (GACommand (memberRole' m) t)
_ -> Nothing
-- A refresh reads the code's count when it runs, so the last one queued shows every claim before it.
coalesceTrackerRefreshes :: [GroupEvent] -> [GroupEvent]
coalesceTrackerRefreshes evs = snd $ foldr keepOne (highestClaims, []) evs
coalesceTrackerRefreshes = snd . foldr keepLast (S.empty, [])
where
-- The highest claim decides the exhaustion notice, whatever order the claims were queued in.
highestClaims = M.fromListWith max [(badgeCodeId, n) | GETracker badgeCodeId _ n <- evs]
keepOne ev (todo, kept) = case ev of
GETracker badgeCodeId code _ -> case M.lookup badgeCodeId todo of
Just n -> (M.delete badgeCodeId todo, GETracker badgeCodeId code n : kept)
Nothing -> (todo, kept)
_ -> (todo, ev : kept)
keepLast ev (seen, kept) = case ev of
GETracker badgeCodeId _
| badgeCodeId `S.member` seen -> (seen, kept)
| otherwise -> (S.insert badgeCodeId seen, ev : kept)
_ -> (seen, ev : kept)
logUncaught :: HasCallStack => IO () -> IO ()
logUncaught a = a `catchOwn'` withFrozenCallStack (logError . tshow)
@@ -207,7 +206,7 @@ handleGroupEvent cc groupId ev = logUncaught (handle ev)
RunCmd cmd -> runGroupCmd cc groupId cmd
ReplyText txt -> void $ sendGroupText cc groupId "command reply" txt
IgnoreMsg -> pure ()
GETracker badgeCodeId code claimedCount -> updateTracker cc groupId badgeCodeId code claimedCount
GETracker badgeCodeId code -> updateTracker cc groupId badgeCodeId code
promoteFirstOwner :: ChatController -> GroupInfo -> GroupMember -> IO ()
promoteFirstOwner cc g@GroupInfo {groupId} member =
@@ -262,7 +261,7 @@ runGroupCmd cc groupId = \case
issueOneCode cc bt months CPSFree uses >>= \case
Left _ -> reply issueFailedText
Right (code, badgeCodeId)
| not (hasTracker uses) -> reply ("code " <> formatBadgeCode code)
| not (hasTracker uses) -> reply ("Code: " <> formatBadgeCode code)
| otherwise -> do
now <- truncateToSecond <$> getCurrentTime
sendGroupText cc groupId ("tracker, code " <> tshow badgeCodeId <> " is lost") (initialTrackerBody code uses)
@@ -273,7 +272,7 @@ runGroupCmd cc groupId = \case
issued = map (formatBadgeCode . fst) ok
reply . T.intercalate "\n" $ case errs of
[] -> issued
_ : _ -> issued <> ["issued " <> tshow (length issued) <> " of " <> tshow count <> ", the rest failed"]
_ : _ -> issued <> ["Issued " <> tshow (length issued) <> " of " <> tshow count <> " codes. The remaining codes could not be issued."]
-- Naming the code tells concurrent revokes apart; the command already made it public.
GCRevoke code -> do
outcome <- either id id <$> revokeWithTracker cc code
@@ -292,20 +291,19 @@ initialTrackerBody :: BadgeCode -> Int -> Text
initialTrackerBody code total = trackerBody code total total Nothing
-- The body is dated by the last redemption, not now, because reconcile rewrites it after a restart.
-- The code is green only while it can still be redeemed.
trackerBody :: BadgeCode -> Int -> Int -> Maybe UTCTime -> Text
trackerBody code remaining total redeemedAt =
"!2 " <> formatBadgeCode code <> "!\n" <> maybe "" lastRedeemed redeemedAt <> tshow remaining <> "/" <> tshow total <> " remaining"
trackerBody code remaining total redeemedAt
| remaining > 0 = "!2 " <> formatBadgeCode code <> "!\n" <> tshow remaining <> " of " <> tshow total <> " uses remaining" <> lastUsed
| otherwise = formatBadgeCode code <> "\nAll " <> tshow total <> " uses redeemed" <> lastUsed
where
lastRedeemed ts = "Last redeemed: " <> fmtDay ts <> " — "
exhaustedBody :: BadgeCode -> Int -> Text
exhaustedBody code total = formatBadgeCode code <> " fully redeemed — all " <> tshow total <> " used"
lastUsed = maybe "" ((", last used " <>) . fmtTime) redeemedAt
revokedBody :: BadgeCode -> Text
revokedBody code = formatBadgeCode code <> " revoked — no longer redeemable"
revokedBody code = formatBadgeCode code <> "\nRevoked, can no longer be redeemed"
fmtDay :: UTCTime -> Text
fmtDay = T.pack . formatTime defaultTimeLocale "%Y-%m-%d"
fmtTime :: UTCTime -> Text
fmtTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC"
data TrackerAction = Edit | Repost
deriving (Eq, Show)
@@ -323,45 +321,34 @@ trackerDecision now sentAt
data RepostPolicy = MayRepost | EditOnly
-- A deleted or moderated tracker is not reposted, because deleting it does not revoke the code.
-- With MayRepost the result is Nothing when there is nothing to write or the message is gone, so no notice follows.
setTrackerBody :: ChatController -> GroupId -> Int64 -> RepostPolicy -> (CodeTracker -> Maybe Text) -> IO (Maybe CodeTracker)
setTrackerBody :: ChatController -> GroupId -> Int64 -> RepostPolicy -> (CodeTracker -> Maybe Text) -> IO ()
setTrackerBody cc groupId badgeCodeId policy mkBody =
withDB' "getCodeTracker" cc (`getCodeTracker` badgeCodeId) >>= \case
Right (Just tracker@CodeTracker {trackerItemId, trackerSentAt}) ->
pure (mkBody tracker) $>>= \body -> do
forM_ (mkBody tracker) $ \body -> do
now <- truncateToSecond <$> getCurrentTime
let handled = pure (Just tracker)
repost =
let repost =
trackerItemText cc groupId trackerItemId >>= \case
Nothing -> do
logWarn $ "badge group tracker not reposted, code " <> tshow badgeCodeId <> " is no longer published"
pure Nothing
Nothing -> logWarn $ "badge group tracker not reposted, code " <> tshow badgeCodeId <> " is no longer published"
-- Past the window every write reposts, so an unchanged body is not posted again.
Just current
| current == body -> handled
| otherwise -> do
sendGroupText cc groupId ("tracker repost, code " <> tshow badgeCodeId <> " keeps its old message") body
>>= mapM_ (\i -> withDB' "setCodeGroupItem" cc (\db -> setCodeGroupItem db badgeCodeId i now))
handled
Just current ->
unless (current == body) $
sendGroupText cc groupId ("tracker repost, code " <> tshow badgeCodeId <> " keeps its old message") body
>>= mapM_ (\i -> withDB' "setCodeGroupItem" cc (\db -> setCodeGroupItem db badgeCodeId i now))
-- The core can also refuse an edit inside the window, because it uses the message's own timestamp.
uneditable = case policy of
MayRepost -> repost
EditOnly -> do
logWarn $ "badge group tracker left uncorrected, code " <> tshow badgeCodeId <> " can no longer be edited"
handled
EditOnly -> logWarn $ "badge group tracker left uncorrected, code " <> tshow badgeCodeId <> " can no longer be edited"
case trackerDecision now trackerSentAt of
Edit ->
sendChatCmd cc (APIUpdateChatItem (ChatRef CTGroup groupId Nothing) trackerItemId False (UpdatedMessage (MCText body) M.empty)) >>= \case
Right CRChatItemUpdated {} -> handled
Right CRChatItemNotChanged {} -> handled
Right CRChatItemUpdated {} -> pure ()
Right CRChatItemNotChanged {} -> pure ()
Left (ChatError CEInvalidChatItemUpdate) -> uneditable
-- Any other failure may still have applied the edit, so a repost could publish the code twice.
-- The notice still follows the claim while the tracker is published.
r -> do
logError $ "badge group tracker not updated, code " <> tshow badgeCodeId <> ": " <> tshow r
($> tracker) <$> trackerItemText cc groupId trackerItemId
r -> logError $ "badge group tracker not updated, code " <> tshow badgeCodeId <> ": " <> tshow r
Repost -> uneditable
_ -> pure Nothing
_ -> pure ()
-- A revoke and a redemption can arrive in either order, so a revoked tracker is left alone.
counterBody :: BadgeCode -> CodeTracker -> Maybe Text
@@ -374,33 +361,28 @@ hasTracker redeemLimit = redeemLimit > singleUse
-- The tracker edit reaches every member, so the group lane runs it off the request path.
-- Without a lane (--run-cli, or no [group]) it runs here, before the response.
refreshTracker :: ChatController -> Maybe (TQueue GroupEvent) -> Int64 -> BadgeCode -> Int -> IO ()
refreshTracker cc trackerQ_ badgeCodeId code claimedCount = case trackerQ_ of
Just q -> atomically $ writeTQueue q (GETracker badgeCodeId code claimedCount)
Nothing -> logUncaught $ withManagedGroup cc $ \groupId -> updateTracker cc groupId badgeCodeId code claimedCount
refreshTracker :: ChatController -> Maybe (TQueue GroupEvent) -> Int64 -> BadgeCode -> IO ()
refreshTracker cc trackerQ_ badgeCodeId code = case trackerQ_ of
Just q -> atomically $ writeTQueue q (GETracker badgeCodeId code)
Nothing -> logUncaught $ withManagedGroup cc $ \groupId -> updateTracker cc groupId badgeCodeId code
-- The notice follows the claim, not the count read now, so queued refreshes post it once.
updateTracker :: ChatController -> GroupId -> Int64 -> BadgeCode -> Int -> IO ()
updateTracker cc groupId badgeCodeId code claimedCount =
setTrackerBody cc groupId badgeCodeId MayRepost (counterBody code)
>>= mapM_ (\CodeTracker {redeemLimit} -> when (claimedCount == redeemLimit) $ postNotice redeemLimit)
where
postNotice redeemLimit = void $ sendGroupText cc groupId ("notice, code " <> tshow badgeCodeId) (exhaustedBody code redeemLimit)
updateTracker :: ChatController -> GroupId -> Int64 -> BadgeCode -> IO ()
updateTracker cc groupId badgeCodeId code = setTrackerBody cc groupId badgeCodeId MayRepost (counterBody code)
-- | Left is a refusal or a failure; both sides are the text to show whoever sent the revoke.
revokeWithTracker :: ChatController -> BadgeCode -> IO (Either Text Text)
revokeWithTracker cc code =
revokeBadgeCode cc code >>= \case
-- The message is retired before the answer, so "revoked" never sits beside a live counter.
Right (Revoked badgeCodeId) -> Right "revoked" <$ retire badgeCodeId
-- The message is retired before the answer, so the answer never sits beside a live counter.
Right (Revoked badgeCodeId) -> Right "Revoked." <$ retire badgeCodeId
-- A repeated revoke repairs a message that an earlier revoke failed to update.
Right (AlreadyRevoked badgeCodeId) -> Right "already revoked" <$ retire badgeCodeId
Right AlreadyRedeemed -> pure $ Left "code was redeemed already, so it cannot be revoked"
Right NoSuchCode -> pure $ Left "no such code"
Left _ -> pure $ Left "revoking the code failed"
Right (AlreadyRevoked badgeCodeId) -> Right "Already revoked." <$ retire badgeCodeId
Right AlreadyRedeemed -> pure $ Left "Fully redeemed. It cannot be revoked."
Right NoSuchCode -> pure $ Left "No such code."
Left _ -> pure $ Left "The code could not be revoked."
where
retire badgeCodeId = logUncaught $ withManagedGroup cc $ \groupId ->
void $ setTrackerBody cc groupId badgeCodeId MayRepost (const $ Just $ revokedBody code)
setTrackerBody cc groupId badgeCodeId MayRepost (const $ Just $ revokedBody code)
withManagedGroup :: ChatController -> (GroupId -> IO ()) -> IO ()
withManagedGroup cc action =
@@ -421,7 +403,7 @@ reconcileTracker :: ChatController -> GroupId -> Int64 -> ChatItemId -> IO ()
reconcileTracker cc groupId badgeCodeId itemId =
readTrackerCode cc groupId badgeCodeId itemId >>= mapM_ reconcile
where
reconcile (code, current) = void $ setTrackerBody cc groupId badgeCodeId EditOnly (correctedBody code current)
reconcile (code, current) = setTrackerBody cc groupId badgeCodeId EditOnly (correctedBody code current)
-- A crash after a revoke committed can leave its tracker still counting, so a revoked code is retired here.
correctedBody code current tracker@CodeTracker {revokedAt} =
mfilter (/= current) $ if isJust revokedAt then Just (revokedBody code) else counterBody code tracker
@@ -64,7 +64,7 @@ cmdActionP = A.choice (map cmdP [minBound .. maxBound])
fullArgsP tag = A.char ' ' *> cmdArgsP tag <* A.endOfInput
-- Without the space check "/issued" would get a usage reply.
usageEndP = void A.space <|> A.endOfInput
usage tag = "use: /" <> cmdName tag <> " " <> cmdParams tag
usage tag = "Usage: /" <> cmdName tag <> " " <> cmdParams tag
cmdArgsP :: CmdTag -> A.Parser GroupCmd
cmdArgsP = \case
@@ -139,4 +139,4 @@ cmdRefusal = \case
CTIssue -> Nothing
CTBulk -> Nothing
-- The sender has just published a code that stays redeemable, so they must learn it was not revoked.
CTRevoke -> Just "only admins can revoke codes, and this code is now visible to the group - ask an admin to revoke it"
CTRevoke -> Just "Only admins can revoke codes. This code is now visible to all members."
@@ -203,13 +203,13 @@ runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse
runBadgeCmd cc cmd
| Right issueOpts <- A.parseOnly issueCmdP cmd =
issueBadgeCode cc issueOpts >>= \case
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code}
Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> formatBadgeCode code}
Left _ -> pure $ chatCmdError (T.unpack issueFailedText)
| Right code <- A.parseOnly revokeCmdP cmd =
revokeWithTracker cc code <&> \case
Right response -> Right CRCustomChatResponse {user_ = Nothing, response}
Left e -> chatCmdError (T.unpack e)
| otherwise = pure $ chatCmdError $ "use: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], or //revoke <code>"
| otherwise = pure $ chatCmdError $ "Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], or //revoke <code>"
revokeCmdP :: A.Parser BadgeCode
revokeCmdP = "revoke " *> codeP <* (A.skipSpace *> A.endOfInput)
@@ -355,14 +355,14 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode
liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case
Nothing ->
readCode now code db >>= \case
Left resp -> pure (resp, Nothing)
Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, Nothing)
Just (purchaseId, claimedCount) -> liftIO $ do
Left resp -> pure (resp, False)
Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False)
Just purchaseId -> liftIO $ do
appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed
entries_ <- getLedgerEntries db purchaseId 0
pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, Just claimedCount)
let (resp, claimedCount_) = fromRight (errorResponse BSEInternal, Nothing) r
when (hasTracker redeemLimit) $ forM_ claimedCount_ $ refreshTracker cc trackerQ_ badgeCodeId code
pure (maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_, True)
let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r
when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code
pure resp
where
readCode now code db = liftIO $
@@ -4,7 +4,6 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
module BadgeService.Store
( IssuedCode (..),
@@ -38,7 +37,6 @@ module BadgeService.Store
where
import BadgeService.Store.Invoices (executeChanging)
import Control.Monad (forM)
import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
@@ -287,25 +285,26 @@ appendLedgerPlan db purchaseId rows issuance_ = do
insertedRowId db
-- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count.
-- Run it in the credential's transaction. It returns the use count this claim reached.
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe (Int64, Int))
-- Run it in the credential's transaction.
createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO (Maybe Int64)
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do
claimed_ <-
maybeFirstRow fromOnly $
DB.query
db
"UPDATE sx_badge_service_badge_codes SET redeem_count = redeem_count + 1, redeemed_at = ? WHERE badge_code_id = ? AND redeem_count < redeem_limit AND revoked_at IS NULL RETURNING redeem_count"
(now, badgeCodeId)
forM claimed_ $ \claimedCount -> do
DB.execute
claimed <-
executeChanging
db
[sql|
INSERT INTO sx_badge_service_badge_purchases
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
(,claimedCount) <$> insertedRowId db
"UPDATE sx_badge_service_badge_codes SET redeem_count = redeem_count + 1, redeemed_at = ? WHERE badge_code_id = ? AND redeem_count < redeem_limit AND revoked_at IS NULL"
(now, badgeCodeId)
if claimed == 0
then pure Nothing
else do
DB.execute
db
[sql|
INSERT INTO sx_badge_service_badge_purchases
(purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now)
Just <$> insertedRowId db
-- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker,
-- or repair one an earlier revoke left live.
+10 -10
View File
@@ -222,7 +222,7 @@ revokeRaw cc args =
issueCodeAs :: HasCallStack => ChatController -> BadgeType -> Int -> String -> IO BadgeCode
issueCodeAs cc badgeType months status =
sendChatCmdStr cc ("//issue " <> T.unpack (textEncode badgeType) <> " " <> show months <> " " <> status) >>= \case
Right (CRCustomChatResponse _ response) -> case T.stripPrefix "code " response of
Right (CRCustomChatResponse _ response) -> case T.stripPrefix "Code: " response of
Just c | Just code <- parseBadgeCode c -> pure code
_ -> error $ "unexpected issue response: " <> T.unpack response
r -> error $ "issue failed: " <> show (() <$ r)
@@ -831,7 +831,7 @@ testRevokedMultiUseHolderRenews ps =
code <- issueMultiUseCode cc BTSupporter 3 2
redeemFirstBadge alice code
redeemed <- ledgerRows (chatController alice) "badge_ledger"
revokeCodeAs cc code `shouldReturn` Right "revoked"
revokeCodeAs cc code `shouldReturn` Right "Revoked."
redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid)
codeUses cc code `shouldReturn` Just (2, 1)
-- A holder whose reply was lost retries with the same key and still gets its badge.
@@ -1518,7 +1518,7 @@ testRevokeRedeemedCode ps =
alice <## "supporter badge - active"
alice <##. "expires "
refused <- revokeCodeAs cc code
refused `shouldBe` Left "code was redeemed already, so it cannot be revoked"
refused `shouldBe` Left "Fully redeemed. It cannot be revoked."
alice ##> ("/_redeem_badge_code 1 " <> codeArg code)
alice <## "badge already redeemed"
@@ -1528,23 +1528,23 @@ testRevokeUnknownCode ps =
g <- C.newRandom
code <- randomBadgeCode g
unknown <- revokeCodeAs cc code
unknown `shouldBe` Left "no such code"
unknown `shouldBe` Left "No such code."
testRevokedCode :: HasCallStack => TestParams -> IO ()
testRevokedCode ps =
withBadgeService ps $ \clientCfg _ cc ->
withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do
paid <- issueCodeAs cc BTSupporter 1 "paid"
revokeCodeAs cc paid `shouldReturn` Right "revoked"
revokeCodeAs cc paid `shouldReturn` Right "Revoked."
alice ##> ("/_redeem_badge_code 1 " <> codeArg paid)
alice <## "cannot redeem badge code: badge service error: code_invalid"
revokeCodeAs cc paid `shouldReturn` Right "already revoked"
revokeCodeAs cc paid `shouldReturn` Right "Already revoked."
testRevokedUnpaidCode :: HasCallStack => TestParams -> IO ()
testRevokedUnpaidCode ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do
code <- issueCodeAs cc BTSupporter 1 "unpaid"
revokeCodeAs cc code `shouldReturn` Right "revoked"
revokeCodeAs cc code `shouldReturn` Right "Revoked."
redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid)
testRevokeRejectsTrailingInput :: HasCallStack => TestParams -> IO ()
@@ -1552,10 +1552,10 @@ testRevokeRejectsTrailingInput ps =
withBadgeService ps $ \_ _ cc -> do
code <- issueCodeAs cc BTSupporter 1 "paid"
refused <- revokeRaw cc (formatBadgeCode code <> " junk")
-- If the refused call revoked the code anyway, this fails with "already revoked".
revokeCodeAs cc code `shouldReturn` Right "revoked"
-- If the refused call revoked the code anyway, this fails with "Already revoked.".
revokeCodeAs cc code `shouldReturn` Right "Revoked."
refused `shouldBe` Left badgeCmdUsage
-- The text is spelled out rather than taken from the service, so a change there fails here.
badgeCmdUsage :: Text
badgeCmdUsage = "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke <code>"
badgeCmdUsage = "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke <code>"
+84 -119
View File
@@ -93,9 +93,9 @@ badgeGroupIntegrationTests = do
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 across redemptions and refuses it when used up" testMultiUseTracker
it "keeps the counter and posts no second notice for a stale refresh of a used-up code" testStaleRefreshIgnored
it "announces a second multi-use code used up on the claim that uses it up" testSecondMultiUseCodeExhausted
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
@@ -105,7 +105,7 @@ badgeGroupIntegrationTests = do
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 "posts no notice when a code whose tracker was deleted is used up" testDeletedTrackerNoNotice
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
@@ -113,7 +113,7 @@ badgeGroupIntegrationTests = do
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 an exhausted tracker whose refresh was lost, posting no notice" testExhaustedTrackerReconciledOnRestart
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
@@ -443,7 +443,7 @@ testFirstJoinerPromoted ps = do
sendGroupCmd alice "/issue supporter"
waitCodeOfType cc "supporter"
memberRoles cc gid `shouldReturn` [("alice", "owner"), ("bob", "member")]
let codeReply = "#" <> groupName <> " " <> botName <> "> code SB-"
let codeReply = "#" <> groupName <> " " <> botName <> "> Code: SB-"
drainUntil alice [codeReply]
drainUntil bob [codeReply, "#" <> groupName <> ": member alice"]
@@ -471,7 +471,7 @@ testRoleGatedIssue ps = do
let announced name = "#" <> groupName <> ": " <> botName <> " added " <> name
preMember name = "#" <> groupName <> ": member " <> name
newMember name = "#" <> groupName <> ": new member " <> name <> " is connected"
usageReply = "#" <> groupName <> " " <> botName <> "> use: /issue <type>"
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.
@@ -485,14 +485,14 @@ testRoleGatedIssue ps = do
dropTime
cath
[ StartsWith ("#" <> groupName <> " alice> /issue supporter"),
StartsWith ("#" <> groupName <> " " <> botName <> "> code SB-")
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 "use: /issue <type> [months <M>] [uses <N>]"
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"]
@@ -578,7 +578,7 @@ 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, the rest failed"]
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
@@ -587,8 +587,8 @@ testIssueFailureIsOpaque :: HasCallStack => TestParams -> IO ()
testIssueFailureIsOpaque ps =
withGroupOwner ps $ \_ cc _ alice -> do
withCodeTableHidden cc $ do
replyTo cc alice "/issue supporter" `shouldReturn` "issuing the code failed"
issueRaw cc "supporter" `shouldReturn` Left "issuing the code failed"
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
@@ -597,8 +597,8 @@ testRevokeFailureIsOpaque ps =
withGroupOwner ps $ \_ cc _ alice -> do
code <- extractCode <$> replyTo cc alice "/issue supporter"
withCodeTableHidden cc $
replyTo cc alice (revokeCmd code) `shouldReturn` revokeReply code "revoking the code failed"
revokeInGroup cc alice code "revoked"
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)
@@ -632,13 +632,13 @@ 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/2 remaining"
tracker0 `shouldSatisfy` T.isInfixOf "2 of 2 uses remaining"
redeemOk gsKey cc env code
t1 <- waitItemText cc trackerItemId "1/2 remaining"
t1 `shouldSatisfy` T.isInfixOf "Last redeemed"
t1 <- waitItemText cc trackerItemId "1 of 2 uses remaining"
t1 `shouldSatisfy` T.isInfixOf "last used"
redeemOk gsKey cc env code
void $ waitItemText cc trackerItemId "0/2 remaining"
waitExhausted cc `shouldReturn` exhaustedText code 2
usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed"
sentItemsWithCode cc code `shouldReturn` [usedUp]
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeUsed)
testStaleRefreshIgnored :: HasCallStack => TestParams -> IO ()
@@ -647,35 +647,21 @@ testStaleRefreshIgnored ps =
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
redeemOk gsKey cc env code
redeemOk gsKey cc env code
void $ waitItemText cc trackerItemId "0/2 remaining"
notice <- waitExhausted cc
replayRefresh cc env code 2 1
usedUp <- waitItemText cc trackerItemId "All 2 uses redeemed"
replayRefresh cc env code 2
awaitLane cc env
exhaustedNotices cc `shouldReturn` [notice]
readItemText cc trackerItemId >>= (`shouldSatisfy` T.isInfixOf "0/2 remaining")
redeemedTrackerCount cc `shouldReturn` 1
sentItemsWithCode cc code `shouldReturn` [usedUp]
testSecondMultiUseCodeExhausted :: HasCallStack => TestParams -> IO ()
testSecondMultiUseCodeExhausted ps =
testCountersKeptApart :: HasCallStack => TestParams -> IO ()
testCountersKeptApart ps =
withGroupOwner ps $ \gsKey cc env alice -> do
-- The first code gets 3 claims, the second code's limit, so reading the wrong count would post the notice early.
(firstItemId, _, firstCode) <- issueTracked cc alice "supporter" 5
replicateM_ 3 (redeemOk gsKey cc env firstCode)
void $ waitItemText cc firstItemId "2/5 remaining"
awaitLane cc env
exhaustedNotices cc `shouldReturn` []
void $ waitItemText cc firstItemId "2 of 5 uses remaining"
(secondItemId, _, secondCode) <- issueTracked cc alice "legend" 3
-- Each claim is awaited because the lane coalesces refreshes queued together.
redeemOk gsKey cc env secondCode
void $ waitItemText cc secondItemId "2/3 remaining"
redeemOk gsKey cc env secondCode
void $ waitItemText cc secondItemId "1/3 remaining"
awaitLane cc env
exhaustedNotices cc `shouldReturn` []
redeemOk gsKey cc env secondCode
void $ waitItemText cc secondItemId "0/3 remaining"
waitExhausted cc `shouldReturn` exhaustedText secondCode 3
readItemText cc firstItemId >>= (`shouldSatisfy` T.isInfixOf "2/5 remaining")
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 =
@@ -685,12 +671,10 @@ testLanelessRedeemRefreshesTracker ps =
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/2 remaining"
t1 `shouldSatisfy` T.isInfixOf "Last redeemed"
exhaustedNotices cc `shouldReturn` []
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 "0/2 remaining")
exhaustedNotices cc `shouldReturn` [exhaustedText code 2]
readItemText cc trackerItemId >>= (`shouldSatisfy` T.isInfixOf "All 2 uses redeemed")
redeemedTrackerCount cc `shouldReturn` 1
testQueuedRequestRefreshesTracker :: HasCallStack => TestParams -> IO ()
@@ -698,7 +682,7 @@ testQueuedRequestRefreshesTracker ps =
withGroupOwner ps $ \_ cc env alice -> do
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
queueRedemption cc env code
void $ waitItemText cc trackerItemId "1/2 remaining"
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
testQueuedRequestWithoutGroupConfig :: HasCallStack => TestParams -> IO ()
testQueuedRequestWithoutGroupConfig ps = do
@@ -707,7 +691,7 @@ testQueuedRequestWithoutGroupConfig ps = do
(\(i, _, c) -> (i, c)) <$> issueTracked cc alice "supporter" 2
runGroupServiceAs svc Nothing $ \cc env _ -> do
queueRedemption cc env code
void $ waitItemText cc trackerItemId "1/2 remaining"
void $ waitItemText cc trackerItemId "1 of 2 uses remaining"
testTrackerRepost :: HasCallStack => TestParams -> IO ()
testTrackerRepost ps =
@@ -716,7 +700,7 @@ testTrackerRepost ps =
backdated <- backdateTracker cc 2
redeemOk gsKey cc env code
(_, tracker1) <- waitTrackerRepost cc 2 itemId0
tracker1 `shouldSatisfy` T.isInfixOf "1/2 remaining"
tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining"
readItemText cc itemId0 `shouldReturn` tracker0
sentAt <- trackerSentAt cc 2
sentAt `shouldSatisfy` (> backdated)
@@ -728,11 +712,10 @@ testRefusedEditReposted ps =
backdateTrackerItem cc itemId0
redeemOk gsKey cc env code
(itemId1, tracker1) <- waitTrackerRepost cc 2 itemId0
tracker1 `shouldSatisfy` T.isInfixOf "1/2 remaining"
tracker1 `shouldSatisfy` T.isInfixOf "1 of 2 uses remaining"
redeemOk gsKey cc env code
void $ waitItemText cc itemId1 "0/2 remaining"
waitExhausted cc `shouldReturn` exhaustedText code 2
readItemText cc itemId0 `shouldReturn` tracker0
usedUp <- waitItemText cc itemId1 "All 2 uses redeemed"
sentItemsWithCode cc code `shouldReturn` [tracker0, usedUp]
testFailedRepostDropped :: HasCallStack => TestParams -> IO ()
testFailedRepostDropped ps =
@@ -748,8 +731,7 @@ testFailedRepostDropped ps =
setBotRole cc "owner"
redeemOk gsKey cc env code
(_, tracker1) <- waitTrackerRepost cc 2 itemId0
tracker1 `shouldSatisfy` T.isInfixOf "0/2 remaining"
waitExhausted cc `shouldReturn` exhaustedText code 2
tracker1 `shouldSatisfy` T.isInfixOf "All 2 uses redeemed"
testDeletedTrackerNotReposted :: HasCallStack => TestParams -> IO ()
testDeletedTrackerNotReposted ps =
@@ -769,14 +751,13 @@ testDeletedTrackerNotRepostedOnRevoke ps =
revokeNeverReposts cc alice code []
-- Both claims fall inside the edit window, so each edit fails because the message is gone.
testDeletedTrackerNoNotice :: HasCallStack => TestParams -> IO ()
testDeletedTrackerNoNotice ps =
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
exhaustedNotices cc `shouldReturn` []
sentItemsWithCode cc code `shouldReturn` []
testModeratedTrackerNotRepostedOnRevoke :: HasCallStack => TestParams -> IO ()
@@ -815,13 +796,12 @@ trackerNeverReposted key cc env code itemId0 codeItems = do
redeemOk key cc env code
awaitLane cc env
sentItemsWithCode cc code `shouldReturn` codeItems
exhaustedNotices cc `shouldReturn` []
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"]
revokeInGroup cc member code "Revoked."
sentItemsWithCode cc code `shouldReturn` codeItems <> [revokeReply code "Revoked."]
revokedTrackerCount cc `shouldReturn` 0
testGroupRevoke :: HasCallStack => TestParams -> IO ()
@@ -829,14 +809,14 @@ 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"
revokeInGroup cc alice code "Revoked."
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid)
redeemOk gsKey cc env other
revokeInGroup cc alice code "already revoked"
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"
revokeInGroup cc alice unissued "No such code."
testGroupRevokeRepairsTracker :: HasCallStack => TestParams -> IO ()
testGroupRevokeRepairsTracker ps =
@@ -846,9 +826,9 @@ testGroupRevokeRepairsTracker ps =
revokeInStore cc 2
readItemText cc trackerItemId `shouldReturn` tracker0
sendGroupCmd alice (revokeCmd code)
retired <- waitItemText cc trackerItemId "revoked"
retired <- waitItemText cc trackerItemId "Revoked"
retired `shouldBe` retiredText code
waitStoredItem cc (revokeReply code "already revoked")
waitStoredItem cc (revokeReply code "Already revoked.")
revokedTrackerCount cc `shouldReturn` 1
testRevokeRepeatPastWindow :: HasCallStack => TestParams -> IO ()
@@ -857,36 +837,36 @@ testRevokeRepeatPastWindow ps =
offsetCodeIds cc
(itemId0, tracker0, code) <- issueTracked cc alice "supporter" 2
void $ backdateTracker cc 2
revokeInGroup cc alice code "revoked"
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"
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
testExhaustedTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
testExhaustedTrackerReconciledOnRestart ps = do
testUsedUpTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
testUsedUpTrackerReconciledOnRestart ps = do
svc@GroupSvc {gsKey} <- prepareGroupService ps
(lostItemId, notice, lostCode) <- runWithOwner svc $ \cc env _ alice -> do
(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
notice <- waitExhausted cc
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, notice, lostCode)
pure (lostItemId, lostCode)
runGroupService svc $ \cc env _ -> do
corrected <- waitItemText cc lostItemId "0/3 remaining"
corrected `shouldBe` ("!2 " <> lostCode <> "!\nLast redeemed: 2026-02-28 — 0/3 remaining")
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
exhaustedNotices cc `shouldReturn` [notice]
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.
@@ -896,21 +876,21 @@ lostRedeemedAt = UTCTime (fromGregorian 2026 2 28) (23 * 3600 + 50 * 60)
testStalledTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
testStalledTrackerReconciledOnRestart ps = do
svc@GroupSvc {gsKey} <- prepareGroupService ps
stalledItemId <- runWithOwner svc $ \cc env _ alice -> do
(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/5 remaining"
stalled <- waitItemText cc stalledItemId "3 of 5 uses remaining"
redeemLosingRefresh gsKey cc code
readItemText cc stalledItemId `shouldReturn` stalled
pure stalledItemId
dateRedemption cc 5 lostRedeemedAt
pure (stalledItemId, code)
runGroupService svc $ \cc env _ -> do
corrected <- waitItemText cc stalledItemId "2/5 remaining"
corrected `shouldSatisfy` T.isInfixOf "Last redeemed: "
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
exhaustedNotices cc `shouldReturn` []
redeemedTrackerCount cc `shouldReturn` 1
sentItemsWithCode cc code `shouldReturn` [corrected]
testRevokedTrackerReconciledOnRestart :: HasCallStack => TestParams -> IO ()
testRevokedTrackerReconciledOnRestart ps = do
@@ -921,7 +901,7 @@ testRevokedTrackerReconciledOnRestart ps = do
readItemText cc trackerItemId `shouldReturn` tracker0
pure (trackerItemId, code)
runGroupService svc $ \cc env _ -> do
retired <- waitItemText cc trackerItemId "revoked"
retired <- waitItemText cc trackerItemId "Revoked"
retired `shouldBe` retiredText code
awaitLane cc env
revokedTrackerCount cc `shouldReturn` 1
@@ -938,12 +918,11 @@ testEveryStalledTrackerReconciled ps = do
readItemText cc secondItemId `shouldReturn` second0
pure (firstItemId, secondItemId)
runGroupService svc $ \cc env _ -> do
first1 <- waitItemText cc firstItemId "3/5 remaining"
first1 `shouldSatisfy` T.isInfixOf "Last redeemed: "
second1 <- waitItemText cc secondItemId "2/3 remaining"
second1 `shouldSatisfy` T.isInfixOf "Last redeemed: "
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
exhaustedNotices cc `shouldReturn` []
redeemedTrackerCount cc `shouldReturn` 2
testUneditableTrackerLeftAlone :: HasCallStack => TestParams -> IO ()
@@ -959,7 +938,7 @@ testUneditableTrackerLeftAlone ps = do
pure (staleItemId, stale0, staleCode, liveItemId, liveCode)
runGroupService svc $ \cc env _ -> do
redeemOk gsKey cc env liveCode
void $ waitItemText cc liveItemId "2/3 remaining"
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
@@ -970,15 +949,14 @@ testRevokeRetiresTracker ps =
offsetCodeIds cc
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
redeemOk gsKey cc env code
void $ waitItemText cc trackerItemId "1/2 remaining"
revokeInGroup cc alice code "revoked"
retired <- waitItemText cc trackerItemId "revoked"
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 2
replayRefresh cc env code 2
awaitLane cc env
readItemText cc trackerItemId `shouldReturn` retired
exhaustedNotices cc `shouldReturn` []
revokedTrackerCount cc `shouldReturn` 1
redeemViaService gsKey cc env code >>= (`shouldAnswerError` BSECodeInvalid)
@@ -988,14 +966,14 @@ testServiceRevokeRetiresTracker ps =
offsetCodeIds cc
(trackerItemId, _, code) <- issueTracked cc alice "supporter" 2
redeemOk gsKey cc env code
void $ waitItemText cc trackerItemId "1/2 remaining"
revokeRaw cc code `shouldReturn` Right "revoked"
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"
revokeRaw cc code `shouldReturn` Right "Already revoked."
revokedTrackerCount cc `shouldReturn` 1
single <- extractCode <$> replyTo cc alice "/issue legend"
revokeRaw cc single `shouldReturn` Right "revoked"
revokeRaw cc single `shouldReturn` Right "Revoked."
revokedTrackerCount cc `shouldReturn` 1
testServiceRevokeRepairsTracker :: HasCallStack => TestParams -> IO ()
@@ -1005,7 +983,7 @@ testServiceRevokeRepairsTracker ps =
(trackerItemId, tracker0, code) <- issueTracked cc alice "supporter" 2
revokeInStore cc 2
readItemText cc trackerItemId `shouldReturn` tracker0
revokeRaw cc code `shouldReturn` Right "already revoked"
revokeRaw cc code `shouldReturn` Right "Already revoked."
readItemText cc trackerItemId `shouldReturn` retiredText code
revokedTrackerCount cc `shouldReturn` 1
@@ -1035,12 +1013,12 @@ queueRedemption cc env codeText = do
redeemLosingRefresh :: HasCallStack => BadgeIssuerKey -> ChatController -> Text -> IO ()
redeemLosingRefresh key cc codeText = newTQueueIO >>= \q -> redeemOkWith key cc (Just q) codeText
replayRefresh :: HasCallStack => ChatController -> ServiceState -> Text -> Int -> Int -> IO ()
replayRefresh cc env codeText uses claim = case parseBadgeCode codeText of
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 claim)
atomically $ writeTQueue (groupEventQ env) (GETracker badgeCodeId code)
getStoredGroup :: ChatController -> IO (Maybe ManagedGroup)
getStoredGroup cc = withTransaction (chatStore cc) getManagedGroup
@@ -1386,26 +1364,13 @@ waitTrackerRepost :: HasCallStack => ChatController -> Int -> Int64 -> IO (Int64
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 redeemed%'"
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 <> " revoked — no longer redeemable"
exhaustedText :: Text -> Int -> Text
exhaustedText code uses = code <> " fully redeemed — all " <> tshow uses <> " used"
-- Notices are not filtered by code or total, so a wrong one still shows up.
exhaustedNotices :: ChatController -> IO [Text]
exhaustedNotices cc = queryColumn cc "SELECT item_text FROM chat_items WHERE item_text LIKE '%fully redeemed%' ORDER BY chat_item_id"
waitExhausted :: HasCallStack => ChatController -> IO Text
waitExhausted cc =
pollUntil (mfilter (not . null) . Just <$> exhaustedNotices cc) >>= \case
[t] -> pure t
ts -> error $ "expected one exhaustion notice, got " <> show (length ts)
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)
+12 -14
View File
@@ -22,9 +22,9 @@ import Test.Hspec
badgeGroupTests :: Spec
badgeGroupTests = describe "badge group" $ do
let p = groupCmdAction GROwner
issueUsage = ReplyText "use: /issue <type> [months <M>] [uses <N>]"
bulkUsage = ReplyText "use: /bulk <type> [months <M>] count <B>"
revokeUsage = ReplyText "use: /revoke <code>"
issueUsage = ReplyText "Usage: /issue <type> [months <M>] [uses <N>]"
bulkUsage = ReplyText "Usage: /bulk <type> [months <M>] count <B>"
revokeUsage = ReplyText "Usage: /revoke <code>"
it "issue defaults" $ p "/issue supporter" `shouldBe` RunCmd (GCIssue BTSupporter 1 1)
it "issue with months and uses" $ p "/issue legend months 6 uses 50" `shouldBe` RunCmd (GCIssue BTLegend 6 50)
it "bulk with count" $ p "/bulk supporter count 20" `shouldBe` RunCmd (GCBulk BTSupporter 1 20)
@@ -111,7 +111,7 @@ badgeGroupTests = describe "badge group" $ do
it "tells a sender below admin that their revoke did not happen" $ do
code <- newCode
map (`groupCmdAction` ("/revoke " <> badgeCodeText code)) [GRMember, GRModerator]
`shouldBe` replicate 2 (ReplyText "only admins can revoke codes, and this code is now visible to the group - ask an admin to revoke it")
`shouldBe` replicate 2 (ReplyText "Only admins can revoke codes. This code is now visible to all members.")
describe "configured group name and description" $ do
let cfg name descr = GroupConfig {gDisplayName = name, gDescription = descr}
profile name descr =
@@ -156,22 +156,20 @@ badgeGroupTests = describe "badge group" $ do
trackerDecision within t0 `shouldBe` Edit
trackerDecision past t0 `shouldBe` Repost
describe "coalescing tracker refreshes" $ do
it "keeps one refresh per code, carrying the highest claim" $ do
code <- newCode
coalesceTrackerRefreshes [GETracker 1 code 1, GETracker 1 code 3, GETracker 2 code 1]
`shouldBe` [GETracker 1 code 3, GETracker 2 code 1]
it "keeps the highest claim whichever order it queued in" $ do
code <- newCode
coalesceTrackerRefreshes [GETracker 1 code 3, GETracker 1 code 1] `shouldBe` [GETracker 1 code 3]
it "keeps the last refresh of each code" $ do
code1 <- newCode
code2 <- newCode
coalesceTrackerRefreshes [GETracker 1 code1, GETracker 2 code2, GETracker 1 code1]
`shouldBe` [GETracker 2 code2, GETracker 1 code1]
it "keeps every other event, in order" $ do
code <- newCode
coalesceTrackerRefreshes
[ GEInGroup 7 (GACommand GRAdmin "a"),
GETracker 1 code 1,
GETracker 1 code,
GEInGroup 7 GAJoined,
GETracker 1 code 2
GETracker 1 code
]
`shouldBe` [GEInGroup 7 (GACommand GRAdmin "a"), GEInGroup 7 GAJoined, GETracker 1 code 2]
`shouldBe` [GEInGroup 7 (GACommand GRAdmin "a"), GEInGroup 7 GAJoined, GETracker 1 code]
newCode :: IO BadgeCode
newCode = C.newRandom >>= randomBadgeCode
+4 -4
View File
@@ -159,7 +159,7 @@ badgeWebTests = do
it "refuses a second row and burns the flag per group" testManagedGroupIsSingleRow
describe "multi-use" $ do
it "finds a key's own purchase with its newest credential, and none for another key" testKeyPurchaseLookup
it "gives concurrent claims exactly the code's uses, each a distinct count" testMultiUseConcurrentClaimsUpToLimit
it "gives concurrent claims exactly the code's uses" testMultiUseConcurrentClaimsUpToLimit
it "refuses a second claim by the same key without using up a use" testSameKeyClaimsOnce
describe "badge service catalog seed" $ do
it "writes the compiled-in catalog into an empty database" testSeedWritesTheCatalog
@@ -728,7 +728,7 @@ insertCode :: DBStore -> ByteString -> BadgeCodePaymentStatus -> Int -> UTCTime
insertCode st codeHash paymentStatus redeemLimit now =
withTransaction st $ \db -> insertBadgeCode db codeHash BTSupporter 1 paymentStatus redeemLimit now
claimUse :: DBStore -> Int64 -> UTCTime -> (C.PublicKeyEd25519, BadgeMasterKey) -> IO (Maybe (Int64, Int))
claimUse :: DBStore -> Int64 -> UTCTime -> (C.PublicKeyEd25519, BadgeMasterKey) -> IO (Maybe Int64)
claimUse st badgeCodeId now (purchaseKey, masterKey) =
withTransaction st $ \db ->
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now
@@ -752,7 +752,7 @@ testKeyPurchaseLookup = withServiceStore $ \st -> do
let codeHash = digestFixture 41
badgeCodeId <- insertCode st codeHash CPSFree 2 now
keys@(k1, mk1) <- newPurchaseKeys
Just (purchaseId, _) <- claimUse st badgeCodeId now keys
Just purchaseId <- claimUse st badgeCodeId now keys
withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) >>= \case
KeyRedeemedUnreadable -> pure ()
_ -> expectationFailure "expected a purchase with no issuance to be unreadable"
@@ -789,7 +789,7 @@ testMultiUseConcurrentClaimsUpToLimit = withServiceStore $ \st -> do
badgeCodeId <- insertCode st codeHash CPSFree 3 now
contenders <- replicateM 6 newPurchaseKeys
results <- Async.mapConcurrently (claimUse st badgeCodeId now) contenders
sort (map snd $ catMaybes results) `shouldBe` [1, 2, 3]
length (catMaybes results) `shouldBe` 3
codeCounts st codeHash `shouldReturn` Just (3, 3)
data StubCall