Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1869,7 +1869,13 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": "Validation result",
"description": "Duplicate request (idempotent)",
"schema": {
"$ref": "#/definitions/tracer_pkg_model.ValidationResponse"
}
},
"201": {
"description": "New validation created",
"schema": {
"$ref": "#/definitions/tracer_pkg_model.ValidationResponse"
}
Expand Down
8 changes: 7 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1458,7 +1458,13 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/tracer_pkg_model.ValidationResponse'
description: Validation result
description: Duplicate request (idempotent)
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/tracer_pkg_model.ValidationResponse'
description: New validation created
"400":
content:
application/json:
Expand Down
8 changes: 7 additions & 1 deletion api/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -1867,7 +1867,13 @@
],
"responses": {
"200": {
"description": "Validation result",
"description": "Duplicate request (idempotent)",
"schema": {
"$ref": "#/definitions/tracer_pkg_model.ValidationResponse"
}
},
"201": {
"description": "New validation created",
"schema": {
"$ref": "#/definitions/tracer_pkg_model.ValidationResponse"
}
Expand Down
6 changes: 5 additions & 1 deletion api/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2040,7 +2040,11 @@ paths:
- application/json
responses:
"200":
description: Validation result
description: Duplicate request (idempotent)
schema:
$ref: '#/definitions/tracer_pkg_model.ValidationResponse'
"201":
description: New validation created
schema:
$ref: '#/definitions/tracer_pkg_model.ValidationResponse'
"400":
Expand Down
5 changes: 3 additions & 2 deletions internal/adapters/http/in/mocks/validation_service_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 14 additions & 6 deletions internal/adapters/http/in/validation_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/gofiber/fiber/v2"
"go.opentelemetry.io/otel/trace"

"tracer/internal/services"
"tracer/pkg/clock"
"tracer/pkg/constant"
"tracer/pkg/logging"
Expand All @@ -31,7 +32,7 @@ const maxPayloadSize = 100 * 1024
// ValidationService defines the interface for validation operations.
// Interface defined locally per Ring pattern.
type ValidationService interface {
Validate(ctx context.Context, request *model.ValidationRequest) (*model.ValidationResponse, error)
Validate(ctx context.Context, request *model.ValidationRequest) (*services.ValidateResult, error)
}

// ValidationHandler handles HTTP requests for transaction validation.
Expand Down Expand Up @@ -67,7 +68,8 @@ func NewValidationHandler(service ValidationService, clk clock.Clock) (*Validati
// @Produce json
// @Security ApiKeyAuth
// @Param request body model.ValidationRequest true "Validation request"
// @Success 200 {object} model.ValidationResponse "Validation result"
// @Success 200 {object} model.ValidationResponse "Duplicate request (idempotent)"
// @Success 201 {object} model.ValidationResponse "New validation created"
// @Failure 400 {object} api.ErrorResponse "Invalid input"
// @Failure 401 {object} api.ErrorResponse "Unauthorized"
// @Failure 413 {object} api.ErrorResponse "Payload too large (exceeds 100KB)"
Expand Down Expand Up @@ -147,19 +149,25 @@ func (h *ValidationHandler) Validate(c *fiber.Ctx) error {
}

// Call validation service
response, err := h.service.Validate(ctx, &request)
result, err := h.service.Validate(ctx, &request)
if err != nil {
return h.handleValidationError(c, &span, err)
}

logger.WithFields(
"operation", "handler.validations.validate",
"request.id", request.RequestID.String(),
"decision", string(response.Decision),
"processing_time_ms", response.ProcessingTimeMs,
"decision", string(result.Response.Decision),
"processing_time_ms", result.Response.ProcessingTimeMs,
"is_duplicate", result.IsDuplicate,
).Info("Validation completed")

return libHTTP.OK(c, response)
// Return HTTP 201 for new requests, HTTP 200 for duplicate (idempotent) requests (DD-9)
if result.IsDuplicate {
return libHTTP.OK(c, result.Response)
}

return libHTTP.Created(c, result.Response)
}

// validationErrorMapping maps validation errors to their specific error codes and messages.
Expand Down
214 changes: 214 additions & 0 deletions internal/adapters/http/in/validation_handler_idempotency_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright (c) 2026 Lerian Studio. All rights reserved.
// Use of this source code is governed by the Elastic License 2.0
// that can be found in the LICENSE file.

package in

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"tracer/internal/adapters/http/in/mocks"
"tracer/internal/services"
"tracer/internal/testutil"
"tracer/pkg/clock"
"tracer/pkg/model"
)

// =============================================================================
// Handler HTTP Status Codes for Idempotency Tests
// =============================================================================
// These tests verify the HTTP status codes based on IsDuplicate flag:
// - New request (IsDuplicate=false): HTTP 201 Created
// - Duplicate request (IsDuplicate=true): HTTP 200 OK
// =============================================================================

// TestValidationHandler_Validate_ReturnsCorrectStatusCodes tests that the handler
// returns HTTP 201 for new requests and HTTP 200 for duplicate requests (DD-9).
func TestValidationHandler_Validate_ReturnsCorrectStatusCodes(t *testing.T) {
validRequestID := testutil.MustDeterministicUUID(7001)
accountID := testutil.MustDeterministicUUID(7002)
now := testutil.DefaultTestTime

validRequest := model.ValidationRequest{
RequestID: validRequestID,
TransactionType: model.TransactionTypeCard,
Amount: decimal.RequireFromString("100"),
Currency: "USD",
TransactionTimestamp: now,
Account: model.AccountContext{
ID: accountID,
},
}

tests := []struct {
name string
mockSetup func(ctrl *gomock.Controller) *mocks.MockValidationService
expectedStatus int
description string
}{
{
name: "new request returns HTTP 201 Created",
mockSetup: func(ctrl *gomock.Controller) *mocks.MockValidationService {
mockService := mocks.NewMockValidationService(ctrl)

// Service returns ValidateResult{Response, IsDuplicate: false} for new requests
mockService.EXPECT().
Validate(gomock.Any(), gomock.Any()).
Return(&services.ValidateResult{
Response: &model.ValidationResponse{
ValidationID: testutil.MustDeterministicUUID(7010),
RequestID: validRequestID,
EvaluationResult: model.EvaluationResult{
Decision: model.DecisionAllow,
MatchedRuleIDs: []uuid.UUID{},
EvaluatedRuleIDs: []uuid.UUID{testutil.MustDeterministicUUID(7011)},
Reason: "No matching rules found",
},
LimitUsageDetails: []model.LimitUsageDetail{},
ProcessingTimeMs: 15,
},
IsDuplicate: false,
}, nil)

return mockService
},
// New requests should return 201 Created
expectedStatus: http.StatusCreated,
description: "Handler should return 201 for new (non-duplicate) requests",
},
{
name: "duplicate request returns HTTP 200 OK",
mockSetup: func(ctrl *gomock.Controller) *mocks.MockValidationService {
mockService := mocks.NewMockValidationService(ctrl)

// Service returns ValidateResult{Response, IsDuplicate: true} for duplicate requests
mockService.EXPECT().
Validate(gomock.Any(), gomock.Any()).
Return(&services.ValidateResult{
Response: &model.ValidationResponse{
ValidationID: testutil.MustDeterministicUUID(7020),
RequestID: validRequestID,
EvaluationResult: model.EvaluationResult{
Decision: model.DecisionAllow,
MatchedRuleIDs: []uuid.UUID{},
EvaluatedRuleIDs: []uuid.UUID{testutil.MustDeterministicUUID(7021)},
Reason: "No matching rules found",
},
LimitUsageDetails: []model.LimitUsageDetail{},
ProcessingTimeMs: 10,
},
IsDuplicate: true,
}, nil)

return mockService
},
// Duplicate requests should return 200 OK
expectedStatus: http.StatusOK,
description: "Handler should return 200 for duplicate (idempotent) requests",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)

mockService := tt.mockSetup(ctrl)

clk := clock.New()
handler, handlerErr := NewValidationHandler(mockService, clk)
require.NoError(t, handlerErr)

app := fiber.New()
app.Post("/v1/validations", handler.Validate)

body, err := json.Marshal(validRequest)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPost, "/v1/validations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()

// ASSERTION: Verify correct status code based on IsDuplicate
assert.Equal(t, tt.expectedStatus, resp.StatusCode, tt.description)
})
}
}

