hardlink/internal/handlers/creditcall_preauth_test.go

394 lines
16 KiB
Go

package handlers
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"encoding/xml"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"gitea.futuresens.co.uk/futuresens/cmstypes"
"gitea.futuresens.co.uk/futuresens/hardlink/internal/types"
"gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus"
)
type preauthTestConnector struct {
inserts chan []driver.NamedValue
count atomic.Int32
}
func (c *preauthTestConnector) Connect(context.Context) (driver.Conn, error) {
return &preauthTestConn{c}, nil
}
func (c *preauthTestConnector) Driver() driver.Driver { return preauthTestDriver{c} }
type preauthTestDriver struct{ c *preauthTestConnector }
func (d preauthTestDriver) Open(string) (driver.Conn, error) { return &preauthTestConn{d.c}, nil }
type preauthTestConn struct{ c *preauthTestConnector }
func (*preauthTestConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("unexpected prepare")
}
func (*preauthTestConn) Close() error { return nil }
func (*preauthTestConn) Begin() (driver.Tx, error) { return nil, errors.New("unexpected begin") }
func (*preauthTestConn) Ping(context.Context) error { return nil }
func (c *preauthTestConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !strings.Contains(query, "INSERT INTO dbo.Preauthorizations") {
return nil, errors.New("unexpected SQL")
}
c.c.count.Add(1)
c.c.inserts <- append([]driver.NamedValue(nil), args...)
return driver.RowsAffected(1), nil
}
func preauthTestDatabase(t *testing.T, app *App) *preauthTestConnector {
t.Helper()
c := &preauthTestConnector{inserts: make(chan []driver.NamedValue, 4)}
app.db = sql.OpenDB(c)
app.cfg.LogDir = t.TempDir()
t.Cleanup(func() { app.db.Close() })
return c
}
func waitPreauthInsert(t *testing.T, c *preauthTestConnector) map[string]any {
t.Helper()
select {
case args := <-c.inserts:
result := map[string]any{}
for _, arg := range args {
result[arg.Name] = arg.Value
}
return result
case <-time.After(3 * time.Second):
t.Fatal("preauth persistence did not reach SQL")
}
return nil
}
func preauthLegacyRequest(amount, kind, checkout string) *http.Request {
body, _ := xml.Marshal(cmstypes.TransactionRec{AmountMinorUnits: amount, TransactionType: kind, CheckoutDate: checkout})
r := httptest.NewRequest(http.MethodPost, "/takepreauth", strings.NewReader(string(body)))
r.Header.Set("Content-Type", "text/xml")
return r
}
func TestCreditCallPreauthLegacyCharacterization(t *testing.T) {
for _, tc := range []struct {
name, amount, requestType, result, resultType, total string
save, approved bool
}{
{"positive", "12000", "Sale", "APPROVED", "SALE", "3100", true, true},
{"declined", "12000", "Sale", "DECLINED", "SALE", "", false, false},
{"verification", "", "AccountVerification", "APPROVED", "ACCOUNT VERIFICATION", "", false, true},
{"verification failure", "", "AccountVerification", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
{"unexpected type", "12000", "Sale", "APPROVED", "Refund", "", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
var starts, prints int
checkout := ""
if tc.amount != "" {
checkout = "2026-09-11 00:00:00 +0000"
}
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.resultType, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "card-hash", types.CardReference: "card-reference", types.ReceiptDataCardholder: "receipt"}
if tc.total != "" {
fields[types.TotalAmount] = tc.total
}
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
starts++
if r.URL.Path != "/start-transaction/" {
t.Errorf("PREAUTH upstream path=%s, want start only", r.URL.Path)
}
var input cmstypes.TransactionRec
if err := xml.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
if input.AmountMinorUnits != tc.amount || input.TransactionType != tc.requestType || input.CheckoutDate != checkout {
t.Errorf("PREAUTH input=%+v, want amount=%q type=%q checkout=%q", input, tc.amount, tc.requestType, checkout)
}
// Legacy ignores upstream HTTP status when XML contains a result.
w.WriteHeader(http.StatusBadGateway)
io.WriteString(w, chipDNAFixture(t, fields))
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
prints++
if receipt != "receipt" {
t.Errorf("receipt=%q, want receipt", receipt)
}
}
recorder := httptest.NewRecorder()
app.takePreauthorization(recorder, preauthLegacyRequest(tc.amount, tc.requestType, checkout))
var response cmstypes.ResponseRec
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != 200 || response.Status.Code != 200 || starts != 1 || prints != 1 || strings.HasPrefix(response.Data, "/successful?") != tc.approved {
t.Errorf("PREAUTH response=%+v HTTP=%d starts=%d prints=%d, want approved=%t one start/receipt", response, recorder.Code, starts, prints, tc.approved)
}
if tc.save {
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != "3100" || got["TxnReference"] != "preauth-ref" {
t.Errorf("persisted=%v, want provider amount/reference", got)
}
departure := time.Date(2026, 9, 11, 0, 0, 0, 0, time.Local).UTC()
if got["DepartureDate"] != departure || got["ReleaseDate"] != departure.Add(48*time.Hour) {
t.Errorf("persisted dates=%v, want existing local midnight plus 48 hours", got)
}
} else if c.count.Load() != 0 {
t.Error("non-persisting result inserted SQL")
}
})
}
}
func preauthStreamRequest(amount, kind, checkout string) *http.Request {
body, _ := json.Marshal(creditCallPreauthRequest{Amount: amount, TransactionType: kind, CheckoutDate: checkout})
r := httptest.NewRequest(http.MethodPost, "/api/payment/preauth", strings.NewReader(string(body)))
r.Header.Set("Content-Type", "application/json")
return r
}
func TestCreditCallPreauthStreamingParity(t *testing.T) {
for _, tc := range []struct {
name, result, kind, total string
approved, persist bool
}{
{"monetary", "APPROVED", "SALE", "3100", true, true},
{"verification", "APPROVED", "ACCOUNT VERIFICATION", "", true, false},
{"declined", "DECLINED", "SALE", "", false, false},
{"verification failed", "DECLINED", "ACCOUNT VERIFICATION", "", false, false},
{"unknown approved type", "APPROVED", "AccountVerification", "", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
fields := map[string]string{types.TransactionResult: tc.result, types.TransactionType: tc.kind, types.Reference: "preauth-ref", types.PanMasked: "************1133", types.CardType: "Visa", types.ExpiryDate: "1228", types.CardHash: "hash", types.CardReference: "cardref", types.ReceiptDataCardholder: "receipt"}
if tc.total != "" {
fields[types.TotalAmount] = tc.total
}
amount, kind, date := "12000", "Sale", "2026-09-11 00:00:00 +0000"
if strings.Contains(tc.name, "verification") {
amount, kind, date = "", "AccountVerification", ""
}
var legacy cmstypes.ResponseRec
for _, stream := range []bool{false, true} {
calls, prints := 0, 0
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if stream {
if r.URL.Path != "/start-transaction-stream/" {
t.Errorf("stream called %s, want one generic start", r.URL.Path)
}
var input creditCallPreauthRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
t.Error(err)
}
if input.Amount != amount || input.TransactionType != kind {
t.Errorf("upstream input=%+v, want %q/%q", input, amount, kind)
}
for _, event := range []struct{ source, value string }{{"UPDATE", "CardRequested"}, {"CARD_STATUS", "Inserted"}, {"UPDATE", "CardRemovalRequested"}, {"CARD_STATUS", "Removed"}, {"UPDATE", "CardRequested"}, {"UPDATE", "CardRemovalEnforced"}, {"UPDATE", "PinEntryStarted"}, {"UPDATE", "OnlineAuthCompleted"}} {
json.NewEncoder(w).Encode(map[string]any{"type": "status", "source": event.source, "value": event.value})
}
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": fields})
} else {
io.WriteString(w, chipDNAFixture(t, fields))
}
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
prints++
if receipt != "receipt" {
t.Errorf("receipt=%q, want retained receipt", receipt)
}
}
recorder := httptest.NewRecorder()
if stream {
app.streamCreditCallPreauth(recorder, preauthStreamRequest(amount, kind, date))
frames := decodePaymentStream(t, recorder.Body)
var statuses []string
for _, frame := range frames {
if frame.Type == "status" {
statuses = append(statuses, frame.Code)
}
}
want := []string{paymentstatus.PresentCard, paymentstatus.DoNotRemoveCard, paymentstatus.RemoveCard, paymentstatus.PresentCard, paymentstatus.RemoveCard, paymentstatus.EnterPIN, paymentstatus.PleaseWait}
if !reflect.DeepEqual(statuses, want) {
t.Errorf("preauth statuses=%v, want %v", statuses, want)
}
final := frames[len(frames)-1].Result
if final == nil {
t.Fatal("missing structured final")
}
if (final.Outcome == "approved") != tc.approved || final.Status != legacy.Status || final.HTTPStatus != 200 {
t.Errorf("stream final=%+v, legacy=%+v", final, legacy)
}
if tc.approved && (final.TransactionReference != "preauth-ref" || final.CardType != "Visa" || final.MaskedCardNumber != "************1133" || final.ExpiryDate != "1228" || final.CardHash != "hash" || final.CardReference != "cardref") {
t.Errorf("preauth fields lost: %+v", final)
}
if len(frames) != len(want)+1 {
t.Errorf("frames=%d, want one final", len(frames))
}
} else {
app.takePreauthorization(recorder, preauthLegacyRequest(amount, kind, date))
if err := json.Unmarshal(recorder.Body.Bytes(), &legacy); err != nil {
t.Fatal(err)
}
}
if calls != 1 || prints != 1 {
t.Errorf("calls/prints=%d/%d, want 1/1", calls, prints)
}
if tc.persist {
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != tc.total {
t.Errorf("SQL amount=%v, want provider %q", got["TotalMinorUnits"], tc.total)
}
} else if c.count.Load() != 0 {
t.Error("verification/failure persisted")
}
}
})
}
}
func TestCreditCallInboundCancellationAfterDispatch(t *testing.T) {
for _, preauth := range []bool{false, true} {
t.Run(map[bool]string{false: "sale", true: "preauth"}[preauth], func(t *testing.T) {
dispatched := make(chan struct{})
release := make(chan struct{})
var starts, confirms, 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
}
starts.Add(1)
close(dispatched)
<-release
if r.Context().Err() != nil {
t.Errorf("financial upstream was cancelled: %v", r.Context().Err())
return
}
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.TotalAmount: "3100", types.Reference: "delayed-ref", types.ReceiptDataCardholder: "receipt"}})
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(receipt string) {
if receipt != "receipt" {
t.Errorf("receipt=%q", receipt)
}
receipts.Add(1)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
recorder := httptest.NewRecorder()
done := make(chan struct{})
go func() {
defer close(done)
if preauth {
app.streamCreditCallPreauth(recorder, preauthStreamRequest("12000", "Sale", "2026-09-11 00:00:00 +0000").WithContext(ctx))
} else {
app.streamCreditCallSale(recorder, saleRequest(true).WithContext(ctx))
}
}()
select {
case <-dispatched:
case <-time.After(3 * time.Second):
close(release)
t.Fatal("no upstream dispatch")
}
cancel() // Exact acceptance boundary: upstream has observed StartTransaction.
close(release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("cancelled inbound request stopped finalization")
}
wantConfirms := int32(1)
if preauth {
wantConfirms = 0
got := waitPreauthInsert(t, c)
if got["TotalMinorUnits"] != "3100" {
t.Errorf("delayed persistence=%v", got)
}
if c.count.Load() != 1 {
t.Errorf("delayed persistence count=%d, want exactly one", c.count.Load())
}
}
if starts.Load() != 1 || receipts.Load() != 1 || confirms.Load() != wantConfirms {
t.Errorf("starts/receipts/confirms=%d/%d/%d, want 1/1/%d", starts.Load(), receipts.Load(), confirms.Load(), wantConfirms)
}
if recorder.Body.Len() != 0 {
t.Errorf("cancelled delivery wrote %s", recorder.Body.String())
}
})
}
}
func TestCreditCallPreauthMissingAmountNeverSynthesized(t *testing.T) {
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"type": "result", "result": map[string]string{types.TransactionResult: "APPROVED", types.TransactionType: "SALE", types.Reference: "missing-amount"}})
})
c := preauthTestDatabase(t, app)
app.creditCallReceipt = func(string) {}
recorder := httptest.NewRecorder()
app.streamCreditCallPreauth(recorder, preauthStreamRequest("9999", "Sale", "2026-09-11 00:00:00 +0000"))
frames := decodePaymentStream(t, recorder.Body)
if frames[0].Result.Outcome != "approved" {
t.Fatalf("missing amount changed approval: %+v", frames[0].Result)
}
deadline := time.Now().Add(3 * time.Second)
for {
data, err := os.ReadFile(app.spoolPath())
var record preauthSpoolRecord
if err == nil && json.Unmarshal(data, &record) == nil {
if _, ok := record.Fields[types.TotalAmount]; ok {
t.Errorf("spool fabricated amount: %v", record.Fields)
}
if record.CheckoutDate != "2026-09-11 00:00:00 +0000" {
t.Errorf("spool checkout=%q", record.CheckoutDate)
}
break
}
if time.Now().After(deadline) {
t.Fatal("missing provider amount did not retain existing spool fallback")
}
time.Sleep(time.Millisecond)
}
if c.count.Load() != 0 {
t.Error("SQL inserted fabricated amount")
}
}
func TestCreditCallPreauthStreamFailureAndCancelledBeforeDispatch(t *testing.T) {
for _, body := range []string{"", "{", "{}\n", "<xml/>\n", "{\"type\":\"result\",\"result\":{}}\n", "{\"type\":\"result\",\"result\":{\"TRANSACTION_RESULT\":\"APPROVED\"}}"} {
app := newCreditCallTestApp(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) })
calls := 0
transport := app.creditCallTransport
app.creditCallTransport = creditCallRoundTrip(func(r *http.Request) (*http.Response, error) { calls++; return transport.RoundTrip(r) })
app.creditCallReceipt = func(string) { t.Error("malformed stream printed receipt") }
recorder := httptest.NewRecorder()
app.streamCreditCallPreauth(recorder, preauthStreamRequest("", "AccountVerification", ""))
frames := decodePaymentStream(t, recorder.Body)
if calls != 1 || len(frames) != 1 || frames[0].Result.Outcome != "error" || frames[0].Result.HTTPStatus != 502 {
t.Errorf("invalid stream %q: calls=%d frames=%+v", body, calls, frames)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
app.streamCreditCallPreauth(httptest.NewRecorder(), preauthStreamRequest("", "AccountVerification", "").WithContext(ctx))
if calls != 1 {
t.Error("already-cancelled request dispatched a transaction")
}
}
}