feat(api): add card-to-iban HTTP endpoint
This commit is contained in:
293
backend/internal/httpapi/handler_test.go
Normal file
293
backend/internal/httpapi/handler_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"card-to-iban/internal/audit"
|
||||
"card-to-iban/internal/card"
|
||||
"card-to-iban/internal/service"
|
||||
"card-to-iban/internal/zarinhub"
|
||||
)
|
||||
|
||||
type fakeConverter struct {
|
||||
result service.ConversionResult
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeConverter) ConvertCardToIBAN(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
) (service.ConversionResult, error) {
|
||||
f.called = true
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
type fakeAuditSaver struct {
|
||||
records []audit.Record
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAuditSaver) Save(
|
||||
ctx context.Context,
|
||||
record audit.Record,
|
||||
) error {
|
||||
f.records = append(f.records, record)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestHandlerConvert(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
result service.ConversionResult
|
||||
convertErr error
|
||||
wantHTTPStatus int
|
||||
wantErrorCode string
|
||||
wantAuditStatus audit.Status
|
||||
wantMaskedCard string
|
||||
wantCalled bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
result: service.ConversionResult{
|
||||
IBAN: "IR820540102680020817909002",
|
||||
UpstreamHTTPStatus: http.StatusOK,
|
||||
RawResponse: []byte(
|
||||
`{"data":{"card":"6037997512345678","iban":"IR820540102680020817909002"}}`,
|
||||
),
|
||||
},
|
||||
wantHTTPStatus: http.StatusOK,
|
||||
wantAuditStatus: audit.StatusSuccess,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "malformed json",
|
||||
method: http.MethodPost,
|
||||
body: `{not-json}`,
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
wantErrorCode: "invalid_card",
|
||||
wantAuditStatus: audit.StatusValidationFailed,
|
||||
},
|
||||
{
|
||||
name: "missing card",
|
||||
method: http.MethodPost,
|
||||
body: `{}`,
|
||||
convertErr: card.ErrCardRequired,
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
wantErrorCode: "invalid_card",
|
||||
wantAuditStatus: audit.StatusValidationFailed,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "wrong card length",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"123"}`,
|
||||
convertErr: card.ErrCardLength,
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
wantErrorCode: "invalid_card",
|
||||
wantAuditStatus: audit.StatusValidationFailed,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "non numeric card",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"603799ABC2345678"}`,
|
||||
convertErr: card.ErrCardNumeric,
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
wantErrorCode: "invalid_card",
|
||||
wantAuditStatus: audit.StatusValidationFailed,
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "upstream rejection",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
convertErr: zarinhub.ErrRejected,
|
||||
wantHTTPStatus: http.StatusUnprocessableEntity,
|
||||
wantErrorCode: "conversion_rejected",
|
||||
wantAuditStatus: audit.StatusUpstreamRejected,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "upstream authentication failure",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
convertErr: zarinhub.ErrAuthentication,
|
||||
wantHTTPStatus: http.StatusBadGateway,
|
||||
wantErrorCode: "upstream_failure",
|
||||
wantAuditStatus: audit.StatusAuthenticationError,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "upstream timeout",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
convertErr: zarinhub.ErrTimeout,
|
||||
wantHTTPStatus: http.StatusGatewayTimeout,
|
||||
wantErrorCode: "upstream_timeout",
|
||||
wantAuditStatus: audit.StatusUpstreamTimeout,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "upstream unavailable",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
convertErr: zarinhub.ErrUnavailable,
|
||||
wantHTTPStatus: http.StatusServiceUnavailable,
|
||||
wantErrorCode: "upstream_unavailable",
|
||||
wantAuditStatus: audit.StatusUpstreamUnavailable,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "invalid upstream response",
|
||||
method: http.MethodPost,
|
||||
body: `{"card":"6037997512345678"}`,
|
||||
convertErr: zarinhub.ErrInvalidResponse,
|
||||
wantHTTPStatus: http.StatusBadGateway,
|
||||
wantErrorCode: "upstream_failure",
|
||||
wantAuditStatus: audit.StatusUpstreamInvalid,
|
||||
wantMaskedCard: "603799******5678",
|
||||
wantCalled: true,
|
||||
},
|
||||
{
|
||||
name: "method not allowed",
|
||||
method: http.MethodGet,
|
||||
wantHTTPStatus: http.StatusMethodNotAllowed,
|
||||
wantErrorCode: "method_not_allowed",
|
||||
wantAuditStatus: audit.StatusValidationFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
converter := &fakeConverter{
|
||||
result: tt.result,
|
||||
err: tt.convertErr,
|
||||
}
|
||||
audits := &fakeAuditSaver{}
|
||||
handler := NewHandler(
|
||||
converter,
|
||||
audits,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
handler.newRequestID = func() string { return "request-test" }
|
||||
|
||||
request := httptest.NewRequest(
|
||||
tt.method,
|
||||
"/api/v1/card-to-iban",
|
||||
strings.NewReader(tt.body),
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.Convert(response, request)
|
||||
|
||||
if response.Code != tt.wantHTTPStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %s", response.Code, tt.wantHTTPStatus, response.Body.String())
|
||||
}
|
||||
|
||||
if got := response.Header().Get("X-Request-ID"); got != "request-test" {
|
||||
t.Errorf("X-Request-ID = %q, want request-test", got)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
IBAN string `json:"iban"`
|
||||
} `json:"data"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
if payload.RequestID != "request-test" {
|
||||
t.Errorf("requestId = %q, want request-test", payload.RequestID)
|
||||
}
|
||||
|
||||
if payload.Error.Code != tt.wantErrorCode {
|
||||
t.Errorf("error code = %q, want %q", payload.Error.Code, tt.wantErrorCode)
|
||||
}
|
||||
|
||||
if tt.wantHTTPStatus == http.StatusOK && payload.Data.IBAN != tt.result.IBAN {
|
||||
t.Errorf("iban = %q, want %q", payload.Data.IBAN, tt.result.IBAN)
|
||||
}
|
||||
|
||||
if converter.called != tt.wantCalled {
|
||||
t.Errorf("converter called = %v, want %v", converter.called, tt.wantCalled)
|
||||
}
|
||||
|
||||
if len(audits.records) != 1 {
|
||||
t.Fatalf("audit records = %d, want 1", len(audits.records))
|
||||
}
|
||||
|
||||
record := audits.records[0]
|
||||
if record.Status != tt.wantAuditStatus {
|
||||
t.Errorf("audit status = %q, want %q", record.Status, tt.wantAuditStatus)
|
||||
}
|
||||
if record.MaskedCard != tt.wantMaskedCard {
|
||||
t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard)
|
||||
}
|
||||
|
||||
if strings.Contains(string(record.ResponseJSON), "6037997512345678") {
|
||||
t.Error("audit response contains the full card number")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerFailsClosedWhenAuditSaveFails(t *testing.T) {
|
||||
converter := &fakeConverter{
|
||||
result: service.ConversionResult{
|
||||
IBAN: "IR820540102680020817909002",
|
||||
UpstreamHTTPStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
audits := &fakeAuditSaver{err: errors.New("database unavailable")}
|
||||
handler := NewHandler(
|
||||
converter,
|
||||
audits,
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
)
|
||||
handler.newRequestID = func() string { return "request-test" }
|
||||
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/card-to-iban",
|
||||
strings.NewReader(`{"card":"6037997512345678"}`),
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.Convert(response, request)
|
||||
|
||||
if response.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", response.Code)
|
||||
}
|
||||
|
||||
if strings.Contains(response.Body.String(), "IR820540102680020817909002") {
|
||||
t.Error("response exposes iban after audit save failure")
|
||||
}
|
||||
|
||||
if !strings.Contains(response.Body.String(), `"code":"internal_error"`) {
|
||||
t.Errorf("response body = %s, want internal_error", response.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user