package gateway import ( "context" "sync" "testing" "time" "github.com/tech/sendico/gateway/chsettle/storage" storagemodel "github.com/tech/sendico/gateway/chsettle/storage/model" "github.com/tech/sendico/pkg/discovery" envelope "github.com/tech/sendico/pkg/messaging/envelope" tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ) // // FAKE STORES // type fakePaymentsStore struct { mu sync.Mutex records map[string]*storagemodel.PaymentRecord } func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { return nil, nil //nolint:nilnil // fake store: no records means no payment by idempotency key } return f.records[key], nil } func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { return nil, nil //nolint:nilnil // fake store: no records means no payment by operation ref } for _, record := range f.records { if record != nil && record.OperationRef == key { return record, nil } } return nil, nil //nolint:nilnil // fake store: operation ref not found } func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { f.records = map[string]*storagemodel.PaymentRecord{} } f.records[record.IdempotencyKey] = record return nil } type fakeTelegramStore struct { mu sync.Mutex records map[string]*storagemodel.TelegramConfirmation } func (f *fakeTelegramStore) Upsert(_ context.Context, record *storagemodel.TelegramConfirmation) error { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { f.records = map[string]*storagemodel.TelegramConfirmation{} } f.records[record.RequestID] = record return nil } type fakeRepo struct { payments *fakePaymentsStore tg *fakeTelegramStore pending *fakePendingStore treasury storage.TreasuryRequestsStore users storage.TreasuryTelegramUsersStore } func (f *fakeRepo) Payments() storage.PaymentsStore { return f.payments } func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { return f.tg } func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore { return f.pending } func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore { return f.treasury } func (f *fakeRepo) TreasuryTelegramUsers() storage.TreasuryTelegramUsersStore { return f.users } type fakePendingStore struct { mu sync.Mutex records map[string]*storagemodel.PendingConfirmation } func (f *fakePendingStore) Upsert(_ context.Context, record *storagemodel.PendingConfirmation) error { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { f.records = map[string]*storagemodel.PendingConfirmation{} } cp := *record f.records[record.RequestID] = &cp return nil } func (f *fakePendingStore) FindByRequestID(_ context.Context, requestID string) (*storagemodel.PendingConfirmation, error) { f.mu.Lock() defer f.mu.Unlock() if f.records == nil { return nil, nil //nolint:nilnil // fake store: no pending confirmations configured } return f.records[requestID], nil } func (f *fakePendingStore) FindByMessageID(_ context.Context, messageID string) (*storagemodel.PendingConfirmation, error) { f.mu.Lock() defer f.mu.Unlock() for _, record := range f.records { if record != nil && record.MessageID == messageID { return record, nil } } return nil, nil //nolint:nilnil // fake store: message id not found } func (f *fakePendingStore) MarkClarified(_ context.Context, requestID string) error { f.mu.Lock() defer f.mu.Unlock() if record := f.records[requestID]; record != nil { record.Clarified = true } return nil } func (f *fakePendingStore) AttachMessage(_ context.Context, requestID string, messageID string) error { f.mu.Lock() defer f.mu.Unlock() if record := f.records[requestID]; record != nil { if record.MessageID == "" { record.MessageID = messageID } } return nil } func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string) error { f.mu.Lock() defer f.mu.Unlock() delete(f.records, requestID) return nil } func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) { f.mu.Lock() defer f.mu.Unlock() if limit <= 0 { limit = 100 } result := make([]storagemodel.PendingConfirmation, 0) for _, record := range f.records { if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) { continue } result = append(result, *record) if int64(len(result)) >= limit { break } } return result, nil } // // FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА) // type fakeBroker struct{} func (f *fakeBroker) Publish(_ envelope.Envelope) error { return nil } func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) { return nil, nil //nolint:nilnil // fake broker does not emit events in unit tests } func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error { return nil } // // CAPTURE ONLY TELEGRAM REACTIONS // type captureProducer struct { mu sync.Mutex reactions []envelope.Envelope sig string } func (c *captureProducer) SendMessage(env envelope.Envelope) error { if env.GetSignature().ToString() != c.sig { return nil } c.mu.Lock() c.reactions = append(c.reactions, env) c.mu.Unlock() return nil } // // TESTS // func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) { logger := mloggerfactory.NewLogger(false) repo := &fakeRepo{ payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}, pending: &fakePendingStore{}, } sigEnv := tnotifications.TelegramReaction(mservice.PaymentGateway, &model.TelegramReactionRequest{ RequestID: "x", ChatID: "1", MessageID: "2", Emoji: "ok", }) prod := &captureProducer{ sig: sigEnv.GetSignature().ToString(), } svc := NewService(logger, repo, prod, &fakeBroker{}, Config{ Rail: "card", SuccessReaction: "👍", }) return svc, repo, prod } func TestConfirmed(t *testing.T) { svc, repo, prod := newTestService(t) _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ IdempotencyKey: "idem-1", PaymentIntentID: "pi-1", QuoteRef: "quote-1", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-1", Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, Status: model.ConfirmationStatusConfirmed, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } _ = svc.onConfirmationResult(context.Background(), result) rec := repo.payments.records["idem-1"] if rec.Status != storagemodel.PaymentStatusSuccess { t.Fatalf("expected success, got %s", rec.Status) } if rec.RequestedMoney == nil { t.Fatalf("requested money not set") } if rec.ExecutedAt.IsZero() { t.Fatalf("executedAt not set") } if repo.tg.records["idem-1"] == nil { t.Fatalf("telegram confirmation not stored") } if len(prod.reactions) != 1 { t.Fatalf("reaction must be published") } } func TestClarified(t *testing.T) { svc, repo, prod := newTestService(t) _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ IdempotencyKey: "idem-2", Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-2", Status: model.ConfirmationStatusClarified, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } _ = svc.onConfirmationResult(context.Background(), result) rec := repo.payments.records["idem-2"] if rec.Status != storagemodel.PaymentStatusWaiting { t.Fatalf("clarified must not change status") } if repo.tg.records["idem-2"] == nil { t.Fatalf("telegram confirmation must be stored") } if len(prod.reactions) != 0 { t.Fatalf("clarified must not publish reaction") } } func TestRejected(t *testing.T) { svc, repo, prod := newTestService(t) // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, // даем минимально ожидаемые поля + non-nil ExecutedMoney. _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ IdempotencyKey: "idem-3", PaymentIntentID: "pi-3", QuoteRef: "quote-3", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-3", Status: model.ConfirmationStatusRejected, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } _ = svc.onConfirmationResult(context.Background(), result) rec := repo.payments.records["idem-3"] if rec.Status != storagemodel.PaymentStatusFailed { t.Fatalf("expected failed") } if repo.tg.records["idem-3"] == nil { t.Fatalf("telegram confirmation must be stored") } if len(prod.reactions) != 0 { t.Fatalf("rejected must not publish reaction") } } func TestTimeout(t *testing.T) { svc, repo, prod := newTestService(t) // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, // даем минимально ожидаемые поля + non-nil ExecutedMoney. _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ IdempotencyKey: "idem-4", PaymentIntentID: "pi-4", QuoteRef: "quote-4", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-4", Status: model.ConfirmationStatusTimeout, RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } _ = svc.onConfirmationResult(context.Background(), result) rec := repo.payments.records["idem-4"] if rec.Status != storagemodel.PaymentStatusFailed { t.Fatalf("timeout must be failed") } if repo.tg.records["idem-4"] == nil { t.Fatalf("telegram confirmation must be stored") } if len(prod.reactions) != 0 { t.Fatalf("timeout must not publish reaction") } } func TestIntentFromSubmitTransfer_NormalizesOutgoingLeg(t *testing.T) { intent, err := intentFromSubmitTransfer(&chainv1.SubmitTransferRequest{ IdempotencyKey: "idem-5", IntentRef: "pi-5", OperationRef: "op-5", PaymentRef: "pay-5", Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, Metadata: map[string]string{ metadataOutgoingLeg: "card", }, }, "provider_settlement", "") if err != nil { t.Fatalf("unexpected error: %v", err) } if got, want := intent.OutgoingLeg, discovery.RailCardPayout; got != want { t.Fatalf("unexpected outgoing leg: got=%q want=%q", got, want) } }