Compare commits
5 commits
511b7a940c
...
b94df9467e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b94df9467e | ||
|
|
17a695d895 | ||
|
|
c37e2a312a | ||
|
|
4c67f5844b | ||
|
|
da4775fa2f |
203
README.md
Normal file
203
README.md
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
# Currency Converter CLI
|
||||||
|
|
||||||
|
A command-line utility for converting currencies using CoinMarketCap API, built with Go following Clean Architecture principles.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Go 1.19 or higher
|
||||||
|
- CoinMarketCap API key (get from https://coinmarketcap.com/api/)
|
||||||
|
|
||||||
|
### Installation & Setup
|
||||||
|
1. Clone the repository
|
||||||
|
2. Set your API key:
|
||||||
|
```bash
|
||||||
|
export CMC_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
#### Using Taskfile (Recommended)
|
||||||
|
```bash
|
||||||
|
# Install task runner (if not already installed)
|
||||||
|
go install github.com/go-task/task/v3/cmd/task@latest
|
||||||
|
|
||||||
|
# Build and run with parameters
|
||||||
|
task run -- 123.45 USD BTC
|
||||||
|
task run -- 1 BTC USD
|
||||||
|
task run -- 100 EUR BTC
|
||||||
|
|
||||||
|
# Other available tasks
|
||||||
|
task build # Build the application
|
||||||
|
task test # Run tests
|
||||||
|
task test-verbose # Run tests with verbose output
|
||||||
|
task clean # Clean build artifacts
|
||||||
|
task all # Full build pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Go directly
|
||||||
|
```bash
|
||||||
|
# Build the application
|
||||||
|
go build -o bin/converter ./cmd/converter
|
||||||
|
|
||||||
|
# Run conversions
|
||||||
|
./bin/converter 123.45 USD BTC
|
||||||
|
./bin/converter 1 BTC USD
|
||||||
|
./bin/converter 100 EUR BTC
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using Go commands directly
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Build manually
|
||||||
|
mkdir -p bin && go build -o bin/converter ./cmd/converter
|
||||||
|
|
||||||
|
# Run with API key
|
||||||
|
CMC_API_KEY=your_key ./bin/converter 50 USD BTC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
converter/
|
||||||
|
├── cmd/converter/ # CLI application entry point
|
||||||
|
│ └── main.go # Main function, argument parsing, DI setup
|
||||||
|
├── app/ # Application layer (Use Cases)
|
||||||
|
│ ├── convert_currency.go # Core use case implementation
|
||||||
|
│ ├── convert_currency_test.go # Use case tests with mocks
|
||||||
|
│ └── mocks/ # Generated mocks (mockery)
|
||||||
|
│ └── RateProvider.go # Mock for RateProvider interface
|
||||||
|
├── domain/ # Domain layer (Business Logic)
|
||||||
|
│ ├── entities.go # Value objects (Currency, Money, Rate)
|
||||||
|
│ ├── entities_test.go # Domain entity tests
|
||||||
|
│ ├── services.go # Domain service (CurrencyConverter)
|
||||||
|
│ └── services_test.go # Domain service tests
|
||||||
|
├── infrastructure/ # Infrastructure layer (External APIs)
|
||||||
|
│ └── coinmarketcap/ # CoinMarketCap API client
|
||||||
|
│ ├── client.go # HTTP client with retry/fallback logic
|
||||||
|
│ └── models.go # API response DTOs
|
||||||
|
├── Taskfile.yml # Task runner configuration
|
||||||
|
├── go.mod # Go module definition
|
||||||
|
├── go.sum # Go module checksums
|
||||||
|
├── TASK.md # Original task requirements (Russian)
|
||||||
|
├── CLAUDE.md # Project configuration for Claude Code
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
|
||||||
|
This project implements **Clean Architecture** with clear separation of concerns:
|
||||||
|
|
||||||
|
- **Domain Layer**: Pure business logic with no external dependencies
|
||||||
|
- Value objects (Currency, Money, Rate) with validation
|
||||||
|
- Domain services (CurrencyConverter) for business operations
|
||||||
|
- Domain errors and business rules
|
||||||
|
|
||||||
|
- **Application Layer**: Use cases that orchestrate domain logic
|
||||||
|
- ConvertCurrencyUseCase: Main conversion workflow
|
||||||
|
- RateProvider interface: Defines contract for rate fetching
|
||||||
|
- DTO to domain object mapping with reversal detection
|
||||||
|
|
||||||
|
- **Infrastructure Layer**: External integrations and technical details
|
||||||
|
- CoinMarketCap API client with automatic retry logic
|
||||||
|
- HTTP error handling and response mapping
|
||||||
|
- Rate reversal logic (USD→BTC becomes BTC→USD inverted)
|
||||||
|
|
||||||
|
- **CLI Layer**: User interface and dependency injection
|
||||||
|
- Command-line parsing and validation
|
||||||
|
- Environment variable configuration
|
||||||
|
- Error formatting for end users
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The project includes comprehensive test coverage using **testify** and **mockery**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
task test
|
||||||
|
# or
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
task test-verbose
|
||||||
|
# or
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
task test-coverage
|
||||||
|
# or
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
- **Unit Tests**: Domain entities and services with table-driven tests
|
||||||
|
- **Use Case Tests**: Application layer with generated mocks using testify/mockery
|
||||||
|
- **Mock Generation**: Use `task generate-mocks` to regenerate mocks
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
|
||||||
|
### Why decimal.Decimal for Money Handling
|
||||||
|
|
||||||
|
We chose `shopspring/decimal` over floating-point numbers and other alternatives for the following critical reasons:
|
||||||
|
|
||||||
|
#### Financial Accuracy Requirements
|
||||||
|
- Floating point arithmetic cannot represent decimal numbers exactly
|
||||||
|
- Example: `0.1 + 0.2 ≠ 0.3` in floating-point arithmetic
|
||||||
|
- Currency conversion errors compound with multiple operations
|
||||||
|
- CoinMarketCap API returns exchange rates with high precision (8+ decimal places)
|
||||||
|
|
||||||
|
#### Real-world Impact
|
||||||
|
```go
|
||||||
|
// Problematic with float64
|
||||||
|
Bad: float64(123.45) * float64(0.000016) = 0.001975200000000001
|
||||||
|
|
||||||
|
// Accurate with decimal.Decimal
|
||||||
|
Good: decimal(123.45) * decimal(0.000016) = 0.001975200000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Industry Standards
|
||||||
|
- Banking and financial systems never use floating-point for monetary calculations
|
||||||
|
- Major payment APIs (Stripe, PayPal) use string representations to avoid precision loss
|
||||||
|
- `shopspring/decimal` is the Go standard for financial applications (used by Kubernetes billing, trading systems)
|
||||||
|
|
||||||
|
#### Alternative Analysis
|
||||||
|
- **big.Rat**: Overkill for this use case, overly complex API for basic currency operations
|
||||||
|
- **Integer scaling**: Requires manual precision management, error-prone across currencies with different decimal places (USD=2, BTC=8, ETH=18)
|
||||||
|
- **float64**: Unacceptable precision loss for financial data
|
||||||
|
|
||||||
|
#### Performance vs Precision Trade-off
|
||||||
|
- Using integer scaling would significantly increase development complexity with manual precision management across different currency types
|
||||||
|
- decimal.Decimal adds only microseconds vs potentially costly conversion errors
|
||||||
|
- Ensures user trust by avoiding strange rounding artifacts
|
||||||
|
- Better maintainability with clear intent and JSON-friendly serialization
|
||||||
|
|
||||||
|
**Conclusion**: The cost of precision errors in financial software far exceeds the minimal performance overhead of using decimal.Decimal.
|
||||||
|
|
||||||
|
### Currency Handling Strategy
|
||||||
|
|
||||||
|
Our implementation uses CoinMarketCap's `/v1/cryptocurrency/quotes/latest` endpoint with intelligent fallback logic:
|
||||||
|
|
||||||
|
- **Direct request**: Try the requested conversion (e.g., `USD → BTC`)
|
||||||
|
- **Fallback on empty data**: If no data returned, try reverse direction (`BTC → USD`) and invert the rate
|
||||||
|
- **Error handling**: Distinguish between unknown symbols, invalid parameters, and network issues
|
||||||
|
|
||||||
|
This approach handles both crypto-to-fiat and fiat-to-crypto conversions using only the cryptocurrency endpoint:
|
||||||
|
- **BTC → USD**: Direct API call works
|
||||||
|
- **USD → BTC**: API call fails (USD not in crypto DB), retry as BTC → USD, then invert rate
|
||||||
|
|
||||||
|
The automatic fallback with rate inversion eliminates the need for separate fiat currency handling while maintaining mathematical precision using decimal arithmetic.
|
||||||
|
|
||||||
|
### Currency Precision Strategy
|
||||||
|
|
||||||
|
To keep the implementation simple, we use a uniform precision rule:
|
||||||
|
|
||||||
|
- **All currencies**: 8 decimal places (covers cryptocurrency precision requirements)
|
||||||
|
|
||||||
|
This approach handles high-precision cryptocurrencies properly while being acceptable for fiat currencies (even though they typically use 2 decimal places). In a production environment, we would maintain per-currency precision metadata, but 8 decimals covers all practical use cases without overcomplicating the initial implementation.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
This project follows Clean Architecture principles and SOLID design patterns as specified in the requirements.
|
||||||
59
Taskfile.yml
Normal file
59
Taskfile.yml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
test:
|
||||||
|
desc: Run all tests
|
||||||
|
cmds:
|
||||||
|
- go test ./...
|
||||||
|
|
||||||
|
test-verbose:
|
||||||
|
desc: Run all tests with verbose output
|
||||||
|
cmds:
|
||||||
|
- go test -v ./...
|
||||||
|
|
||||||
|
test-coverage:
|
||||||
|
desc: Run tests with coverage report
|
||||||
|
cmds:
|
||||||
|
- go test -coverprofile=coverage.out ./...
|
||||||
|
- go tool cover -html=coverage.out -o coverage.html
|
||||||
|
- echo "Coverage report generated at coverage.html"
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Build the application
|
||||||
|
cmds:
|
||||||
|
- go build -o bin/converter ./cmd/converter
|
||||||
|
|
||||||
|
run:
|
||||||
|
desc: "Run the application with parameters (usage: task run -- <amount> <from> <to>)"
|
||||||
|
deps: [build]
|
||||||
|
cmds:
|
||||||
|
- "./bin/converter {{.CLI_ARGS}}"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
desc: Clean build artifacts and coverage files
|
||||||
|
cmds:
|
||||||
|
- rm -rf bin/
|
||||||
|
- rm -f coverage.out coverage.html
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: Run linter (if available)
|
||||||
|
cmds:
|
||||||
|
- go vet ./...
|
||||||
|
- go fmt ./...
|
||||||
|
|
||||||
|
deps:
|
||||||
|
desc: Download dependencies
|
||||||
|
cmds:
|
||||||
|
- go mod download
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
generate-mocks:
|
||||||
|
desc: Generate mocks using mockery
|
||||||
|
cmds:
|
||||||
|
- go run github.com/vektra/mockery/v2@latest --name=RateProvider --dir=./app --output=./app/mocks
|
||||||
|
|
||||||
|
all:
|
||||||
|
desc: Run full build pipeline
|
||||||
|
deps: [deps, generate-mocks, lint, test, build]
|
||||||
|
cmds:
|
||||||
|
- echo "Build pipeline completed successfully!"
|
||||||
122
app/convert_currency.go
Normal file
122
app/convert_currency.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"converter/domain"
|
||||||
|
"converter/infrastructure/coinmarketcap"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const (
|
||||||
|
CurrencyPrecision = 8 // All currencies use 8 decimal precision
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateProvider defines the contract for fetching exchange rates
|
||||||
|
// 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
|
||||||
|
type RateProvider interface {
|
||||||
|
GetRate(ctx context.Context, fromCode, toCode string) (coinmarketcap.RateDTO, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertCurrencyUseCase handles currency conversion operations
|
||||||
|
type ConvertCurrencyUseCase struct {
|
||||||
|
rateProvider RateProvider
|
||||||
|
converter *domain.CurrencyConverter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConvertCurrencyUseCase creates a new ConvertCurrencyUseCase
|
||||||
|
func NewConvertCurrencyUseCase(rateProvider RateProvider, converter *domain.CurrencyConverter) *ConvertCurrencyUseCase {
|
||||||
|
return &ConvertCurrencyUseCase{
|
||||||
|
rateProvider: rateProvider,
|
||||||
|
converter: converter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs currency conversion
|
||||||
|
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
|
||||||
|
fromCurrency, err := domain.NewCurrency(fromCode, fromCode, CurrencyPrecision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Money{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create money object from input
|
||||||
|
money, err := domain.NewMoney(amount, fromCurrency)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Money{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get exchange rate DTO from provider
|
||||||
|
rateDTO, err := uc.rateProvider.GetRate(ctx, fromCode, toCode)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Money{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert DTO to domain Rate (domain logic)
|
||||||
|
rate, err := uc.createDomainRate(rateDTO, fromCode, toCode)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Money{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert using domain service
|
||||||
|
result, err := uc.converter.Convert(money, rate)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Money{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDomainRate converts RateDTO to domain.Rate with proper domain validation
|
||||||
|
func (uc *ConvertCurrencyUseCase) createDomainRate(dto coinmarketcap.RateDTO, requestedFromCode, requestedToCode string) (domain.Rate, error) {
|
||||||
|
// Convert price to decimal with proper precision first
|
||||||
|
priceStr := fmt.Sprintf("%.*f", CurrencyPrecision, dto.Price)
|
||||||
|
rateValue, err := decimal.NewFromString(priceStr)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Rate{}, fmt.Errorf("invalid price format: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we got a reversed rate and handle accordingly
|
||||||
|
if dto.FromCode == requestedToCode && dto.ToCode == requestedFromCode {
|
||||||
|
// Reversed: we got BTC->USD but requested USD->BTC
|
||||||
|
// dto.FromName always contains the crypto name (Bitcoin)
|
||||||
|
if rateValue.IsZero() {
|
||||||
|
return domain.Rate{}, fmt.Errorf("cannot invert zero price")
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCurrency, err := domain.NewCurrency(requestedFromCode, dto.ToName, CurrencyPrecision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Rate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toCurrency, err := domain.NewCurrency(requestedToCode, dto.FromName, CurrencyPrecision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Rate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
invertedRate := decimal.NewFromInt(1).Div(rateValue)
|
||||||
|
return domain.NewRate(fromCurrency, toCurrency, invertedRate, dto.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct: we got exactly what we requested
|
||||||
|
// dto.FromName contains the crypto name
|
||||||
|
fromCurrency, err := domain.NewCurrency(dto.FromCode, dto.FromName, CurrencyPrecision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Rate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toCurrency, err := domain.NewCurrency(dto.ToCode, dto.ToName, CurrencyPrecision)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Rate{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain.NewRate(fromCurrency, toCurrency, rateValue, dto.Source)
|
||||||
|
}
|
||||||
146
app/convert_currency_test.go
Normal file
146
app/convert_currency_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"converter/app/mocks"
|
||||||
|
"converter/domain"
|
||||||
|
"converter/infrastructure/coinmarketcap"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertCurrencyUseCase_Execute(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
amount string
|
||||||
|
fromCode string
|
||||||
|
toCode string
|
||||||
|
wantCode string
|
||||||
|
wantErr bool
|
||||||
|
setupMock func(*mocks.RateProvider)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "direct conversion - crypto to fiat",
|
||||||
|
amount: "1.0",
|
||||||
|
fromCode: "BTC",
|
||||||
|
toCode: "USD",
|
||||||
|
wantCode: "USD",
|
||||||
|
wantErr: false,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
// Direct rate: BTC->USD
|
||||||
|
rateDTO := coinmarketcap.RateDTO{
|
||||||
|
FromCode: "BTC", // Crypto symbol
|
||||||
|
ToCode: "USD", // Fiat code
|
||||||
|
FromName: "Bitcoin", // Crypto name from API
|
||||||
|
ToName: "USD", // Fiat code
|
||||||
|
Price: 50000.0, // BTC price in USD
|
||||||
|
Source: "coinmarketcap",
|
||||||
|
}
|
||||||
|
m.On("GetRate", mock.Anything, "BTC", "USD").Return(rateDTO, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reversed conversion - fiat to crypto",
|
||||||
|
amount: "50000.0",
|
||||||
|
fromCode: "USD",
|
||||||
|
toCode: "BTC",
|
||||||
|
wantCode: "BTC",
|
||||||
|
wantErr: false,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
// Reversed rate: requested USD->BTC but got BTC->USD
|
||||||
|
rateDTO := coinmarketcap.RateDTO{
|
||||||
|
FromCode: "BTC", // What API actually returned
|
||||||
|
ToCode: "USD", // What API actually returned
|
||||||
|
FromName: "Bitcoin", // Crypto name from API
|
||||||
|
ToName: "USD", // Fiat code
|
||||||
|
Price: 50000.0, // BTC price in USD (will be inverted)
|
||||||
|
Source: "coinmarketcap",
|
||||||
|
}
|
||||||
|
m.On("GetRate", mock.Anything, "USD", "BTC").Return(rateDTO, nil)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid amount",
|
||||||
|
amount: "invalid",
|
||||||
|
fromCode: "USD",
|
||||||
|
toCode: "BTC",
|
||||||
|
wantCode: "",
|
||||||
|
wantErr: true,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
// No mock setup needed - error occurs before calling provider
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative amount",
|
||||||
|
amount: "-50.00",
|
||||||
|
fromCode: "USD",
|
||||||
|
toCode: "BTC",
|
||||||
|
wantCode: "",
|
||||||
|
wantErr: true,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
// No mock setup needed - error occurs before calling provider
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rate not found",
|
||||||
|
amount: "100.00",
|
||||||
|
fromCode: "XYZ",
|
||||||
|
toCode: "ABC",
|
||||||
|
wantCode: "",
|
||||||
|
wantErr: true,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
m.On("GetRate", mock.Anything, "XYZ", "ABC").Return(coinmarketcap.RateDTO{}, domain.ErrRateNotFound)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "provider error",
|
||||||
|
amount: "100.00",
|
||||||
|
fromCode: "USD",
|
||||||
|
toCode: "INVALID",
|
||||||
|
wantCode: "",
|
||||||
|
wantErr: true,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
m.On("GetRate", mock.Anything, "USD", "INVALID").Return(coinmarketcap.RateDTO{}, domain.ErrUnsupportedCurrency)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty currency code",
|
||||||
|
amount: "100.00",
|
||||||
|
fromCode: "",
|
||||||
|
toCode: "BTC",
|
||||||
|
wantCode: "",
|
||||||
|
wantErr: true,
|
||||||
|
setupMock: func(m *mocks.RateProvider) {
|
||||||
|
// No mock setup needed - error occurs before calling provider
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create fresh mock and use case for each test
|
||||||
|
mockProvider := mocks.NewRateProvider(t)
|
||||||
|
converter := domain.NewCurrencyConverter()
|
||||||
|
useCase := NewConvertCurrencyUseCase(mockProvider, converter)
|
||||||
|
|
||||||
|
// Setup mock expectations
|
||||||
|
tt.setupMock(mockProvider)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
result, err := useCase.Execute(context.Background(), tt.amount, tt.fromCode, tt.toCode)
|
||||||
|
|
||||||
|
// Assert error expectations
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err, "Execute() should return error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert success case
|
||||||
|
assert.NoError(t, err, "Execute() should not return error")
|
||||||
|
assert.Equal(t, tt.wantCode, result.Currency.Code, "Currency code mismatch")
|
||||||
|
assert.True(t, result.Amount.IsPositive(), "Amount should be positive")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/mocks/RateProvider.go
Normal file
57
app/mocks/RateProvider.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Code generated by mockery v2.53.5. DO NOT EDIT.
|
||||||
|
|
||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
coinmarketcap "converter/infrastructure/coinmarketcap"
|
||||||
|
|
||||||
|
mock "github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateProvider is an autogenerated mock type for the RateProvider type
|
||||||
|
type RateProvider struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRate provides a mock function with given fields: ctx, fromCode, toCode
|
||||||
|
func (_m *RateProvider) GetRate(ctx context.Context, fromCode string, toCode string) (coinmarketcap.RateDTO, error) {
|
||||||
|
ret := _m.Called(ctx, fromCode, toCode)
|
||||||
|
|
||||||
|
if len(ret) == 0 {
|
||||||
|
panic("no return value specified for GetRate")
|
||||||
|
}
|
||||||
|
|
||||||
|
var r0 coinmarketcap.RateDTO
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string, string) (coinmarketcap.RateDTO, error)); ok {
|
||||||
|
return rf(ctx, fromCode, toCode)
|
||||||
|
}
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string, string) coinmarketcap.RateDTO); ok {
|
||||||
|
r0 = rf(ctx, fromCode, toCode)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Get(0).(coinmarketcap.RateDTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
|
||||||
|
r1 = rf(ctx, fromCode, toCode)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateProvider creates a new instance of RateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||||
|
// The first argument is typically a *testing.T value.
|
||||||
|
func NewRateProvider(t interface {
|
||||||
|
mock.TestingT
|
||||||
|
Cleanup(func())
|
||||||
|
}) *RateProvider {
|
||||||
|
mock := &RateProvider{}
|
||||||
|
mock.Mock.Test(t)
|
||||||
|
|
||||||
|
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||||
|
|
||||||
|
return mock
|
||||||
|
}
|
||||||
70
cmd/converter/main.go
Normal file
70
cmd/converter/main.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"converter/app"
|
||||||
|
"converter/domain"
|
||||||
|
"converter/infrastructure/coinmarketcap"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
expectedArgs = 4 // program name + 3 arguments
|
||||||
|
apiKeyEnvVar = "CMC_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Check command line arguments
|
||||||
|
if len(os.Args) != expectedArgs {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s <amount> <from_currency> <to_currency>\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Example: %s 100.50 USD BTC\n", os.Args[0])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse arguments
|
||||||
|
amount := os.Args[1]
|
||||||
|
fromCurrency := os.Args[2]
|
||||||
|
toCurrency := os.Args[3]
|
||||||
|
|
||||||
|
// Get API key from environment
|
||||||
|
apiKey := os.Getenv(apiKeyEnvVar)
|
||||||
|
if apiKey == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %s environment variable is required\n", apiKeyEnvVar)
|
||||||
|
fmt.Fprintf(os.Stderr, "Please set your CoinMarketCap API key:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "export %s=your_api_key_here\n", apiKeyEnvVar)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dependencies
|
||||||
|
client := coinmarketcap.NewClient(apiKey)
|
||||||
|
converter := domain.NewCurrencyConverter()
|
||||||
|
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
|
||||||
|
result, err := useCase.Execute(ctx, amount, fromCurrency, toCurrency)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output result
|
||||||
|
fmt.Printf("%s %s\n", result.Amount.StringFixed(8), result.Currency.Code)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue