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", "\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") } } }