package dojo import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "reflect" "strings" "sync/atomic" "testing" "gitea.futuresens.co.uk/futuresens/hardlink/internal/paymentsvc" "gitea.futuresens.co.uk/futuresens/hardlink/paymentstatus" ) const terminalUnavailableBody = `{"detail":"the terminal is either offline or currently in use","errors":{},"status":409,"title":"terminal unavailable","traceId":"trace-secret","type":"https://docs.dojo.tech/problems/terminal-unavailable"}` func TestDoJSONReturnsStructuredHTTPResponseError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(" " + terminalUnavailableBody + " ")) })) defer server.Close() client := newDojoTestClient(t, server) err := client.doJSON(context.Background(), http.MethodPost, "/payment-intents", nil, false, nil) var responseErr *httpResponseError if !errors.As(err, &responseErr) { t.Fatalf("error = %T %v, want *httpResponseError", err, err) } if responseErr.StatusCode != http.StatusConflict { t.Fatalf("StatusCode = %d, want %d", responseErr.StatusCode, http.StatusConflict) } if responseErr.Body != terminalUnavailableBody { t.Fatalf("Body = %q, want retained trimmed response", responseErr.Body) } } func TestSaleMapsCreateTerminalSessionConflictToTerminalUnavailable(t *testing.T) { var paymentIntentRequests atomic.Int32 var terminalSessionRequests atomic.Int32 var unexpectedRequests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/payment-intents": paymentIntentRequests.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintln(w, `{"id":"intent-123"}`) case r.Method == http.MethodPost && r.URL.Path == "/terminal-sessions": terminalSessionRequests.Add(1) w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(http.StatusConflict) _, _ = fmt.Fprintln(w, terminalUnavailableBody) default: unexpectedRequests.Add(1) http.Error(w, "unexpected request", http.StatusInternalServerError) } })) defer server.Close() client := newDojoTestClient(t, server) request := paymentsvc.SaleRequest{ RequestID: "REQ-123", Reference: "BOOKING-123", Amount: 10852, Currency: "GBP", } var statuses []string result, err := client.Sale(context.Background(), request, func(update paymentsvc.StatusUpdate) { statuses = append(statuses, update.Code) }) if err != nil { t.Fatal(err) } if result == nil { t.Fatal("Sale returned nil result") } if result.Success || result.Status == "APPROVED" { t.Fatalf("result was accidentally successful: %+v", result) } if result.Status != dojoTerminalUnavailableStatus || result.ErrorMessage != dojoTerminalUnavailableMessage { t.Fatalf("terminal-unavailable result = %+v", result) } if result.RequestID != request.RequestID || result.Operation != "SALE" || result.Amount != request.Amount || result.Currency != request.Currency || result.DeviceUsed != "terminal-1" || result.DeviceType != "Dojo Terminal" { t.Fatalf("base result fields were not retained: %+v", result) } if result.TransactionID != "" || result.CustomerReceipt != "" || result.MerchantReceipt != "" { t.Fatalf("terminal-unavailable result invented transaction data: %+v", result) } if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting, paymentstatus.TerminalUnavailable}) { t.Fatalf("statuses = %#v", statuses) } if paymentIntentRequests.Load() != 1 || terminalSessionRequests.Load() != 1 || unexpectedRequests.Load() != 0 { t.Fatalf( "requests: intent=%d terminal=%d unexpected=%d", paymentIntentRequests.Load(), terminalSessionRequests.Load(), unexpectedRequests.Load(), ) } resultText := fmt.Sprintf("%+v", result) for _, forbidden := range []string{"offline or currently in use", "trace-secret", "docs.dojo.tech", terminalUnavailableBody} { if strings.Contains(resultText, forbidden) { t.Fatalf("result exposed %q: %s", forbidden, resultText) } } } func TestSaleDoesNotClassifyPaymentIntentConflictAsTerminalUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/payment-intents" { t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) } w.WriteHeader(http.StatusConflict) _, _ = fmt.Fprintln(w, terminalUnavailableBody) })) defer server.Close() client := newDojoTestClient(t, server) var statuses []string result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{ RequestID: "REQ-123", Amount: 10852, Currency: "GBP", }, func(update paymentsvc.StatusUpdate) { statuses = append(statuses, update.Code) }) if result != nil || err == nil { t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err) } var responseErr *httpResponseError if !errors.As(err, &responseErr) || responseErr.StatusCode != http.StatusConflict { t.Fatalf("error = %T %v, want wrapped HTTP 409", err, err) } if !reflect.DeepEqual(statuses, []string{paymentstatus.Starting}) { t.Fatalf("statuses = %#v, want only PAYMENT_STARTING", statuses) } } func TestSaleRetainsGenericCreateTerminalSessionErrors(t *testing.T) { for _, statusCode := range []int{http.StatusBadRequest, http.StatusBadGateway} { t.Run(http.StatusText(statusCode), func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/payment-intents": _, _ = fmt.Fprintln(w, `{"id":"intent-123"}`) case "/terminal-sessions": w.WriteHeader(statusCode) _, _ = fmt.Fprintln(w, `{"detail":"provider failure"}`) default: t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) } })) defer server.Close() client := newDojoTestClient(t, server) result, err := client.Sale(context.Background(), paymentsvc.SaleRequest{ RequestID: "REQ-123", Amount: 10852, Currency: "GBP", }, nil) if result != nil || err == nil { t.Fatalf("Sale result/error = %+v/%v, want nil generic error", result, err) } var responseErr *httpResponseError if !errors.As(err, &responseErr) || responseErr.StatusCode != statusCode { t.Fatalf("error = %T %v, want wrapped HTTP %d", err, err, statusCode) } }) } } func newDojoTestClient(t *testing.T, server *httptest.Server) *Client { t.Helper() client, err := NewClient(Config{ BaseURL: server.URL, APIKey: "test-api-key", SoftwareHouseID: "software-house-1", TerminalID: "terminal-1", }) if err != nil { t.Fatal(err) } client.httpClient = server.Client() return client }