// TestValidationHandler_Validate_IdempotencyHeader tests that the handler works
// correctly for both new and duplicate requests without requiring special headers.
func TestValidationHandler_Validate_IdempotencyHeader(t *testing.T) {
validRequestID := testutil.MustDeterministicUUID(8001)
accountID := testutil.MustDeterministicUUID(8002)
now := testutil.DefaultTestTime

validRequest := model.ValidationRequest{
RequestID: validRequestID,
TransactionType: model.TransactionTypeCard,
Amount: decimal.RequireFromString("100"),
Currency: "USD",
TransactionTimestamp: now,
Account: model.AccountContext{
ID: accountID,
Type: "checking",
Status: "active",
},
}

ctrl := gomock.NewController(t)

mockService := mocks.NewMockValidationService(ctrl)

// Simulate duplicate request scenario
mockService.EXPECT().
Validate(gomock.Any(), gomock.Any()).
Return(&services.ValidateResult{
Response: &model.ValidationResponse{
ValidationID: testutil.MustDeterministicUUID(8010),
RequestID: validRequestID,
EvaluationResult: model.EvaluationResult{
Decision: model.DecisionAllow,
MatchedRuleIDs: []uuid.UUID{},
EvaluatedRuleIDs: []uuid.UUID{},
Reason: "Approved",
},
LimitUsageDetails: []model.LimitUsageDetail{},
ProcessingTimeMs: 5,
EvaluatedAt: now,
},
IsDuplicate: true,
}, nil)

clk := clock.New()
handler, handlerErr := NewValidationHandler(mockService, clk)
require.NoError(t, handlerErr)

app := fiber.New()
app.Post("/v1/validations", handler.Validate)

body, err := json.Marshal(validRequest)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPost, "/v1/validations", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")

resp, err := app.Test(req)
require.NoError(t, err)
defer resp.Body.Close()

// Duplicate requests should return 200 OK
assert.Equal(t, http.StatusOK, resp.StatusCode, "Duplicate response should return 200 OK")
}
Loading