Compare commits

..

6 commits

5 changed files with 52 additions and 23 deletions

View file

@ -1,9 +1,11 @@
package app package app
import ( import (
"context"
"converter/domain" "converter/domain"
"converter/infrastructure/coinmarketcap" "converter/infrastructure/coinmarketcap"
"fmt" "fmt"
"strings"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@ -17,7 +19,7 @@ const (
// Note: The returned RateDTO may contain a reversed rate (e.g., if requesting USD->BTC // Note: The returned RateDTO may contain a reversed rate (e.g., if requesting USD->BTC
// but only BTC->USD is available), indicated by FromCode/ToCode being swapped from the request // but only BTC->USD is available), indicated by FromCode/ToCode being swapped from the request
type RateProvider interface { type RateProvider interface {
GetRate(fromCode, toCode string) (coinmarketcap.RateDTO, error) GetRate(ctx context.Context, fromCode, toCode string) (coinmarketcap.RateDTO, error)
} }
// ConvertCurrencyUseCase handles currency conversion operations // ConvertCurrencyUseCase handles currency conversion operations
@ -35,7 +37,11 @@ func NewConvertCurrencyUseCase(rateProvider RateProvider, converter *domain.Curr
} }
// Execute performs currency conversion // Execute performs currency conversion
func (uc *ConvertCurrencyUseCase) Execute(amount, fromCode, toCode string) (domain.Money, error) { func (uc *ConvertCurrencyUseCase) Execute(ctx context.Context, amount, fromCode, toCode string) (domain.Money, error) {
// Normalize currency codes to uppercase for consistency
fromCode = strings.ToUpper(strings.TrimSpace(fromCode))
toCode = strings.ToUpper(strings.TrimSpace(toCode))
// Create currencies with uniform precision // Create currencies with uniform precision
fromCurrency, err := domain.NewCurrency(fromCode, fromCode, CurrencyPrecision) fromCurrency, err := domain.NewCurrency(fromCode, fromCode, CurrencyPrecision)
if err != nil { if err != nil {
@ -49,7 +55,7 @@ func (uc *ConvertCurrencyUseCase) Execute(amount, fromCode, toCode string) (doma
} }
// Get exchange rate DTO from provider // Get exchange rate DTO from provider
rateDTO, err := uc.rateProvider.GetRate(fromCode, toCode) rateDTO, err := uc.rateProvider.GetRate(ctx, fromCode, toCode)
if err != nil { if err != nil {
return domain.Money{}, err return domain.Money{}, err
} }

View file

@ -1,12 +1,14 @@
package app package app
import ( import (
"context"
"converter/app/mocks" "converter/app/mocks"
"converter/domain" "converter/domain"
"converter/infrastructure/coinmarketcap" "converter/infrastructure/coinmarketcap"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
) )
func TestConvertCurrencyUseCase_Execute(t *testing.T) { func TestConvertCurrencyUseCase_Execute(t *testing.T) {
@ -36,7 +38,7 @@ func TestConvertCurrencyUseCase_Execute(t *testing.T) {
Price: 50000.0, // BTC price in USD Price: 50000.0, // BTC price in USD
Source: "coinmarketcap", Source: "coinmarketcap",
} }
m.On("GetRate", "BTC", "USD").Return(rateDTO, nil) m.On("GetRate", mock.Anything, "BTC", "USD").Return(rateDTO, nil)
}, },
}, },
{ {
@ -56,7 +58,7 @@ func TestConvertCurrencyUseCase_Execute(t *testing.T) {
Price: 50000.0, // BTC price in USD (will be inverted) Price: 50000.0, // BTC price in USD (will be inverted)
Source: "coinmarketcap", Source: "coinmarketcap",
} }
m.On("GetRate", "USD", "BTC").Return(rateDTO, nil) m.On("GetRate", mock.Anything, "USD", "BTC").Return(rateDTO, nil)
}, },
}, },
{ {
@ -89,7 +91,7 @@ func TestConvertCurrencyUseCase_Execute(t *testing.T) {
wantCode: "", wantCode: "",
wantErr: true, wantErr: true,
setupMock: func(m *mocks.RateProvider) { setupMock: func(m *mocks.RateProvider) {
m.On("GetRate", "XYZ", "ABC").Return(coinmarketcap.RateDTO{}, domain.ErrRateNotFound) m.On("GetRate", mock.Anything, "XYZ", "ABC").Return(coinmarketcap.RateDTO{}, domain.ErrRateNotFound)
}, },
}, },
{ {
@ -100,7 +102,7 @@ func TestConvertCurrencyUseCase_Execute(t *testing.T) {
wantCode: "", wantCode: "",
wantErr: true, wantErr: true,
setupMock: func(m *mocks.RateProvider) { setupMock: func(m *mocks.RateProvider) {
m.On("GetRate", "USD", "INVALID").Return(coinmarketcap.RateDTO{}, domain.ErrUnsupportedCurrency) m.On("GetRate", mock.Anything, "USD", "INVALID").Return(coinmarketcap.RateDTO{}, domain.ErrUnsupportedCurrency)
}, },
}, },
{ {
@ -127,7 +129,7 @@ func TestConvertCurrencyUseCase_Execute(t *testing.T) {
tt.setupMock(mockProvider) tt.setupMock(mockProvider)
// Execute // Execute
result, err := useCase.Execute(tt.amount, tt.fromCode, tt.toCode) result, err := useCase.Execute(context.Background(), tt.amount, tt.fromCode, tt.toCode)
// Assert error expectations // Assert error expectations
if tt.wantErr { if tt.wantErr {

View file

@ -3,6 +3,7 @@
package mocks package mocks
import ( import (
context "context"
coinmarketcap "converter/infrastructure/coinmarketcap" coinmarketcap "converter/infrastructure/coinmarketcap"
mock "github.com/stretchr/testify/mock" mock "github.com/stretchr/testify/mock"
@ -13,9 +14,9 @@ type RateProvider struct {
mock.Mock mock.Mock
} }
// GetRate provides a mock function with given fields: fromCode, toCode // GetRate provides a mock function with given fields: ctx, fromCode, toCode
func (_m *RateProvider) GetRate(fromCode string, toCode string) (coinmarketcap.RateDTO, error) { func (_m *RateProvider) GetRate(ctx context.Context, fromCode string, toCode string) (coinmarketcap.RateDTO, error) {
ret := _m.Called(fromCode, toCode) ret := _m.Called(ctx, fromCode, toCode)
if len(ret) == 0 { if len(ret) == 0 {
panic("no return value specified for GetRate") panic("no return value specified for GetRate")
@ -23,17 +24,17 @@ func (_m *RateProvider) GetRate(fromCode string, toCode string) (coinmarketcap.R
var r0 coinmarketcap.RateDTO var r0 coinmarketcap.RateDTO
var r1 error var r1 error
if rf, ok := ret.Get(0).(func(string, string) (coinmarketcap.RateDTO, error)); ok { if rf, ok := ret.Get(0).(func(context.Context, string, string) (coinmarketcap.RateDTO, error)); ok {
return rf(fromCode, toCode) return rf(ctx, fromCode, toCode)
} }
if rf, ok := ret.Get(0).(func(string, string) coinmarketcap.RateDTO); ok { if rf, ok := ret.Get(0).(func(context.Context, string, string) coinmarketcap.RateDTO); ok {
r0 = rf(fromCode, toCode) r0 = rf(ctx, fromCode, toCode)
} else { } else {
r0 = ret.Get(0).(coinmarketcap.RateDTO) r0 = ret.Get(0).(coinmarketcap.RateDTO)
} }
if rf, ok := ret.Get(1).(func(string, string) error); ok { if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(fromCode, toCode) r1 = rf(ctx, fromCode, toCode)
} else { } else {
r1 = ret.Error(1) r1 = ret.Error(1)
} }

View file

@ -1,11 +1,15 @@
package main package main
import ( import (
"context"
"converter/app" "converter/app"
"converter/domain" "converter/domain"
"converter/infrastructure/coinmarketcap" "converter/infrastructure/coinmarketcap"
"fmt" "fmt"
"os" "os"
"os/signal"
"syscall"
"time"
) )
const ( const (
@ -40,8 +44,22 @@ func main() {
converter := domain.NewCurrencyConverter() converter := domain.NewCurrencyConverter()
useCase := app.NewConvertCurrencyUseCase(client, converter) useCase := app.NewConvertCurrencyUseCase(client, converter)
// Create context with timeout and signal handling
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Set up graceful shutdown on interrupt signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
fmt.Fprintf(os.Stderr, "\nReceived interrupt signal, shutting down...\n")
cancel()
}()
// Execute conversion // Execute conversion
result, err := useCase.Execute(amount, fromCurrency, toCurrency) result, err := useCase.Execute(ctx, amount, fromCurrency, toCurrency)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
@ -49,4 +67,4 @@ func main() {
// Output result // Output result
fmt.Printf("%s %s\n", result.Amount.StringFixed(8), result.Currency.Code) fmt.Printf("%s %s\n", result.Amount.StringFixed(8), result.Currency.Code)
} }

View file

@ -1,6 +1,7 @@
package coinmarketcap package coinmarketcap
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
@ -40,16 +41,16 @@ func NewClient(apiKey string) *Client {
} }
// GetRate fetches exchange rate from CoinMarketCap API // GetRate fetches exchange rate from CoinMarketCap API
func (c *Client) GetRate(fromCode, toCode string) (RateDTO, error) { func (c *Client) GetRate(ctx context.Context, fromCode, toCode string) (RateDTO, error) {
// Try direct conversion first // Try direct conversion first
rateDTO, err := c.tryGetRate(fromCode, toCode) rateDTO, err := c.tryGetRate(ctx, fromCode, toCode)
if err == nil { if err == nil {
return rateDTO, nil return rateDTO, nil
} }
// If empty result but no other error, try reverse // If empty result but no other error, try reverse
if err == ErrUnknownSymbol { if err == ErrUnknownSymbol {
reverseDTO, reverseErr := c.tryGetRate(toCode, fromCode) reverseDTO, reverseErr := c.tryGetRate(ctx, toCode, fromCode)
if reverseErr == nil { if reverseErr == nil {
return reverseDTO, nil return reverseDTO, nil
} }
@ -69,11 +70,12 @@ func (c *Client) GetRate(fromCode, toCode string) (RateDTO, error) {
} }
// tryGetRate attempts to get rate in specified direction // tryGetRate attempts to get rate in specified direction
func (c *Client) tryGetRate(fromCode, toCode string) (RateDTO, error) { func (c *Client) tryGetRate(ctx context.Context, fromCode, toCode string) (RateDTO, error) {
var response APIResponse var response APIResponse
// Make API request // Make API request
resp, err := c.httpClient.R(). resp, err := c.httpClient.R().
SetContext(ctx).
SetQueryParam("symbol", fromCode). SetQueryParam("symbol", fromCode).
SetQueryParam("convert", toCode). SetQueryParam("convert", toCode).
SetResult(&response). SetResult(&response).