Treasury bot + ledger fix
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
)
|
||||
|
||||
type DialogState string
|
||||
|
||||
const (
|
||||
DialogStateWaitingAmount DialogState = "waiting_amount"
|
||||
DialogStateWaitingConfirmation DialogState = "waiting_confirmation"
|
||||
)
|
||||
|
||||
type DialogSession struct {
|
||||
State DialogState
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
LedgerAccountID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type Dialogs struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]DialogSession
|
||||
}
|
||||
|
||||
func NewDialogs() *Dialogs {
|
||||
return &Dialogs{
|
||||
sessions: map[string]DialogSession{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) {
|
||||
if d == nil {
|
||||
return DialogSession{}, false
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return DialogSession{}, false
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
session, ok := d.sessions[telegramUserID]
|
||||
return session, ok
|
||||
}
|
||||
|
||||
func (d *Dialogs) Set(telegramUserID string, session DialogSession) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.sessions[telegramUserID] = session
|
||||
}
|
||||
|
||||
func (d *Dialogs) Clear(telegramUserID string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if telegramUserID == "" {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
delete(d.sessions, telegramUserID)
|
||||
}
|
||||
366
api/gateway/tgsettle/internal/service/treasury/bot/router.go
Normal file
366
api/gateway/tgsettle/internal/service/treasury/bot/router.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const unauthorizedMessage = "Access denied.\n\nYour Telegram account is not authorized to perform treasury operations."
|
||||
|
||||
type SendTextFunc func(ctx context.Context, chatID string, text string) error
|
||||
|
||||
type ScheduleTracker interface {
|
||||
TrackScheduled(record *storagemodel.TreasuryRequest)
|
||||
Untrack(requestID string)
|
||||
}
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type TreasuryService interface {
|
||||
ExecutionDelay() time.Duration
|
||||
MaxPerOperationLimit() string
|
||||
|
||||
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
|
||||
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
|
||||
ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
}
|
||||
|
||||
type limitError interface {
|
||||
error
|
||||
LimitKind() string
|
||||
LimitMax() string
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
service TreasuryService
|
||||
dialogs *Dialogs
|
||||
send SendTextFunc
|
||||
tracker ScheduleTracker
|
||||
|
||||
allowedChats map[string]struct{}
|
||||
userAccounts map[string]string
|
||||
allowAnyChat bool
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
logger mlogger.Logger,
|
||||
service TreasuryService,
|
||||
send SendTextFunc,
|
||||
tracker ScheduleTracker,
|
||||
allowedChats []string,
|
||||
userAccounts map[string]string,
|
||||
) *Router {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_router")
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
for _, chatID := range allowedChats {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
continue
|
||||
}
|
||||
allowed[chatID] = struct{}{}
|
||||
}
|
||||
users := map[string]string{}
|
||||
for userID, accountID := range userAccounts {
|
||||
userID = strings.TrimSpace(userID)
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users[userID] = accountID
|
||||
}
|
||||
return &Router{
|
||||
logger: logger,
|
||||
service: service,
|
||||
dialogs: NewDialogs(),
|
||||
send: send,
|
||||
tracker: tracker,
|
||||
allowedChats: allowed,
|
||||
userAccounts: users,
|
||||
allowAnyChat: len(allowed) == 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Enabled() bool {
|
||||
return r != nil && r.service != nil && len(r.userAccounts) > 0
|
||||
}
|
||||
|
||||
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if !r.Enabled() || update == nil || update.Message == nil {
|
||||
return false
|
||||
}
|
||||
message := update.Message
|
||||
chatID := strings.TrimSpace(message.ChatID)
|
||||
userID := strings.TrimSpace(message.FromUserID)
|
||||
text := strings.TrimSpace(message.Text)
|
||||
|
||||
if chatID == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
if !r.allowAnyChat {
|
||||
if _, ok := r.allowedChats[chatID]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
accountID, ok := r.userAccounts[userID]
|
||||
if !ok || strings.TrimSpace(accountID) == "" {
|
||||
r.logUnauthorized(update)
|
||||
_ = r.sendText(ctx, chatID, unauthorizedMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
command := parseCommand(text)
|
||||
switch command {
|
||||
case "fund":
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
|
||||
return true
|
||||
case "withdraw":
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
|
||||
return true
|
||||
case "confirm":
|
||||
r.confirm(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
case "cancel":
|
||||
r.cancel(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
}
|
||||
|
||||
session, hasSession := r.dialogs.Get(userID)
|
||||
if hasSession {
|
||||
switch session.State {
|
||||
case DialogStateWaitingAmount:
|
||||
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
|
||||
return true
|
||||
case DialogStateWaitingConfirmation:
|
||||
_ = r.sendText(ctx, chatID, "Confirm operation?\n\n/confirm\n/cancel")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "/") {
|
||||
_ = r.sendText(ctx, chatID, "Supported commands:\n/fund\n/withdraw\n/confirm\n/cancel")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
|
||||
return
|
||||
}
|
||||
if active != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: active.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingAmount,
|
||||
OperationType: operation,
|
||||
LedgerAccountID: accountID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, "Enter amount:")
|
||||
}
|
||||
|
||||
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
|
||||
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
|
||||
OperationType: operation,
|
||||
TelegramUserID: userID,
|
||||
LedgerAccountID: accountID,
|
||||
ChatID: chatID,
|
||||
Amount: amount,
|
||||
})
|
||||
if err != nil {
|
||||
if record != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if typed, ok := err.(limitError); ok {
|
||||
switch typed.LimitKind() {
|
||||
case "per_operation":
|
||||
_ = r.sendText(ctx, chatID, "Amount exceeds allowed limit.\n\nMax per operation: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
|
||||
return
|
||||
case "daily":
|
||||
_ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
if record == nil {
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
|
||||
}
|
||||
|
||||
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
|
||||
return
|
||||
}
|
||||
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "Unable to confirm treasury request.\n\nUse /cancel or create a new request with /fund or /withdraw.")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.TrackScheduled(record)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
delay := int64(r.service.ExecutionDelay().Seconds())
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
|
||||
return
|
||||
}
|
||||
record, err := r.service.CancelRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "Unable to cancel treasury request.")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.Untrack(record.RequestID)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "Operation cancelled.\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
|
||||
if r == nil || r.send == nil {
|
||||
return nil
|
||||
}
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
text = strings.TrimSpace(text)
|
||||
if chatID == "" || text == "" {
|
||||
return nil
|
||||
}
|
||||
return r.send(ctx, chatID, text)
|
||||
}
|
||||
|
||||
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
|
||||
if r == nil || r.logger == nil || update == nil || update.Message == nil {
|
||||
return
|
||||
}
|
||||
message := update.Message
|
||||
r.logger.Warn("unauthorized_access",
|
||||
zap.String("event", "unauthorized_access"),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
|
||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
||||
zap.String("message_text", strings.TrimSpace(message.Text)),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "You already have a pending treasury operation.\n\n/cancel"
|
||||
}
|
||||
return "You already have a pending treasury operation.\n\n" +
|
||||
"Request ID: " + strings.TrimSpace(record.RequestID) + "\n" +
|
||||
"Status: " + strings.TrimSpace(string(record.Status)) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
|
||||
"Wait for execution or cancel it.\n\n/cancel"
|
||||
}
|
||||
|
||||
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "Request created.\n\n/confirm\n/cancel"
|
||||
}
|
||||
title := "Funding request created."
|
||||
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
|
||||
title = "Withdrawal request created."
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"Account: " + strings.TrimSpace(record.LedgerAccountID) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
|
||||
"Confirm operation?\n\n/confirm\n/cancel"
|
||||
}
|
||||
|
||||
func parseCommand(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return ""
|
||||
}
|
||||
token := text
|
||||
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimPrefix(token, "/")
|
||||
if idx := strings.Index(token, "@"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func formatSeconds(value int64) string {
|
||||
if value == 1 {
|
||||
return "1 second"
|
||||
}
|
||||
return strconv.FormatInt(value, 10) + " seconds"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
func (fakeService) ExecutionDelay() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func (fakeService) MaxPerOperationLimit() string {
|
||||
return "1000000"
|
||||
}
|
||||
|
||||
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "100",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterUnknownChatIsIgnored(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("expected no messages, got %d", len(sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != "Enter amount:" {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user