481 lines
20 KiB
Go
481 lines
20 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.futuresens.co.uk/futuresens/cmstypes"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/config"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/creditcall"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
|
|
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
|
|
)
|
|
|
|
type failedCreditCallBody struct{ closed *bool }
|
|
|
|
func (b failedCreditCallBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
|
|
func (b failedCreditCallBody) Close() error { *b.closed = true; return nil }
|
|
|
|
func TestCreditCallConfirmationReadFailureAndHTTPStatus(t *testing.T) {
|
|
for _, failFirstRead := range []bool{false, true} {
|
|
calls, receipts, firstClosed := 0, 0, false
|
|
app := &App{cfg: &config.ConfigRec{}, creditCallReceipt: func(string) { receipts++ }}
|
|
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
|
calls++
|
|
if r.URL.Path != "/confirm-transaction/" {
|
|
t.Errorf("confirmation path=%s", r.URL.Path)
|
|
}
|
|
deadline, ok := r.Context().Deadline()
|
|
if !ok || time.Until(deadline) < 299*time.Second {
|
|
t.Errorf("confirmation call has no fresh 300-second deadline: %v", deadline)
|
|
}
|
|
var body io.ReadCloser = io.NopCloser(strings.NewReader(chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"})))
|
|
if failFirstRead && calls == 1 {
|
|
body = failedCreditCallBody{closed: &firstClosed}
|
|
}
|
|
// Legacy confirmation ignores HTTP status when the body can be read.
|
|
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: body}, nil
|
|
})
|
|
outcome := app.executeCreditCallSale(cmstypes.TransactionRec{AmountMinorUnits: "1234"}, func(*http.Client) (creditcall.TransactionResultXML, error) {
|
|
return creditcall.TransactionResultXML{Entries: []creditcall.EntryXML{{Key: types.TransactionResult, Value: "Approved"}, {Key: types.Reference, Value: "ref"}}}, nil
|
|
})
|
|
wantCalls := 1
|
|
if failFirstRead {
|
|
wantCalls = 2
|
|
}
|
|
if calls != wantCalls || receipts != 1 || !outcome.Approved || (failFirstRead && !firstClosed) {
|
|
t.Errorf("readFailure=%v calls/receipts/approved/closed=%d/%d/%v/%v", failFirstRead, calls, receipts, outcome.Approved, firstClosed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreditCallStreamFailureNeverRestartsOrConfirms(t *testing.T) {
|
|
calls := 0
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if r.URL.Path != "/start-transaction-stream/" {
|
|
t.Errorf("unexpected fallback/confirmation %s", r.URL.Path)
|
|
}
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n{partial")
|
|
})
|
|
app.creditCallReceipt = func(string) { t.Error("invalid upstream stream printed receipt") }
|
|
recorder := httptest.NewRecorder()
|
|
app.salePayment(recorder, saleRequest(true))
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
if calls != 1 || len(frames) != 2 || frames[1].Result == nil || frames[1].Result.Outcome != "error" || frames[1].Result.HTTPStatus != 502 {
|
|
t.Errorf("malformed stream calls=%d frames=%+v", calls, frames)
|
|
}
|
|
}
|
|
|
|
func TestCreditCallStructuredFieldsExcludeRawData(t *testing.T) {
|
|
result := (creditCallSaleOutcome{Approved: true, Payment: creditcall.PaymentResult{Fields: map[string]string{
|
|
types.Reference: "ref", types.PanMasked: "1234567890123456", types.CardHash: "hash", types.CardReference: "card-ref",
|
|
"PAN": "pan-secret", "CVV": "cvv-secret", "PIN": "pin-secret", "TRACK_DATA": "track-secret", types.ReceiptDataCardholder: "receipt-secret",
|
|
}}}).streamResult()
|
|
body, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{"1234567890123456", "pan-secret", "cvv-secret", "pin-secret", "track-secret", "receipt-secret", "/successful", "/unsuccessful"} {
|
|
if bytes.Contains(body, []byte(forbidden)) {
|
|
t.Errorf("structured result exposed %q", forbidden)
|
|
}
|
|
}
|
|
if result.CardHash != "hash" || result.CardReference != "card-ref" || result.TransactionReference != "ref" {
|
|
t.Errorf("required identifiers missing: %+v", result)
|
|
}
|
|
}
|
|
|
|
type blockedPaymentWriter struct {
|
|
*httptest.ResponseRecorder
|
|
started chan struct{}
|
|
release chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (w *blockedPaymentWriter) Write(data []byte) (int, error) {
|
|
w.once.Do(func() { close(w.started) })
|
|
<-w.release
|
|
return w.ResponseRecorder.Write(data)
|
|
}
|
|
|
|
func TestCreditCallBackpressureCannotBlockFinalizationOrLoseFinal(t *testing.T) {
|
|
nativeRelease, receiptPrinted := make(chan struct{}), make(chan struct{})
|
|
var nativeOnce, deliveryOnce sync.Once
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/confirm-transaction/" {
|
|
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved"}))
|
|
return
|
|
}
|
|
line := "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n"
|
|
io.WriteString(w, line)
|
|
w.(http.Flusher).Flush()
|
|
<-nativeRelease
|
|
for i := 0; i < 1000; i++ {
|
|
io.WriteString(w, line)
|
|
}
|
|
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
|
|
})
|
|
app.creditCallReceipt = func(string) { close(receiptPrinted) }
|
|
writer := &blockedPaymentWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
|
|
t.Cleanup(func() {
|
|
nativeOnce.Do(func() { close(nativeRelease) })
|
|
deliveryOnce.Do(func() { close(writer.release) })
|
|
})
|
|
done := make(chan struct{})
|
|
go func() { app.salePayment(writer, saleRequest(true)); close(done) }()
|
|
select {
|
|
case <-writer.started:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("progress was not live")
|
|
}
|
|
nativeOnce.Do(func() { close(nativeRelease) })
|
|
select {
|
|
case <-receiptPrinted:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("blocked writer stopped financial finalization")
|
|
}
|
|
deliveryOnce.Do(func() { close(writer.release) })
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("stream did not deliver final")
|
|
}
|
|
frames := decodePaymentStream(t, writer.Body)
|
|
if len(frames) != 130 || frames[len(frames)-1].Result == nil || frames[len(frames)-1].Result.Outcome != "approved" {
|
|
t.Errorf("backpressure frames=%d, last=%+v, want 129 statuses and final approval", len(frames), frames[len(frames)-1])
|
|
}
|
|
}
|
|
|
|
type creditCallRoundTrip func(*http.Request) (*http.Response, error)
|
|
|
|
func (f creditCallRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
|
|
|
func chipDNAFixture(t *testing.T, fields map[string]string) string {
|
|
t.Helper()
|
|
var result creditcall.TransactionResultXML
|
|
for key, value := range fields {
|
|
result.Entries = append(result.Entries, creditcall.EntryXML{Key: key, Value: value})
|
|
}
|
|
data, err := xml.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func newCreditCallTestApp(t *testing.T, handler http.HandlerFunc) *App {
|
|
t.Helper()
|
|
server := httptest.NewServer(handler)
|
|
t.Cleanup(server.Close)
|
|
target, err := url.Parse(server.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{TimeoutSeconds: 1},
|
|
creditCallTransport: creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
|
clone := r.Clone(r.Context())
|
|
clone.URL.Scheme, clone.URL.Host = target.Scheme, target.Host
|
|
return server.Client().Transport.RoundTrip(clone)
|
|
}),
|
|
}
|
|
}
|
|
|
|
func saleRequest(stream bool) *http.Request {
|
|
if stream {
|
|
r := httptest.NewRequest(http.MethodPost, "/api/payment/sale", strings.NewReader(`{"amount":1234}`))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
return r
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "/takepayment", strings.NewReader(`<TransactionPayload><Amount>1234</Amount><TransactionType>Sale</TransactionType></TransactionPayload>`))
|
|
r.Header.Set("Content-Type", "text/xml")
|
|
return r
|
|
}
|
|
|
|
func TestCreditCallLegacyAndStreamingSaleParity(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name, startResult, confirmation string
|
|
confirms int
|
|
approved bool
|
|
receipt string
|
|
}{
|
|
{"approved", "Approved", "Approved", 1, true, "confirm receipt"},
|
|
{"declined", "Declined", "", 0, false, "start receipt"},
|
|
{"cancelled", "Cancelled", "", 0, false, "start receipt"},
|
|
{"timeout", "TIMEOUT", "", 0, false, "start receipt"},
|
|
{"confirmation declined", "Approved", "Declined", 1, false, "confirm receipt"},
|
|
{"confirmation omissions", "Approved", "omitted", 1, true, "start receipt"},
|
|
{"malformed confirmation", "Approved", "malformed", 1, true, "start receipt"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fields := map[string]string{types.TransactionResult: test.startResult, types.Reference: "native-ref",
|
|
types.CardType: "Visa", types.PanMasked: "************1234", types.ExpiryDate: "1228",
|
|
types.CardHash: "hash", types.CardReference: "card-ref", types.ReceiptDataCardholder: "start receipt"}
|
|
var legacy cmstypes.ResponseRec
|
|
for _, streaming := range []bool{false, true} {
|
|
var starts, confirms atomic.Int32
|
|
var receipts []string
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/start-transaction/":
|
|
starts.Add(1)
|
|
io.WriteString(w, chipDNAFixture(t, fields))
|
|
case "/start-transaction-stream/":
|
|
starts.Add(1)
|
|
var input struct{ Amount, TransactionType string }
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.TransactionType != "Sale" {
|
|
t.Errorf("stream start input = %+v, error %v", input, err)
|
|
}
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n")
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
|
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
|
|
case "/confirm-transaction/":
|
|
confirms.Add(1)
|
|
var input creditcall.ConfirmTransactionRequest
|
|
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil || input.Amount != "1234" || input.Reference != "native-ref" {
|
|
t.Errorf("confirmation input = %+v, error %v", input, err)
|
|
}
|
|
switch test.confirmation {
|
|
case "omitted":
|
|
io.WriteString(w, "<TransactionResult/>")
|
|
case "malformed":
|
|
io.WriteString(w, "<TransactionResult><Entry>")
|
|
default:
|
|
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.confirmation, types.ReceiptDataCardholder: "confirm receipt"}))
|
|
}
|
|
default:
|
|
t.Errorf("unexpected ChipDNA path %q", r.URL.Path)
|
|
}
|
|
})
|
|
app.creditCallReceipt = func(receipt string) { receipts = append(receipts, receipt) }
|
|
if got := app.creditCallClient().Timeout; got != 300*time.Second {
|
|
t.Fatalf("per-call timeout = %v, want 300s", got)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
if streaming {
|
|
app.salePayment(recorder, saleRequest(true))
|
|
} else {
|
|
app.takePayment(recorder, saleRequest(false))
|
|
}
|
|
if starts.Load() != 1 || int(confirms.Load()) != test.confirms {
|
|
t.Errorf("stream=%v starts/confirms=%d/%d, want 1/%d", streaming, starts.Load(), confirms.Load(), test.confirms)
|
|
}
|
|
if !reflect.DeepEqual(receipts, []string{test.receipt}) {
|
|
t.Errorf("stream=%v receipts=%v, want [%s]", streaming, receipts, test.receipt)
|
|
}
|
|
if !streaming {
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.HasPrefix(legacy.Data, "/successful") != test.approved {
|
|
t.Errorf("legacy result = %+v, approved want %v", legacy, test.approved)
|
|
}
|
|
continue
|
|
}
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
if len(frames) != 3 || frames[0].Code != paymentstatus.PresentCard || frames[1].Code != paymentstatus.PleaseWait || frames[2].Result == nil {
|
|
t.Fatalf("frames = %+v, want two ordered hints and one final", frames)
|
|
}
|
|
final := frames[2].Result
|
|
if final.HTTPStatus != http.StatusOK || final.Status != legacy.Status || (final.Outcome == "approved") != test.approved {
|
|
t.Errorf("structured final = %+v, legacy = %+v", final, legacy)
|
|
}
|
|
parsed, err := url.Parse(legacy.Data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if test.approved {
|
|
if final.TransactionReference != "native-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1234" || final.CardHash != "hash" || final.CardReference != "card-ref" || final.ExpiryDate != "1228" {
|
|
t.Errorf("retained structured fields = %+v", final)
|
|
}
|
|
} else if final.Message != parsed.Query().Get("Description") {
|
|
t.Errorf("structured message = %q, legacy description = %q", final.Message, parsed.Query().Get("Description"))
|
|
}
|
|
if frames[2].Response != nil {
|
|
t.Error("CreditCall stream included legacy response")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreditCallConfirmationRetryAndFailureParity(t *testing.T) {
|
|
for _, streaming := range []bool{false, true} {
|
|
var attempts int
|
|
var times []time.Time
|
|
app := &App{isPayment: true, creditCallStreamEnabled: true, cfg: &config.ConfigRec{}}
|
|
app.creditCallReceipt = func(string) { t.Error("transport-failed confirmation must not print a receipt") }
|
|
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) {
|
|
if r.Context().Err() != nil {
|
|
t.Error("transaction context was cancelled")
|
|
}
|
|
if r.URL.Path == "/confirm-transaction/" {
|
|
attempts++
|
|
times = append(times, time.Now())
|
|
return nil, errors.New("test transport failure")
|
|
}
|
|
body := chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.Reference: "native-ref"})
|
|
if streaming {
|
|
body = "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"native-ref\"}}\n"
|
|
}
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
|
})
|
|
recorder := httptest.NewRecorder()
|
|
if streaming {
|
|
app.salePayment(recorder, saleRequest(true))
|
|
} else {
|
|
app.takePayment(recorder, saleRequest(false))
|
|
}
|
|
if attempts != 2 {
|
|
t.Fatalf("stream=%v confirms=%d, want 2", streaming, attempts)
|
|
}
|
|
if times[1].Sub(times[0]) < 2*time.Second {
|
|
t.Errorf("retry delay=%v, want at least 2s", times[1].Sub(times[0]))
|
|
}
|
|
if streaming {
|
|
frames := decodePaymentStream(t, recorder.Body)
|
|
if len(frames) != 1 || frames[0].Result.HTTPStatus != 502 || frames[0].Result.Status.Code != 500 || frames[0].Result.Message != "Transaction error" {
|
|
t.Fatalf("confirmation failure frames=%+v", frames)
|
|
}
|
|
} else if recorder.Code != 502 {
|
|
t.Errorf("legacy confirm failure HTTP=%d, want 502", recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreditCallLegacyMalformedStartAndPreauth(t *testing.T) {
|
|
for _, test := range []struct{ name, path, result, transactionType string }{
|
|
{"malformed sale", "/takepayment", "malformed", ""},
|
|
{"approved account verification", "/takepreauth", "Approved", types.AccountVerificationType},
|
|
{"declined preauth", "/takepreauth", "Declined", "Sale"},
|
|
{"malformed preauth", "/takepreauth", "malformed", ""},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
calls, prints := 0, 0
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if r.URL.Path != "/start-transaction/" {
|
|
t.Errorf("legacy/preauth path=%s", r.URL.Path)
|
|
}
|
|
if test.result == "malformed" {
|
|
io.WriteString(w, "<TransactionResult><Entry>")
|
|
return
|
|
}
|
|
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: test.result, types.TransactionType: test.transactionType}))
|
|
})
|
|
app.creditCallReceipt = func(string) { prints++ }
|
|
recorder := httptest.NewRecorder()
|
|
request := saleRequest(false)
|
|
if test.path == "/takepreauth" {
|
|
app.takePreauthorization(recorder, request)
|
|
} else {
|
|
app.takePayment(recorder, request)
|
|
}
|
|
var response cmstypes.ResponseRec
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recorder.Code != 200 || calls != 1 || prints != 1 {
|
|
t.Errorf("legacy HTTP/calls/receipts=%d/%d/%d", recorder.Code, calls, prints)
|
|
}
|
|
if test.result == "malformed" && (response.Status.Code != 0 || response.Data != "/unsuccessful?Description=&MsgType=") {
|
|
t.Errorf("malformed legacy result=%+v", response)
|
|
}
|
|
if test.result == "Approved" && !strings.HasPrefix(response.Data, "/successful?") {
|
|
t.Errorf("approved preauth=%+v", response)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChipDNAStreamRejectsIncompleteOrInvalidFrames(t *testing.T) {
|
|
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\"}\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"error\",\"error\":\"sensitive diagnostic\"}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\"}}", "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"Approved\"}\n"} {
|
|
client := &http.Client{Transport: creditCallRoundTrip(func(*http.Request) (*http.Response, error) {
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil
|
|
})}
|
|
_, err := callChipDNAStream(client, 1234, func(code string) { t.Errorf("invalid stream produced status %q", code) })
|
|
if err == nil {
|
|
t.Errorf("callChipDNAStream(%q) succeeded, want error", body)
|
|
}
|
|
if err != nil && strings.Contains(err.Error(), "sensitive diagnostic") {
|
|
t.Error("raw diagnostic leaked")
|
|
}
|
|
}
|
|
}
|
|
|
|
type disconnectedPaymentWriter struct {
|
|
header http.Header
|
|
once sync.Once
|
|
failed chan struct{}
|
|
writes atomic.Int32
|
|
}
|
|
|
|
func (w *disconnectedPaymentWriter) Header() http.Header { return w.header }
|
|
func (w *disconnectedPaymentWriter) WriteHeader(int) {}
|
|
func (w *disconnectedPaymentWriter) Flush() {}
|
|
func (w *disconnectedPaymentWriter) Write([]byte) (int, error) {
|
|
w.writes.Add(1)
|
|
w.once.Do(func() { close(w.failed) })
|
|
return 0, io.ErrClosedPipe
|
|
}
|
|
|
|
func TestCreditCallDisconnectStillConfirmsAndPrints(t *testing.T) {
|
|
release := make(chan struct{})
|
|
var confirms atomic.Int32
|
|
var receipts atomic.Int32
|
|
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/confirm-transaction/" {
|
|
confirms.Add(1)
|
|
io.WriteString(w, chipDNAFixture(t, map[string]string{types.TransactionResult: "Approved", types.ReceiptDataCardholder: "receipt"}))
|
|
return
|
|
}
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"CardRequested\"}\n")
|
|
w.(http.Flusher).Flush()
|
|
<-release
|
|
io.WriteString(w, "{\"type\":\"status\",\"source\":\"UPDATE\",\"value\":\"OnlineAuthCompleted\"}\n")
|
|
io.WriteString(w, "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"Approved\",\"REFERENCE\":\"ref\"}}\n")
|
|
})
|
|
app.creditCallReceipt = func(receipt string) {
|
|
if receipt != "receipt" {
|
|
t.Errorf("receipt=%q", receipt)
|
|
}
|
|
receipts.Add(1)
|
|
}
|
|
writer := &disconnectedPaymentWriter{header: make(http.Header), failed: make(chan struct{})}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan struct{})
|
|
go func() { app.salePayment(writer, saleRequest(true).WithContext(ctx)); close(done) }()
|
|
select {
|
|
case <-writer.failed:
|
|
case <-time.After(5 * time.Second):
|
|
close(release)
|
|
t.Fatal("no live progress before final result")
|
|
}
|
|
cancel()
|
|
close(release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("finalization stopped after disconnect")
|
|
}
|
|
if confirms.Load() != 1 || receipts.Load() != 1 || writer.writes.Load() != 1 {
|
|
t.Errorf("disconnect confirms/receipts/writes=%d/%d/%d, want 1/1/1", confirms.Load(), receipts.Load(), writer.writes.Load())
|
|
}
|
|
}
|