Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe60b7ac1a | ||
|
|
b644a10663 | ||
|
|
c106706615 | ||
|
|
915b8e8123 |
No files matched your search
@@ -75,7 +75,6 @@ func OpenDependencies(_ context.Context, logger mlogger.Logger, config *api.Conf
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
deps.Payments = &payments.Service{Store: paymentStore, Provider: provider, Identity: deps.Identity, Logger: logger.Named("payments")}
|
||||||
deps.Auth.Wallet = deps.Payments
|
|
||||||
logger.Info("Sendico sign-up facts integration enabled")
|
logger.Info("Sendico sign-up facts integration enabled")
|
||||||
}
|
}
|
||||||
records, err := factorstore.Open(logger, db)
|
records, err := factorstore.Open(logger, db)
|
||||||
|
|||||||
@@ -31,13 +31,8 @@ type SignupFacts interface {
|
|||||||
Ready(context.Context, bson.ObjectID) error
|
Ready(context.Context, bson.ObjectID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type CurrencyAccounts interface {
|
|
||||||
Ensure(context.Context, *customer.Account) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Facts SignupFacts
|
Facts SignupFacts
|
||||||
Wallet CurrencyAccounts
|
|
||||||
Accounts account.DB
|
Accounts account.DB
|
||||||
Customers accountservice.AccountService
|
Customers accountservice.AccountService
|
||||||
Tokens verification.DB
|
Tokens verification.DB
|
||||||
@@ -115,11 +110,6 @@ func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.Wallet != nil {
|
|
||||||
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
u, _ := url.Parse(s.ActivationURL)
|
u, _ := url.Parse(s.ActivationURL)
|
||||||
q := u.Query()
|
q := u.Query()
|
||||||
@@ -158,11 +148,6 @@ func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.Wallet != nil {
|
|
||||||
if err := s.Wallet.Ensure(ctx, a); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = s.sendActivation(ctx, a, key)
|
_, err = s.sendActivation(ctx, a, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,17 +75,6 @@ func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupWallet struct {
|
|
||||||
customers []bson.ObjectID
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *signupWallet) Ensure(_ context.Context, a *customer.Account) error {
|
|
||||||
s.customers = append(s.customers, a.ID)
|
|
||||||
return s.err
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -113,8 +102,6 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
provider := &signupProvider{fail: true}
|
provider := &signupProvider{fail: true}
|
||||||
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
service.Facts = &identity.Service{Store: bindings, Provider: provider, Logger: zap.NewNop()}
|
||||||
wallet := &signupWallet{err: merrors.ServiceUnavailable("account setup temporarily unavailable")}
|
|
||||||
service.Wallet = wallet
|
|
||||||
// Keep the real verification/customer stores and capture only session writes.
|
// Keep the real verification/customer stores and capture only session writes.
|
||||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||||
@@ -146,14 +133,6 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
signup["name"] = "Changed retry name"
|
signup["name"] = "Changed retry name"
|
||||||
provider.fail = false
|
provider.fail = false
|
||||||
w = post("signup", signup)
|
w = post("signup", signup)
|
||||||
require.Equal(t, 503, w.Code, w.Body.String())
|
|
||||||
require.Empty(t, mail.messages)
|
|
||||||
require.Len(t, wallet.customers, 1)
|
|
||||||
w = post("verification/send", map[string]string{"email": "user@example.com", "idempotencyKey": "wallet-retry"})
|
|
||||||
require.Equal(t, 503, w.Code, w.Body.String())
|
|
||||||
require.Empty(t, mail.messages)
|
|
||||||
wallet.err = nil
|
|
||||||
w = post("signup", signup)
|
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 1, provider.registrations)
|
require.Equal(t, 1, provider.registrations)
|
||||||
require.Equal(t, "Customer", provider.last.FullName)
|
require.Equal(t, "Customer", provider.last.FullName)
|
||||||
@@ -170,7 +149,6 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
require.Equal(t, a.ID.Hex(), provider.last.CustomerRef)
|
||||||
require.Equal(t, []bson.ObjectID{a.ID, a.ID, a.ID}, wallet.customers)
|
|
||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package payments
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var accountSetup = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_currency_accounts_setup_total", Help: "Default currency account setup attempts, including checks of existing accounts."}, []string{"outcome"})
|
|
||||||
var accountSetupDuration = promauto.NewHistogram(prometheus.HistogramOpts{Name: "sxchnge_currency_accounts_setup_duration_seconds", Help: "Time to confirm all default currency accounts exist."})
|
|
||||||
|
|
||||||
func (s *Service) Ensure(ctx context.Context, a *customer.Account) error {
|
|
||||||
_, err := s.ensureAccounts(ctx, a)
|
|
||||||
if err != nil {
|
|
||||||
return merrors.ServiceUnavailableWrap(err, "currency account setup unavailable")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) ensureAccounts(ctx context.Context, a *customer.Account) (subject string, result error) {
|
|
||||||
started := time.Now()
|
|
||||||
defer func() {
|
|
||||||
outcome := "ready"
|
|
||||||
if result != nil {
|
|
||||||
outcome = "failed"
|
|
||||||
}
|
|
||||||
accountSetup.WithLabelValues(outcome).Inc()
|
|
||||||
accountSetupDuration.Observe(time.Since(started).Seconds())
|
|
||||||
}()
|
|
||||||
if s == nil || s.Identity == nil || s.Provider == nil {
|
|
||||||
return "", Unavailable
|
|
||||||
}
|
|
||||||
if a == nil || a.ID.IsZero() {
|
|
||||||
return "", NotFound
|
|
||||||
}
|
|
||||||
subject, result = s.Identity.Subject(ctx, a)
|
|
||||||
if result != nil {
|
|
||||||
return "", result
|
|
||||||
}
|
|
||||||
if result = s.Provider.EnsureAccounts(ctx, subject); result != nil {
|
|
||||||
s.Logger.Warn("Currency account setup incomplete", zap.String("account_ref", a.ID.Hex()), zap.Error(result))
|
|
||||||
return "", result
|
|
||||||
}
|
|
||||||
return subject, nil
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package payments
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSignupAndWalletReadEnsureCustomerCurrencyAccounts(t *testing.T) {
|
|
||||||
a := &customer.Account{}
|
|
||||||
a.ID = bson.NewObjectID()
|
|
||||||
p := &provider{namespace: "provider"}
|
|
||||||
s := &Service{Provider: p, Identity: identity{}, Logger: zap.NewNop(), Factors: &factors{}, Store: &memoryStore{records: map[bson.ObjectID]Record{}}}
|
|
||||||
require.NoError(t, s.Ensure(t.Context(), a))
|
|
||||||
require.Equal(t, "subject", p.setupSubject)
|
|
||||||
snapshot, err := s.Fetch(t.Context(), a)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, snapshot.Accounts, 1)
|
|
||||||
require.Equal(t, 2, p.setupCalls)
|
|
||||||
p.setupErr = Unavailable
|
|
||||||
_, err = s.Fetch(t.Context(), a)
|
|
||||||
require.ErrorIs(t, err, Unavailable)
|
|
||||||
require.Error(t, s.Ensure(t.Context(), a))
|
|
||||||
require.Error(t, s.Ensure(t.Context(), nil))
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,6 @@ func (*Record) Collection() string { return "payment_quotes" }
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Namespace() string
|
Namespace() string
|
||||||
EnsureAccounts(context.Context, string) error
|
|
||||||
Accounts(context.Context, string) ([]Account, error)
|
Accounts(context.Context, string) ([]Account, error)
|
||||||
Quote(context.Context, string, Request, []Account) (Quote, error)
|
Quote(context.Context, string, Request, []Account) (Quote, error)
|
||||||
Execute(context.Context, string, string) (RemoteOperation, error)
|
Execute(context.Context, string, string) (RemoteOperation, error)
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func (s *Service) Fetch(ctx context.Context, a *customer.Account) (Snapshot, err
|
|||||||
if err := s.ready(); err != nil {
|
if err := s.ready(); err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
subject, err := s.ensureAccounts(ctx, a)
|
subject, err := s.Identity.Subject(ctx, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Snapshot{}, err
|
return Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,19 +82,9 @@ type provider struct {
|
|||||||
status string
|
status string
|
||||||
key string
|
key string
|
||||||
namespace string
|
namespace string
|
||||||
setupCalls int
|
|
||||||
setupSubject string
|
|
||||||
setupErr error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Namespace() string { return p.namespace }
|
func (p *provider) Namespace() string { return p.namespace }
|
||||||
func (p *provider) EnsureAccounts(_ context.Context, subject string) error {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.setupCalls++
|
|
||||||
p.setupSubject = subject
|
|
||||||
return p.setupErr
|
|
||||||
}
|
|
||||||
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
func (p *provider) Accounts(context.Context, string) ([]Account, error) {
|
||||||
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
return []Account{{ID: "ledger", Currency: "EUR", Precision: 2, Available: 5000}}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package sendico
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.sendico.io/tech/sxchnge/product/payments"
|
|
||||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *Client) validCustomerAccount(a accountDTO, subject string) bool {
|
|
||||||
return validRef(a.ID) && a.ScopeRef == c.config.OrganizationID && a.Owner == "" &&
|
|
||||||
a.Type == "ACCOUNT_TYPE_ASSET" && a.LedgerRole == "ACCOUNT_ROLE_OPERATING" &&
|
|
||||||
a.Status == "ACCOUNT_STATUS_ACTIVE" && !a.AllowNegative && a.Metadata["sxchnge_subject_ref"] == subject &&
|
|
||||||
(a.Metadata["external_subject_ref"] == "" || a.Metadata["external_subject_ref"] == subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureAccounts also repairs older customers, using provider-enforced
|
|
||||||
// organization/subject/currency uniqueness for safe retries after lost responses.
|
|
||||||
func (c *Client) EnsureAccounts(ctx context.Context, subject string) error {
|
|
||||||
if !validRef(subject) {
|
|
||||||
return payments.Unavailable
|
|
||||||
}
|
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
|
||||||
var data accountListDTO
|
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing := map[string]bool{}
|
|
||||||
reconcile := map[string]bool{}
|
|
||||||
for _, a := range data.Accounts {
|
|
||||||
if a.Metadata["sxchnge_subject_ref"] != subject && a.Metadata["external_subject_ref"] != subject {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !c.validCustomerAccount(a, subject) || existing[a.Currency] {
|
|
||||||
return payments.Unavailable
|
|
||||||
}
|
|
||||||
existing[a.Currency] = true
|
|
||||||
reconcile[a.Currency] = a.Metadata["external_subject_ref"] == subject
|
|
||||||
}
|
|
||||||
missing := []string{}
|
|
||||||
for _, code := range []string{"RUB", "USDT", "USDC"} {
|
|
||||||
if !existing[code] || reconcile[code] {
|
|
||||||
missing = append(missing, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Older providers can accept the metadata but cannot prevent duplicates.
|
|
||||||
// Require the explicit capability before issuing the first create.
|
|
||||||
if !data.ExternalSubjectAccounts {
|
|
||||||
return payments.Unavailable
|
|
||||||
}
|
|
||||||
var catalog struct {
|
|
||||||
Rows []currencyDTO `json:"currencies"`
|
|
||||||
}
|
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, "/payments/fx/currencies/"+c.config.OrganizationID, nil, &catalog); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, code := range missing {
|
|
||||||
found := false
|
|
||||||
for _, currency := range catalog.Rows {
|
|
||||||
if currency.Code == code && currency.Scale != nil && *currency.Scale >= 0 && *currency.Scale <= 8 {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return merrors.OpaqueWrap(payments.Unavailable, payments.Unsupported, "default currency missing from provider catalog")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Replaying a bound create also resumes Sendico's durable wallet/route setup
|
|
||||||
// if a previous response failed after the ledger itself had been committed.
|
|
||||||
for _, code := range missing {
|
|
||||||
var response struct {
|
|
||||||
Account accountDTO `json:"account"`
|
|
||||||
}
|
|
||||||
request := createAccountDTO{Currency: code, Type: "asset", LedgerRole: "operating", Metadata: map[string]string{"external_subject_ref": subject, "sxchnge_subject_ref": subject}}
|
|
||||||
if err := c.paymentRequest(ctx, http.MethodPost, root, request, &response, http.StatusCreated); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !c.validCustomerAccount(response.Account, subject) || response.Account.Currency != code || response.Account.Metadata["external_subject_ref"] != subject {
|
|
||||||
return payments.Unavailable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
package sendico
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
|
||||||
)
|
|
||||||
|
|
||||||
type accountServer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
rows []accountDTO
|
|
||||||
posts []string
|
|
||||||
capable bool
|
|
||||||
missingCurrency bool
|
|
||||||
failCurrency string
|
|
||||||
loseResponse bool
|
|
||||||
wrongResponse bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func customerLedger(code string) accountDTO {
|
|
||||||
return accountDTO{ID: bson.NewObjectID().Hex(), ScopeRef: org, Currency: code, Type: "ACCOUNT_TYPE_ASSET", LedgerRole: "ACCOUNT_ROLE_OPERATING", Status: "ACCOUNT_STATUS_ACTIVE", Metadata: map[string]string{"sxchnge_subject_ref": subject, "external_subject_ref": subject}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *accountServer) serve(t *testing.T) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
require.NotEmpty(t, r.Header.Get("Authorization"))
|
|
||||||
var data any
|
|
||||||
switch {
|
|
||||||
case strings.Contains(r.URL.Path, "/currencies/"):
|
|
||||||
rows := []map[string]any{{"code": "RUB", "accountingScale": 2}, {"code": "USDT", "accountingScale": 6}, {"code": "USDC", "accountingScale": 6}}
|
|
||||||
if s.missingCurrency {
|
|
||||||
rows = rows[:2]
|
|
||||||
}
|
|
||||||
data = map[string]any{"currencies": rows}
|
|
||||||
case r.Method == http.MethodPost:
|
|
||||||
var request createAccountDTO
|
|
||||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
|
|
||||||
require.Equal(t, "asset", request.Type)
|
|
||||||
require.Equal(t, "operating", request.LedgerRole)
|
|
||||||
require.False(t, request.AllowNegative)
|
|
||||||
require.Equal(t, subject, request.Metadata["external_subject_ref"])
|
|
||||||
require.Equal(t, subject, request.Metadata["sxchnge_subject_ref"])
|
|
||||||
s.posts = append(s.posts, request.Currency)
|
|
||||||
if s.failCurrency == request.Currency {
|
|
||||||
s.failCurrency = ""
|
|
||||||
w.WriteHeader(503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var row accountDTO
|
|
||||||
for _, a := range s.rows {
|
|
||||||
if a.Currency == request.Currency && a.Metadata["external_subject_ref"] == subject {
|
|
||||||
row = a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if row.ID == "" {
|
|
||||||
row = customerLedger(request.Currency)
|
|
||||||
s.rows = append(s.rows, row)
|
|
||||||
}
|
|
||||||
if s.loseResponse {
|
|
||||||
s.loseResponse = false
|
|
||||||
w.WriteHeader(503)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.wrongResponse {
|
|
||||||
row.Currency = "EUR"
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
data = map[string]any{"account": row}
|
|
||||||
case strings.HasSuffix(r.URL.Path, "/balance"):
|
|
||||||
parts := strings.Split(r.URL.Path, "/")
|
|
||||||
data = map[string]any{"balance": balanceDTO{ID: parts[len(parts)-2], Money: moneyDTO{Amount: "45.00", Currency: "RUB"}}}
|
|
||||||
default:
|
|
||||||
data = map[string]any{"accounts": s.rows, "externalSubjectAccounts": s.capable}
|
|
||||||
}
|
|
||||||
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"status": "success", "data": data}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureCurrencyAccountsPreservesExistingAndRecoversPartialFailure(t *testing.T) {
|
|
||||||
for _, lost := range []bool{false, true} {
|
|
||||||
t.Run(map[bool]string{false: "partial failure", true: "lost response"}[lost], func(t *testing.T) {
|
|
||||||
s := &accountServer{capable: true, failCurrency: "USDT", loseResponse: lost}
|
|
||||||
if lost {
|
|
||||||
s.failCurrency = ""
|
|
||||||
}
|
|
||||||
server := httptest.NewServer(s.serve(t))
|
|
||||||
defer server.Close()
|
|
||||||
c, err := Open(config(t, server.URL))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.Len(t, s.rows, 1)
|
|
||||||
firstID := s.rows[0].ID
|
|
||||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.Len(t, s.rows, 3)
|
|
||||||
require.Equal(t, firstID, s.rows[0].ID)
|
|
||||||
// Bound creates are replayed to finish interrupted wallet/route setup;
|
|
||||||
// the provider must return the original ledger each time.
|
|
||||||
require.Equal(t, 3, countCurrency(s.posts, "RUB"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func countCurrency(rows []string, code string) int {
|
|
||||||
n := 0
|
|
||||||
for _, r := range rows {
|
|
||||||
if r == code {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureAccountsConcurrentRequestsUseStableSubjectBinding(t *testing.T) {
|
|
||||||
s := &accountServer{capable: true}
|
|
||||||
server := httptest.NewServer(s.serve(t))
|
|
||||||
defer server.Close()
|
|
||||||
c, err := Open(config(t, server.URL))
|
|
||||||
require.NoError(t, err)
|
|
||||||
errors := make(chan error, 12)
|
|
||||||
for range 12 {
|
|
||||||
go func() { errors <- c.EnsureAccounts(t.Context(), subject) }()
|
|
||||||
}
|
|
||||||
for range 12 {
|
|
||||||
require.NoError(t, <-errors)
|
|
||||||
}
|
|
||||||
require.Len(t, s.rows, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureAccountsRejectsUnsafeProviderStateBeforeCreating(t *testing.T) {
|
|
||||||
for _, kind := range []string{"old provider", "missing currency", "frozen", "owned", "negative", "duplicate", "wrong subject"} {
|
|
||||||
t.Run(kind, func(t *testing.T) {
|
|
||||||
s := &accountServer{capable: true}
|
|
||||||
a := customerLedger("RUB")
|
|
||||||
switch kind {
|
|
||||||
case "old provider":
|
|
||||||
s.capable = false
|
|
||||||
case "missing currency":
|
|
||||||
s.missingCurrency = true
|
|
||||||
case "frozen":
|
|
||||||
a.Status = "ACCOUNT_STATUS_FROZEN"
|
|
||||||
s.rows = []accountDTO{a}
|
|
||||||
case "owned":
|
|
||||||
a.Owner = source
|
|
||||||
s.rows = []accountDTO{a}
|
|
||||||
case "negative":
|
|
||||||
a.AllowNegative = true
|
|
||||||
s.rows = []accountDTO{a}
|
|
||||||
case "duplicate":
|
|
||||||
s.rows = []accountDTO{a, customerLedger("RUB")}
|
|
||||||
case "wrong subject":
|
|
||||||
a.Metadata["sxchnge_subject_ref"] = source
|
|
||||||
s.rows = []accountDTO{a}
|
|
||||||
}
|
|
||||||
server := httptest.NewServer(s.serve(t))
|
|
||||||
defer server.Close()
|
|
||||||
c, err := Open(config(t, server.URL))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.Empty(t, s.posts)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureAccountsValidatesCreateReceipt(t *testing.T) {
|
|
||||||
s := &accountServer{capable: true, wrongResponse: true}
|
|
||||||
server := httptest.NewServer(s.serve(t))
|
|
||||||
defer server.Close()
|
|
||||||
c, err := Open(config(t, server.URL))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Error(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.Len(t, s.posts, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureAccountsKeepsExistingBindingsAndBalances(t *testing.T) {
|
|
||||||
a := customerLedger("RUB")
|
|
||||||
delete(a.Metadata, "external_subject_ref")
|
|
||||||
s := &accountServer{capable: true, rows: []accountDTO{a}}
|
|
||||||
server := httptest.NewServer(s.serve(t))
|
|
||||||
defer server.Close()
|
|
||||||
c, err := Open(config(t, server.URL))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, c.EnsureAccounts(t.Context(), subject))
|
|
||||||
require.Equal(t, []string{"USDT", "USDC"}, s.posts)
|
|
||||||
// A subsequent read uses the original RUB ledger and its authoritative funds.
|
|
||||||
s.rows = s.rows[:1]
|
|
||||||
accounts, err := c.Accounts(t.Context(), subject)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, accounts, 1)
|
|
||||||
require.Equal(t, a.ID, accounts[0].ID)
|
|
||||||
require.EqualValues(t, 4500, accounts[0].Available)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package sendico
|
|
||||||
|
|
||||||
type accountListDTO struct {
|
|
||||||
Accounts []accountDTO `json:"accounts"`
|
|
||||||
ExternalSubjectAccounts bool `json:"externalSubjectAccounts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type createAccountDTO struct {
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Type string `json:"accountType"`
|
|
||||||
LedgerRole string `json:"role"`
|
|
||||||
AllowNegative bool `json:"allowNegative"`
|
|
||||||
Metadata map[string]string `json:"metadata"`
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,9 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
scales[row.Code] = *row.Scale
|
scales[row.Code] = *row.Scale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var data accountListDTO
|
var data struct {
|
||||||
|
Accounts []accountDTO `json:"accounts"`
|
||||||
|
}
|
||||||
root := "/ledger_accounts/" + c.config.OrganizationID
|
root := "/ledger_accounts/" + c.config.OrganizationID
|
||||||
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
if err := c.paymentRequest(ctx, http.MethodGet, root, nil, &data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -37,7 +39,7 @@ func (c *Client) Accounts(ctx context.Context, subject string) ([]payments.Accou
|
|||||||
if row.Metadata["sxchnge_subject_ref"] != subject {
|
if row.Metadata["sxchnge_subject_ref"] != subject {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !c.validCustomerAccount(row, subject) {
|
if !validRef(row.ID) || row.ScopeRef != c.config.OrganizationID || row.Owner != "" || row.Status != "ACCOUNT_STATUS_ACTIVE" || row.Type != "ACCOUNT_TYPE_ASSET" || row.LedgerRole != "ACCOUNT_ROLE_OPERATING" {
|
||||||
return nil, payments.Unavailable
|
return nil, payments.Unavailable
|
||||||
}
|
}
|
||||||
scale, ok := scales[row.Currency]
|
scale, ok := scales[row.Currency]
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ type accountDTO struct {
|
|||||||
Type string `json:"accountType"`
|
Type string `json:"accountType"`
|
||||||
LedgerRole string `json:"role"`
|
LedgerRole string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
AllowNegative bool `json:"allowNegative"`
|
|
||||||
Metadata map[string]string `json:"metadata"`
|
Metadata map[string]string `json:"metadata"`
|
||||||
}
|
}
|
||||||
type currencyDTO struct {
|
type currencyDTO struct {
|
||||||
@@ -73,7 +72,7 @@ type balanceDTO struct {
|
|||||||
Money moneyDTO `json:"balance"`
|
Money moneyDTO `json:"balance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any, accepted ...int) error {
|
func (c *Client) paymentRequest(ctx context.Context, method, path string, payload, dst any) error {
|
||||||
token, err := c.token()
|
token, err := c.token()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -99,11 +98,7 @@ func (c *Client) paymentRequest(ctx context.Context, method, path string, payloa
|
|||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
// Never forward upstream errors, credentials, or arbitrary error bodies.
|
||||||
expected := http.StatusOK
|
if res.StatusCode != http.StatusOK {
|
||||||
if len(accepted) != 0 {
|
|
||||||
expected = accepted[0]
|
|
||||||
}
|
|
||||||
if res.StatusCode != expected {
|
|
||||||
return payments.Unavailable
|
return payments.Unavailable
|
||||||
}
|
}
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
|
|||||||
@@ -130,10 +130,8 @@ This recovery retains the configured provider URL, account and replay namespace.
|
|||||||
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
layout and least-privilege identities, Mongo transactions/lease rotation, and
|
||||||
authenticated Sendico currency and ledger reads through the same loopback
|
authenticated Sendico currency and ledger reads through the same loopback
|
||||||
connection and token used by BFF. It sends no email and executes no payment.
|
connection and token used by BFF. It sends no email and executes no payment.
|
||||||
Signup and wallet access automatically ensure subject-bound RUB, USDT and USDC
|
An empty wallet is expected until dedicated subject-bound currency ledgers and
|
||||||
ledgers as described in [account setup](payments.md#automatic-currency-accounts-72).
|
saved methods are provisioned as described in [payment provisioning](payments.md).
|
||||||
Deploy the provider prerequisite and grant ledger-create permission first.
|
|
||||||
Saved funding and payout methods remain separate from currency-account setup.
|
|
||||||
|
|
||||||
## Recovery
|
## Recovery
|
||||||
|
|
||||||
|
|||||||
+7
-36
@@ -52,50 +52,22 @@ coordinated deployment of the platform's financial services.
|
|||||||
|
|
||||||
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
Reuse `api.sendico` and its existing Vault-rendered token file. Do not put provider
|
||||||
credentials or organization/source settings in Flutter. Provision the integration
|
credentials or organization/source settings in Flutter. Provision the integration
|
||||||
account's organization membership, payment-create/read, ledger-account create/read,
|
account's organization membership, payment-create/read, ledger-account/balance
|
||||||
balance read and the permissions needed to resolve its stored payment methods. Verify
|
read and the permissions needed to resolve its stored payment methods. Verify
|
||||||
those permissions in the intended environment before enabling real operations.
|
those permissions in the intended environment before enabling real operations.
|
||||||
|
|
||||||
### Automatic currency accounts (#72)
|
Through Sendico's existing operator APIs, provision one dedicated unowned
|
||||||
|
organization ledger per customer currency, with active `asset` / `operating`
|
||||||
The backend ensures **RUB, USDT and USDC** accounts after confirmed identity intake
|
semantics, no negative balance and metadata:
|
||||||
during signup, before sending the activation email. Resending activation retries
|
|
||||||
the same setup. Every authenticated wallet load also ensures the defaults, so
|
|
||||||
existing customers receive missing accounts automatically when they next open
|
|
||||||
their wallet. This is lazy backfill, not an eager scan of dormant customers.
|
|
||||||
|
|
||||||
Setup creates dedicated unowned organization ledgers through the public
|
|
||||||
`POST /ledger_accounts/{organization}` API, with active `asset` / `operating`
|
|
||||||
semantics, zero initial funds, no negative balance and metadata:
|
|
||||||
|
|
||||||
| Ledger metadata | Meaning |
|
| Ledger metadata | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
| `sxchnge_subject_ref` | Confirmed independent subject reference from sign-up |
|
||||||
| `external_subject_ref` | Same confirmed subject; provider-enforced organization/subject/currency uniqueness |
|
|
||||||
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
| `sxchnge_payout_method_ref` | Optional prevalidated same-currency saved payout method |
|
||||||
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
| `sxchnge_payout_label` | Safe customer-visible destination label, e.g. bank plus masked suffix |
|
||||||
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
| `sxchnge_funding_method_ref` | Optional same-currency saved funding source supported by the platform route |
|
||||||
|
|
||||||
Existing customer ledgers and their balances are preserved, including manually
|
These are financial-resource bindings maintained by trusted provisioning, not
|
||||||
provisioned ledgers with the original `sxchnge_subject_ref` tag. Only missing
|
|
||||||
defaults are created; existing additional currencies remain visible. Repeated
|
|
||||||
bound creates reuse the ledger and resume Sendico's durable wallet/route setup
|
|
||||||
after an interrupted response. Frozen, duplicate or incorrectly owned bindings
|
|
||||||
fail instead of creating a replacement. All required currency precision entries
|
|
||||||
are checked against the provider catalog before issuing creates. Saved funding
|
|
||||||
and payout bindings remain separate from creation of the currency accounts.
|
|
||||||
|
|
||||||
Deploy [Sendico #2968](https://git.sendico.io/tech/sendico/issues/2968) first:
|
|
||||||
Ledger (including its unique index), then Product Edge, then SXCHNGE backend and
|
|
||||||
frontend from [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The public
|
|
||||||
ledger list must advertise `externalSubjectAccounts: true` before SXCHNGE sends
|
|
||||||
any create requests. Older providers fail safely without creating accounts.
|
|
||||||
The acknowledgement assumes Ledger and Edge are deployed in that order; it
|
|
||||||
does not discover the version of a separately running Ledger. No new runtime
|
|
||||||
configuration or credentials are introduced. Existing deployments must give the
|
|
||||||
integration principal the ledger-create permission in addition to its reads.
|
|
||||||
|
|
||||||
These are financial-resource bindings maintained by server-side setup, not
|
|
||||||
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
client choices or new runtime configuration. Do not tag a pooled/integration-login
|
||||||
ledger as a customer ledger. Do not place full bank/card details or secret values
|
ledger as a customer ledger. Do not place full bank/card details or secret values
|
||||||
in labels or metadata. The BFF checks subject, organization, account type, role,
|
in labels or metadata. The BFF checks subject, organization, account type, role,
|
||||||
@@ -107,8 +79,7 @@ review action. Funding uses a quote for the provisioned stored source; it does
|
|||||||
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
not mint money or treat demo top-ups as real deposits. Additional destinations,
|
||||||
recipient onboarding and other funding instructions remain separate features.
|
recipient onboarding and other funding instructions remain separate features.
|
||||||
|
|
||||||
If setup reports a failure, the API returns an unavailable wallet rather than an
|
An empty set of provisioned ledgers is an empty wallet. Existing organization
|
||||||
empty snapshot or demo balances. Retrying the app load retries setup. Existing organization
|
|
||||||
payments and pooled balances are never imported into customer history. This
|
payments and pooled balances are never imported into customer history. This
|
||||||
integration exposes the durable operations initiated through this BFF, with live
|
integration exposes the durable operations initiated through this BFF, with live
|
||||||
provider reconciliation; independent inbound deposits/history ingestion and
|
provider reconciliation; independent inbound deposits/history ingestion and
|
||||||
|
|||||||
@@ -302,54 +302,32 @@ that contradicts displayed balances or usage without explaining the fixtures.
|
|||||||
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
- Keep unrelated accounts/history accessible during a pending operation. Do not
|
||||||
block the whole product behind an exchange spinner.
|
block the whole product behind an exchange spinner.
|
||||||
|
|
||||||
## Automatic currency accounts (task #72)
|
## Empty currency accounts (task #66)
|
||||||
|
|
||||||
New customers receive RUB, USDT and USDC accounts through backend-owned setup
|
The shared empty-account message used on Accounts and transfer screens explains
|
||||||
during signup. Existing customers receive missing defaults on their next wallet
|
that no currency accounts have been set up and directs the customer to support
|
||||||
load. The UI requires no account-creation form or support contact. During initial
|
to request setup. English and Russian no longer suggest that refreshing creates
|
||||||
loading it announces that the accounts are being prepared. Failure shows one
|
accounts or imply that automatic provisioning is underway. This follows the
|
||||||
clear load error and a **Try again** action that reruns the same idempotent setup.
|
existing support guidance for missing funding and payout setup. The application
|
||||||
An unexpected empty snapshot has its own setup-failure fallback, rather than
|
has no configured support contact channel; the message therefore provides text
|
||||||
being shown simultaneously with an error. A failed refresh retains existing
|
guidance without a nonfunctional contact button or invented contact details.
|
||||||
cards and the stale-data warning. Account-not-found copy requires loaded data.
|
|
||||||
|
|
||||||
The existing DTO → mapper → service → model → provider → UI boundaries remain
|
UX review: the copy names the missing resource and the next step, retains the
|
||||||
intact. Account creation belongs to the backend/provider, and widgets invoke the
|
existing wrapping text and accessible reading order, and does not alter loading,
|
||||||
existing provider refresh action. English and Russian use wrapping text and the
|
error or populated-state behavior. Existing manual refresh remains available for
|
||||||
existing accessible button pattern; status text uses a live region. Dedicated
|
accounts provisioned externally. This copy-only change adds no controls or
|
||||||
widget regressions cover loading, failure, retry and successful default accounts
|
journey outcomes and requires no new analytics events. Existing transaction
|
||||||
on Accounts, Top up, Exchange and Payout at 320px with 1.6× text. Native device and
|
journey signals and their production measurement limitations below still apply;
|
||||||
screen-reader QA remain a release check; tests do not claim physical-device QA.
|
support resolution and external account provisioning are not measured in-app.
|
||||||
|
|
||||||
Measurement: `sxchnge_currency_accounts_setup_total{outcome="ready|failed"}` and
|
Validation: `flutter gen-l10n`, `flutter analyze` in `frontend/app`,
|
||||||
`sxchnge_currency_accounts_setup_duration_seconds` cover the server setup attempt.
|
`make test-frontend` (217 tests), and `git diff --check` passed. No Dart source
|
||||||
`ready` is emitted only after all default bindings are confirmed, including
|
changed, so Dart formatting was not applicable. Existing responsive workspace
|
||||||
idempotent completion of provider wallet/route setup for newly bound ledgers.
|
tests cover Russian at 320px with enlarged text; this copy-only change does not
|
||||||
Each signup/resend/wallet load is a separate attempt; a retry is not a new unique
|
add a dedicated empty-state rendering or native screen-reader test. No security
|
||||||
customer conversion. The failure fraction uses all attempts as its denominator;
|
scan was requested. Delivery: [task #66](https://git.sendico.io/tech/sxchnge/issues/66),
|
||||||
the histogram measures one attempt, not time across multiple browser sessions.
|
branch `wallet-66`, [PR #68](https://git.sendico.io/tech/sxchnge/pulls/68).
|
||||||
No customer, ledger, currency, balance, credentials or form input is a metric
|
Merge and deployment remain separate.
|
||||||
label. These server metrics do not measure client abandonment or prove that a
|
|
||||||
balance card was viewed. Existing transaction journey outcomes below are reused;
|
|
||||||
no speculative button-click events are added.
|
|
||||||
|
|
||||||
Scope and rollout: [task #72](https://git.sendico.io/tech/sxchnge/issues/72), branch
|
|
||||||
`wallet-72`, [PR #73](https://git.sendico.io/tech/sxchnge/pulls/73). The provider
|
|
||||||
prerequisite and deployment order are documented in [payments](../payments.md).
|
|
||||||
Merge and deployment remain separate. Final validation is recorded in the task
|
|
||||||
and PR, including any transient failures and environmental blockers.
|
|
||||||
|
|
||||||
Final validation (2026-10-23): `flutter gen-l10n`, `flutter analyze` in
|
|
||||||
`frontend/app`, the changed-Dart formatting check, `make lint-backend`, and
|
|
||||||
`git diff --check` passed. `make test-frontend` passed all 242 tests on the current
|
|
||||||
app base, including the eight setup recovery scenarios. The full `make test`
|
|
||||||
passed with `TESTCONTAINERS_RYUK_DISABLED=true GOFLAGS='-p=2'`: earlier attempts
|
|
||||||
hit dependency DNS and Mongo/cleanup-helper startup timeouts. Disabling only the
|
|
||||||
failing helper retained every integration assertion, test timeout and race check;
|
|
||||||
each test's existing container cleanup still ran. No security scan was requested.
|
|
||||||
The live local provider catalog confirms RUB scale 2 and USDT/USDC scale 6.
|
|
||||||
Provider-side concurrency and frozen-binding checks passed against a disposable
|
|
||||||
Mongo replica set; see [Sendico PR #2970](https://git.sendico.io/tech/sendico/pulls/2970).
|
|
||||||
|
|
||||||
## Journey measurement plan
|
## Journey measurement plan
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Identity photo preview correction
|
||||||
|
|
||||||
|
Task [#69](https://git.sendico.io/tech/sxchnge/issues/69), branch `kyc-69`.
|
||||||
|
Delivery: [PR #71](https://git.sendico.io/tech/sxchnge/pulls/71).
|
||||||
|
This frontend change builds on the [identity verification journey](kyc.md)
|
||||||
|
and is independent of the birthday controls in task #67.
|
||||||
|
|
||||||
|
## Cause and correction
|
||||||
|
|
||||||
|
A synthetic valid PNG reproduced this Flutter 3.47.7 browser error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TypeError: Instance of '_UnmodifiableUint8ListView': type
|
||||||
|
'_UnmodifiableUint8ListView' is not a subtype of type 'NativeUint8List'
|
||||||
|
```
|
||||||
|
|
||||||
|
The stack reached `Uint8ListToJSUint8Array.toJS` from CanvasKit's browser
|
||||||
|
image decoder through `MemoryImage` and `ResizeImage`. File selection had
|
||||||
|
successfully decoded the original typed bytes. The `Photo` constructor then
|
||||||
|
copied them into an unmodifiable view, which was passed directly into the
|
||||||
|
preview's web decoder. Native decoding accepted that view, hiding the bug in
|
||||||
|
native-only tests. The generic image error builder presented the failure as a
|
||||||
|
damaged document even though the selected bytes were valid.
|
||||||
|
|
||||||
|
`PhotoPreview` now creates a native `Uint8List` copy for `MemoryImage` once per
|
||||||
|
selected photo. The shared domain model stays immutable, and validation,
|
||||||
|
providers, services and transport contracts remain unchanged. The preview still
|
||||||
|
requests a decode width of at most 1000 pixels and evicts its decoded cache entry
|
||||||
|
on replacement or disposal. The extra encoded-byte copy belongs to the preview
|
||||||
|
and is released with its provider/cache references; no secure-erasure guarantee
|
||||||
|
is made.
|
||||||
|
|
||||||
|
An actual preview failure now says that the preview could not be displayed and
|
||||||
|
suggests selecting the photo again. The English/Russian fallback keeps at least
|
||||||
|
the preview's usual height, can grow for long text, and has a separate live-region
|
||||||
|
semantic node. Existing replacement/removal controls stay available and selected
|
||||||
|
input is retained. This is distinct from validation's corrupt-file message.
|
||||||
|
|
||||||
|
## UX and measurement review
|
||||||
|
|
||||||
|
The outcome remains selecting readable ID and selfie photos, checking both on
|
||||||
|
review, and submitting for confirmed service acceptance. Existing KYC journey
|
||||||
|
attempts, retries, failure, submission and abandonment signals are reused. No
|
||||||
|
control-level events, image bytes, filenames, form values or raw exceptions are
|
||||||
|
added to telemetry. A selection success measures validation; it does not establish
|
||||||
|
that pixels were painted. Preview-only rendering failures are not separately
|
||||||
|
counted by existing telemetry. Server review/approval measurement and native
|
||||||
|
screen-reader QA retain the limitations documented in the original journey.
|
||||||
|
|
||||||
|
Regression tests use synthetic JPEG/PNG content only. They start and await real
|
||||||
|
image decoding outside the widget test's fake async zone, inspect the actual
|
||||||
|
rendered images, and surface stream errors so an error fallback cannot conceal a
|
||||||
|
failure. Checks cover portrait/landscape decoding, model immutability, bounded
|
||||||
|
decode width, stable provider reuse, replacement/removal cache eviction, both
|
||||||
|
review slots, returning to review, localized fallback/replacement, a 320px view
|
||||||
|
at 1.6 text scale, and error announcements isolated from surrounding instructions.
|
||||||
|
The `image` package is already a KYC runtime dependency; its direct app test
|
||||||
|
dependency is used only to create synthetic fixtures in memory.
|
||||||
|
|
||||||
|
## Validation and delivery
|
||||||
|
|
||||||
|
The original browser regression failed before the fix while its native equivalent
|
||||||
|
passed. The complete task diff was reviewed against frontend architecture, UX,
|
||||||
|
measurement, lifecycle and privacy requirements. Final validation passed:
|
||||||
|
|
||||||
|
- In `frontend/app`, `dart format --output=none --set-exit-if-changed
|
||||||
|
lib/kyc/preview.dart test/preview_test.dart`: two files, no changes.
|
||||||
|
- In `frontend/app`, `flutter analyze --no-pub`: no issues.
|
||||||
|
- In `frontend/app`, `flutter test --platform chrome test/preview_test.dart
|
||||||
|
--reporter expanded --no-pub --verbose`: all six regression tests passed.
|
||||||
|
- At the repository root, `make test-frontend`: all 228 tests passed across
|
||||||
|
the shared packages, theme example and app, including the six native preview
|
||||||
|
regressions and existing KYC journey/analytics tests.
|
||||||
|
- In `frontend/app`, `flutter build web --no-pub`: release build and Wasm
|
||||||
|
compatibility dry run succeeded.
|
||||||
|
|
||||||
|
Browser regression coverage uses Chrome; Safari and native assistive-technology
|
||||||
|
testing remain release checks. Generated localization and build code stays
|
||||||
|
ignored; the tracked app lockfile changes only `image` from transitive to direct
|
||||||
|
test dependency. The separate birthday worktree was preserved.
|
||||||
|
|
||||||
|
## Follow-up contribution review
|
||||||
|
|
||||||
|
The complete seven-file contribution was reviewed again before the authorized
|
||||||
|
merge and frontend rollout. No additional defects were found in the byte-copy
|
||||||
|
boundary, provider reuse, cache disposal, error semantics/localization, privacy,
|
||||||
|
architecture or regression coverage. No source changes were needed.
|
||||||
|
|
||||||
|
Current `main` (`ec4115d`, including the birthday, signup-delivery and account
|
||||||
|
setup changes) was merged into `kyc-69` before final verification. The two-file
|
||||||
|
format check and app analyzer passed; all six Chrome preview regressions and
|
||||||
|
all 240 tests in `make test-frontend` passed on the combined code. The original
|
||||||
|
228-test result above describes the earlier isolated branch.
|
||||||
|
|
||||||
|
The authorized rollout rebuilds only `frontend` from clean, pulled `main`, using
|
||||||
|
the repository's fresh stable-release resolver and digest-pinned local inputs.
|
||||||
|
Runtime image, HTTPS and served-bundle verification are recorded in task #69
|
||||||
|
after the rollout; generated local pins remain ignored.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
@@ -15,8 +17,12 @@ class PhotoPreview extends StatefulWidget {
|
|||||||
|
|
||||||
class _PhotoPreviewState extends State<PhotoPreview> {
|
class _PhotoPreviewState extends State<PhotoPreview> {
|
||||||
late ImageProvider _image = _provider();
|
late ImageProvider _image = _provider();
|
||||||
ImageProvider _provider() =>
|
ImageProvider _provider() => ResizeImage.resizeIfNeeded(
|
||||||
ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes));
|
1000,
|
||||||
|
null,
|
||||||
|
// Web decoders require a native typed array, not the model's read-only view.
|
||||||
|
MemoryImage(Uint8List.fromList(widget.photo.bytes)),
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
void didUpdateWidget(covariant PhotoPreview oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,7 +44,15 @@ class _PhotoPreviewState extends State<PhotoPreview> {
|
|||||||
height: 220,
|
height: 220,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
semanticLabel: widget.label,
|
semanticLabel: widget.label,
|
||||||
errorBuilder: (_, _, _) =>
|
errorBuilder: (_, _, _) => ConstrainedBox(
|
||||||
Text(AppLocalizations.of(context)!.kycErrorUnreadable),
|
constraints: const BoxConstraints(minHeight: 220),
|
||||||
|
child: Center(
|
||||||
|
child: Semantics(
|
||||||
|
container: true,
|
||||||
|
liveRegion: true,
|
||||||
|
child: Text(AppLocalizations.of(context)!.kycErrorPreview),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
"kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.",
|
||||||
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
"kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.",
|
||||||
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
"kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.",
|
||||||
|
"kycErrorPreview": "The photo preview could not be displayed. Try choosing the photo again.",
|
||||||
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
"kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.",
|
||||||
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
"kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.",
|
||||||
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
"kycErrorSession": "Your session has expired. Return to login, then check your verification status.",
|
||||||
@@ -160,10 +161,7 @@
|
|||||||
"wRecent": "Recent activity",
|
"wRecent": "Recent activity",
|
||||||
"wSeeAll": "See all",
|
"wSeeAll": "See all",
|
||||||
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
"wEmptyActivity": "Your next move starts here. Your transactions will appear below.",
|
||||||
"wEmptyAccounts": "We couldn’t finish setting up your currency accounts. Try again to continue.",
|
"wEmptyAccounts": "No currency accounts have been set up for you yet. Contact support to request account setup.",
|
||||||
"wAccountsLoading": "Getting your RUB, USDT and USDC accounts ready…",
|
|
||||||
"wAccountsFailed": "We couldn’t load your currency accounts. Try again to continue.",
|
|
||||||
"wAccountsRetry": "Try again",
|
|
||||||
"wRefresh": "Refresh",
|
"wRefresh": "Refresh",
|
||||||
"wSource": "From account",
|
"wSource": "From account",
|
||||||
"wDestination": "To account",
|
"wDestination": "To account",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
"kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.",
|
||||||
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
"kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.",
|
||||||
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
"kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.",
|
||||||
|
"kycErrorPreview": "Не удалось показать фото. Попробуйте выбрать его ещё раз.",
|
||||||
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
"kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.",
|
||||||
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
"kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш документ.",
|
||||||
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
"kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.",
|
||||||
@@ -160,10 +161,7 @@
|
|||||||
"wRecent": "Последние операции",
|
"wRecent": "Последние операции",
|
||||||
"wSeeAll": "Смотреть все",
|
"wSeeAll": "Смотреть все",
|
||||||
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
"wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.",
|
||||||
"wEmptyAccounts": "Не удалось завершить настройку валютных счетов. Повторите попытку, чтобы продолжить.",
|
"wEmptyAccounts": "У вас ещё нет валютных счетов. Обратитесь в поддержку за помощью с их открытием.",
|
||||||
"wAccountsLoading": "Подготавливаем ваши счета в RUB, USDT и USDC…",
|
|
||||||
"wAccountsFailed": "Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.",
|
|
||||||
"wAccountsRetry": "Повторить попытку",
|
|
||||||
"wRefresh": "Обновить",
|
"wRefresh": "Обновить",
|
||||||
"wSource": "Со счёта",
|
"wSource": "Со счёта",
|
||||||
"wDestination": "На счёт",
|
"wDestination": "На счёт",
|
||||||
|
|||||||
@@ -65,34 +65,15 @@ class AccountsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
if (p.loading) const LinearProgressIndicator(),
|
if (p.loading) const LinearProgressIndicator(),
|
||||||
if ((p.loading || (!p.failed && p.data == null)) &&
|
if (p.failed)
|
||||||
accounts.isEmpty)
|
|
||||||
Semantics(liveRegion: true, child: Text(l.wAccountsLoading)),
|
|
||||||
if (p.failed && accounts.isNotEmpty)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
child: Text(l.wUnavailable, style: TextStyle(color: c.error)),
|
||||||
),
|
),
|
||||||
if (!p.loading &&
|
if (!p.loading && accounts.isEmpty) Text(l.wEmptyAccounts),
|
||||||
accounts.isEmpty &&
|
|
||||||
(p.failed || p.data != null)) ...[
|
|
||||||
Semantics(
|
|
||||||
liveRegion: true,
|
|
||||||
child: Text(p.failed ? l.wAccountsFailed : l.wEmptyAccounts),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: p.refresh,
|
|
||||||
child: Text(l.wAccountsRetry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (accountId != null &&
|
if (accountId != null &&
|
||||||
p.account(accountId) == null &&
|
p.account(accountId) == null &&
|
||||||
!p.loading &&
|
!p.loading)
|
||||||
!p.failed &&
|
|
||||||
p.data != null)
|
|
||||||
Text(l.wNotFound),
|
Text(l.wNotFound),
|
||||||
if (selected != null) ...[
|
if (selected != null) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -256,22 +256,10 @@ class TransferPageState extends State<TransferPage> {
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
if (accounts.isEmpty) ...[
|
if (accounts.isEmpty) ...[
|
||||||
Semantics(
|
Text(wallet.loading ? l.wWorking : l.wEmptyAccounts),
|
||||||
liveRegion: true,
|
|
||||||
child: Text(
|
|
||||||
wallet.loading ||
|
|
||||||
(!wallet.failed && wallet.data == null)
|
|
||||||
? l.wAccountsLoading
|
|
||||||
: wallet.failed
|
|
||||||
? l.wAccountsFailed
|
|
||||||
: l.wEmptyAccounts,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!wallet.loading &&
|
|
||||||
(wallet.failed || wallet.data != null))
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: wallet.refresh,
|
onPressed: wallet.loading ? null : wallet.refresh,
|
||||||
child: Text(l.wAccountsRetry),
|
child: Text(l.wRefresh),
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ dependencies:
|
|||||||
go_router: ^18.0.1
|
go_router: ^18.0.1
|
||||||
country_picker: ^2.0.28
|
country_picker: ^2.0.28
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
image: ^4.9.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
|
||||||
|
|
||||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
|
||||||
|
|
||||||
import 'package:sxchnge_app/main.dart';
|
|
||||||
import 'package:sxchnge_app/providers/locale.dart';
|
|
||||||
|
|
||||||
class SetupWallet extends DummyWalletService {
|
|
||||||
SetupWallet(String locale)
|
|
||||||
: super(
|
|
||||||
DummyIdentityService(delay: Duration.zero),
|
|
||||||
delay: Duration.zero,
|
|
||||||
initialLocale: locale,
|
|
||||||
);
|
|
||||||
|
|
||||||
Completer<WalletSnapshot> pending = Completer<WalletSnapshot>();
|
|
||||||
int calls = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<WalletSnapshot> fetch() {
|
|
||||||
calls++;
|
|
||||||
return pending.future;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
WalletSnapshot readyAccounts() => WalletSnapshot(
|
|
||||||
accounts: [
|
|
||||||
for (final currency in ['RUB', 'USDT', 'USDC'])
|
|
||||||
CurrencyAccount(
|
|
||||||
id: currency.toLowerCase(),
|
|
||||||
currency: currency,
|
|
||||||
precision: currency == 'RUB' ? 2 : 6,
|
|
||||||
available: 0,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
operations: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
for (final locale in ['en', 'ru']) {
|
|
||||||
for (final path in ['/accounts', '/top-up', '/exchange', '/payout']) {
|
|
||||||
testWidgets(
|
|
||||||
'$path recovers account setup in $locale at narrow width and large text',
|
|
||||||
(t) async {
|
|
||||||
t.view.physicalSize = const Size(320, 1100);
|
|
||||||
t.view.devicePixelRatio = 1;
|
|
||||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
|
||||||
t.platformDispatcher.defaultRouteNameTestValue = path;
|
|
||||||
addTearDown(t.view.resetPhysicalSize);
|
|
||||||
addTearDown(t.view.resetDevicePixelRatio);
|
|
||||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
|
||||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
|
||||||
final wallet = SetupWallet(locale);
|
|
||||||
await t.pumpWidget(
|
|
||||||
SxchngeApp(
|
|
||||||
walletService: wallet,
|
|
||||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await t.pump();
|
|
||||||
await t.pump();
|
|
||||||
final loading = locale == 'en'
|
|
||||||
? 'Getting your RUB, USDT and USDC accounts ready…'
|
|
||||||
: 'Подготавливаем ваши счета в RUB, USDT и USDC…';
|
|
||||||
final failed = locale == 'en'
|
|
||||||
? 'We couldn’t load your currency accounts. Try again to continue.'
|
|
||||||
: 'Не удалось загрузить валютные счета. Повторите попытку, чтобы продолжить.';
|
|
||||||
final retry = locale == 'en' ? 'Try again' : 'Повторить попытку';
|
|
||||||
expect(find.text(loading), findsOneWidget);
|
|
||||||
expect(find.textContaining('Contact support'), findsNothing);
|
|
||||||
expect(t.takeException(), isNull);
|
|
||||||
wallet.pending.completeError(
|
|
||||||
const WalletException(WalletError.unavailable),
|
|
||||||
);
|
|
||||||
await t.pumpAndSettle();
|
|
||||||
expect(find.text(failed), findsOneWidget);
|
|
||||||
expect(find.text(loading), findsNothing);
|
|
||||||
expect(find.text(retry), findsOneWidget);
|
|
||||||
expect(t.takeException(), isNull);
|
|
||||||
wallet.pending = Completer<WalletSnapshot>();
|
|
||||||
await t.ensureVisible(find.text(retry));
|
|
||||||
await t.tap(find.text(retry));
|
|
||||||
await t.pump();
|
|
||||||
expect(wallet.calls, 2);
|
|
||||||
expect(find.text(loading), findsOneWidget);
|
|
||||||
expect(find.text(failed), findsNothing);
|
|
||||||
wallet.pending.complete(readyAccounts());
|
|
||||||
await t.pumpAndSettle();
|
|
||||||
expect(find.text(loading), findsNothing);
|
|
||||||
expect(find.text(failed), findsNothing);
|
|
||||||
expect(find.text('RUB'), findsWidgets);
|
|
||||||
expect(t.takeException(), isNull);
|
|
||||||
await t.pumpWidget(const SizedBox.shrink());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:image/image.dart' as images;
|
||||||
|
|
||||||
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/kyc/photo.dart';
|
||||||
|
import 'package:sxchnge_app/kyc/preview.dart';
|
||||||
|
|
||||||
|
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
Widget host(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start decoding outside FakeAsync as well as waiting outside it, including
|
||||||
|
// browser codec callbacks that otherwise remain pending in the test zone.
|
||||||
|
Future<void> show(WidgetTester tester, Widget widget) async {
|
||||||
|
await tester.runAsync(() => tester.pumpWidget(widget));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pumpAndSettle alone does not wait for image codecs. Listen to the actual
|
||||||
|
// preview streams so a hidden errorBuilder cannot make this regression pass.
|
||||||
|
Future<void> decoded(WidgetTester tester, {bool succeeds = true}) async {
|
||||||
|
final providers = tester
|
||||||
|
.widgetList<Image>(find.byType(Image))
|
||||||
|
.map((image) => image.image)
|
||||||
|
.toList();
|
||||||
|
expect(providers, isNotEmpty);
|
||||||
|
for (final provider in providers) {
|
||||||
|
Object? renderError;
|
||||||
|
StackTrace? renderStack;
|
||||||
|
await tester.runAsync(() async {
|
||||||
|
final stream = provider.resolve(ImageConfiguration.empty);
|
||||||
|
final ready = Completer<void>();
|
||||||
|
final listener = ImageStreamListener(
|
||||||
|
(info, _) {
|
||||||
|
info.dispose();
|
||||||
|
if (!ready.isCompleted) ready.complete();
|
||||||
|
},
|
||||||
|
onError: (Object error, StackTrace? stack) {
|
||||||
|
renderError = error;
|
||||||
|
renderStack = stack;
|
||||||
|
if (!ready.isCompleted) ready.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
stream.addListener(listener);
|
||||||
|
try {
|
||||||
|
await ready.future.timeout(const Duration(seconds: 15));
|
||||||
|
} finally {
|
||||||
|
stream.removeListener(listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
renderError,
|
||||||
|
succeeds ? isNull : isNotNull,
|
||||||
|
reason: succeeds ? '$renderError\n$renderStack' : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Uint8List png;
|
||||||
|
late Uint8List jpeg;
|
||||||
|
setUpAll(() {
|
||||||
|
// Synthetic, non-sensitive portrait and landscape photos in both formats.
|
||||||
|
png = images.encodePng(images.Image(width: 1200, height: 1600));
|
||||||
|
jpeg = images.encodeJpg(images.Image(width: 1600, height: 1200));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final format in ['PNG', 'JPEG']) {
|
||||||
|
testWidgets('accepted $format renders from an immutable photo model', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final source = Uint8List.fromList(format == 'PNG' ? png : jpeg);
|
||||||
|
final photo = (await tester.runAsync(
|
||||||
|
() => FilePhotoService.validate(source),
|
||||||
|
))!;
|
||||||
|
final originalFirstByte = photo.bytes.first;
|
||||||
|
source[0] = 0;
|
||||||
|
expect(photo.bytes.first, originalFirstByte);
|
||||||
|
expect(() => photo.bytes[0] = 0, throwsUnsupportedError);
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(PhotoPreview(photo: photo, label: 'ID document photo')),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
final raw = tester.widget<RawImage>(find.byType(RawImage));
|
||||||
|
expect(raw.image, isNotNull);
|
||||||
|
expect(raw.image!.width, lessThanOrEqualTo(1000));
|
||||||
|
expect(
|
||||||
|
tester.widget<Image>(find.byType(Image)).semanticLabel,
|
||||||
|
'ID document photo',
|
||||||
|
);
|
||||||
|
expect(find.textContaining('damaged'), findsNothing);
|
||||||
|
expect(find.textContaining('could not be displayed'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('replacement and removal evict sensitive preview cache entries', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final first = Photo(png);
|
||||||
|
await show(tester, host(PhotoPreview(photo: first, label: 'ID')));
|
||||||
|
await decoded(tester);
|
||||||
|
final initialProvider = tester.widget<Image>(find.byType(Image)).image;
|
||||||
|
final initialKey = await initialProvider.obtainKey(
|
||||||
|
ImageConfiguration.empty,
|
||||||
|
);
|
||||||
|
final cache = PaintingBinding.instance.imageCache;
|
||||||
|
expect(cache.statusForKey(initialKey).keepAlive, isTrue);
|
||||||
|
await show(tester, host(PhotoPreview(photo: first, label: 'Document')));
|
||||||
|
await decoded(tester);
|
||||||
|
expect(
|
||||||
|
identical(
|
||||||
|
tester.widget<Image>(find.byType(Image)).image,
|
||||||
|
initialProvider,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await show(tester, host(PhotoPreview(photo: Photo(jpeg), label: 'ID')));
|
||||||
|
await decoded(tester);
|
||||||
|
expect(cache.statusForKey(initialKey).untracked, isTrue);
|
||||||
|
final replacement = tester.widget<Image>(find.byType(Image)).image;
|
||||||
|
final replacementKey = await replacement.obtainKey(
|
||||||
|
ImageConfiguration.empty,
|
||||||
|
);
|
||||||
|
expect(cache.statusForKey(replacementKey).keepAlive, isTrue);
|
||||||
|
expect(tester.widget<RawImage>(find.byType(RawImage)).image!.width, 1000);
|
||||||
|
await show(tester, host(const SizedBox()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(cache.statusForKey(replacementKey).untracked, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ID and selfie both render on review and after returning', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final passport = Photo(png);
|
||||||
|
final selfie = Photo(jpeg);
|
||||||
|
for (var visit = 0; visit < 2; visit++) {
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
PhotoPanel(
|
||||||
|
kind: PhotoKind.passport,
|
||||||
|
photo: passport,
|
||||||
|
review: true,
|
||||||
|
),
|
||||||
|
PhotoPanel(kind: PhotoKind.selfie, photo: selfie, review: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
final previews = tester.widgetList<RawImage>(find.byType(RawImage));
|
||||||
|
expect(previews.length, 2);
|
||||||
|
expect(previews.every((image) => image.image != null), isTrue);
|
||||||
|
expect(find.text('Choose a different photo'), findsNothing);
|
||||||
|
await show(tester, host(const SizedBox()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final locale in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'$locale preview failure explains recovery and can be replaced',
|
||||||
|
(tester) async {
|
||||||
|
tester.view.physicalSize = const Size(320, 900);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||||
|
var replacements = 0;
|
||||||
|
var removals = 0;
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
PhotoPanel(
|
||||||
|
kind: PhotoKind.passport,
|
||||||
|
photo: Photo(Uint8List.fromList([1, 2, 3])),
|
||||||
|
onPick: () => replacements++,
|
||||||
|
onRemove: () => removals++,
|
||||||
|
),
|
||||||
|
locale: Locale(locale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester, succeeds: false);
|
||||||
|
final l = AppLocalizations.of(tester.element(find.byType(PhotoPanel)))!;
|
||||||
|
expect(find.text(l.kycErrorPreview), findsOneWidget);
|
||||||
|
expect(find.text(l.kycErrorUnreadable), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(find.text(l.kycErrorPreview)),
|
||||||
|
matchesSemantics(isLiveRegion: true, label: l.kycErrorPreview),
|
||||||
|
);
|
||||||
|
final replace = find.byKey(const ValueKey('pick-passport'));
|
||||||
|
await tester.ensureVisible(replace);
|
||||||
|
await tester.tap(replace);
|
||||||
|
final remove = find.text(l.kycRemove);
|
||||||
|
await tester.ensureVisible(remove);
|
||||||
|
await tester.tap(remove);
|
||||||
|
expect(replacements, 1);
|
||||||
|
expect(removals, 1);
|
||||||
|
await show(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
PhotoPanel(kind: PhotoKind.passport, photo: Photo(jpeg)),
|
||||||
|
locale: Locale(locale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await decoded(tester);
|
||||||
|
expect(find.text(l.kycErrorPreview), findsNothing);
|
||||||
|
expect(tester.widget<RawImage>(find.byType(RawImage)).image, isNotNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user