From 46065450f03eaa724cc45c818442681c1afce295 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 17:35:01 +0000 Subject: [PATCH 1/3] fix: align mock server with actual 3commas API structure BREAKING CHANGE: Mock server now uses generated OpenAPI types instead of simplified custom types The mock server was using custom simplified types that didn't match the actual 3commas API structure. This caused consumers to receive different data structures than the real API provides. ## Critical Changes: **bot_events structure:** - Before: 11 fields (action, coin, type, status, price, size, order_type, order_size, order_position, is_market, created_at) - After: 2 fields (message, created_at) - matching the real API **Bot and Deal types:** - Now use full tcmock.Bot and tcmock.Deal generated from OpenAPI spec - Bot: ~50 fields matching real API instead of 4 custom fields - Deal: ~100 fields matching real API instead of 8 custom fields ## Migration Guide: ### Before: ```go mockServer.AddBot(server.Bot{ ID: 1, Name: "Test", Enabled: true, AccountID: 123, }) mockServer.AddDeal(1, server.Deal{ ID: 101, BotID: 1, Status: "active", Events: []server.BotEvent{ {Action: "place", Coin: "BTC", ...}, }, }) ``` ### After: ```go mockServer.AddBot(server.NewBot(1, "Test", 123, true)) deal := server.NewDeal(101, 1, "USDT_BTC", "active") server.AddBotEvent(&deal, "Placing base order. Price: 50000.0") mockServer.AddDeal(deal) ``` ## New Helper Functions: - `server.NewBot()` - Create bot with sensible defaults - `server.NewDeal()` - Create deal with required fields - `server.AddBotEvent()` - Add event with message and timestamp - `TestServer.AddBotEventToDeal()` - Add event to existing deal Fixes the core issue where the mock didn't behave like the actual 3commas server. --- examples/example_test.go | 86 +++++--------------- server/server.go | 24 +++--- server/server_test.go | 128 ++++++++++------------------- server/state.go | 82 +++++++++++-------- server/types.go | 168 +++++++++++++++++++++++++++++---------- 5 files changed, 250 insertions(+), 238 deletions(-) diff --git a/examples/example_test.go b/examples/example_test.go index 770ac31..1649b49 100644 --- a/examples/example_test.go +++ b/examples/example_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/recomma/3commas-mock/server" + "github.com/recomma/3commas-mock/tcmock" ) // TestExampleBasicUsage demonstrates basic usage of the mock server @@ -15,38 +16,11 @@ func TestExampleBasicUsage(t *testing.T) { defer mockServer.Close() // Configure mock state - mockServer.AddBot(server.Bot{ - ID: 1, - Name: "Test Bot", - Enabled: true, - AccountID: 12345, - }) - - mockServer.AddDeal(1, server.Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - ToCurrency: "BTC", - FromCurrency: "USDT", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []server.BotEvent{ - { - CreatedAt: "2024-01-15T10:30:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "50000.0", - Size: "0.0002", - OrderType: "base", - OrderSize: 1, - OrderPosition: 1, - IsMarket: false, - }, - }, - }) + mockServer.AddBot(server.NewBot(1, "Test Bot", 12345, true)) + + deal := server.NewDeal(101, 1, "USDT_BTC", "active") + server.AddBotEvent(&deal, "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC") + mockServer.AddDeal(deal) // Test ListBots resp, err := http.Get(mockServer.URL() + "/ver1/bots?scope=enabled") @@ -55,7 +29,7 @@ func TestExampleBasicUsage(t *testing.T) { } defer resp.Body.Close() - var bots []server.Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode bots: %v", err) } @@ -63,8 +37,8 @@ func TestExampleBasicUsage(t *testing.T) { if len(bots) != 1 { t.Fatalf("expected 1 bot, got %d", len(bots)) } - if bots[0].ID != 1 { - t.Fatalf("expected bot ID 1, got %d", bots[0].ID) + if bots[0].Id != 1 { + t.Fatalf("expected bot ID 1, got %d", bots[0].Id) } // Test GetDeal @@ -74,16 +48,16 @@ func TestExampleBasicUsage(t *testing.T) { } defer resp.Body.Close() - var deal server.Deal - if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { t.Fatalf("failed to decode deal: %v", err) } - if deal.ID != 101 { - t.Fatalf("expected deal ID 101, got %d", deal.ID) + if dealResp.Id != 101 { + t.Fatalf("expected deal ID 101, got %d", dealResp.Id) } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } @@ -92,36 +66,20 @@ func TestExampleEventAddition(t *testing.T) { mockServer := server.NewTestServer(t) defer mockServer.Close() - mockServer.AddBot(server.Bot{ID: 1, Name: "Test Bot", Enabled: true}) - mockServer.AddDeal(1, server.Deal{ - ID: 101, - BotID: 1, - Status: "active", - Events: []server.BotEvent{}, - }) + mockServer.AddBot(server.NewBot(1, "Test Bot", 12345, true)) + deal := server.NewDeal(101, 1, "USDT_BTC", "active") + mockServer.AddDeal(deal) // Simulate safety order being placed - mockServer.AddBotEvent(101, server.BotEvent{ - CreatedAt: "2024-01-15T10:32:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "48750.0", - Size: "0.0004", - OrderType: "safety", - OrderSize: 2, - OrderPosition: 1, - IsMarket: false, - }) + mockServer.AddBotEventToDeal(101, "Placing safety order. Price: 48750.0 USDT Size: 0.0004 BTC") // Fetch deal and verify event was added - deal, ok := mockServer.GetDealByID(101) + dealResp, ok := mockServer.GetDealByID(101) if !ok { t.Fatal("deal not found") } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } diff --git a/server/server.go b/server/server.go index 456ce9a..c17eb5f 100644 --- a/server/server.go +++ b/server/server.go @@ -17,8 +17,8 @@ type TestServer struct { mu sync.RWMutex // State - bots map[int]*Bot - deals map[int]*Deal + bots map[int]*tcmock.Bot + deals map[int]*tcmock.Deal // Error simulation rateLimitEnabled bool @@ -30,8 +30,8 @@ type TestServer struct { // NewTestServer creates a new mock 3Commas server for testing func NewTestServer(t *testing.T) *TestServer { ts := &TestServer{ - bots: make(map[int]*Bot), - deals: make(map[int]*Deal), + bots: make(map[int]*tcmock.Bot), + deals: make(map[int]*tcmock.Deal), botErrors: make(map[int]error), dealErrors: make(map[int]error), } @@ -58,8 +58,8 @@ func (ts *TestServer) Reset() { ts.mu.Lock() defer ts.mu.Unlock() - ts.bots = make(map[int]*Bot) - ts.deals = make(map[int]*Deal) + ts.bots = make(map[int]*tcmock.Bot) + ts.deals = make(map[int]*tcmock.Deal) ts.botErrors = make(map[int]error) ts.dealErrors = make(map[int]error) ts.rateLimitEnabled = false @@ -85,14 +85,14 @@ func (ts *TestServer) ListBots(w http.ResponseWriter, r *http.Request, params tc } // Filter bots based on scope parameter - var result []Bot + var result []tcmock.Bot for _, bot := range ts.bots { // Apply scope filter if provided if params.Scope != nil { - if *params.Scope == tcmock.Enabled && !bot.Enabled { + if *params.Scope == tcmock.Enabled && !bot.IsEnabled { continue } - if *params.Scope == tcmock.Disabled && bot.Enabled { + if *params.Scope == tcmock.Disabled && bot.IsEnabled { continue } } @@ -110,15 +110,15 @@ func (ts *TestServer) ListDeals(w http.ResponseWriter, r *http.Request, params t defer ts.mu.RUnlock() // Filter deals based on parameters - var result []Deal + var result []tcmock.Deal for _, deal := range ts.deals { // Apply bot_id filter if provided - if params.BotId != nil && deal.BotID != *params.BotId { + if params.BotId != nil && deal.BotId != *params.BotId { continue } // Apply scope filter if provided - if params.Scope != nil && string(*params.Scope) != deal.Status { + if params.Scope != nil && tcmock.DealStatus(*params.Scope) != deal.Status { continue } diff --git a/server/server_test.go b/server/server_test.go index 7ac4b6b..1f258c4 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "testing" + + "github.com/recomma/3commas-mock/tcmock" ) func TestNewTestServer(t *testing.T) { @@ -29,7 +31,7 @@ func TestListBots_Empty(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -44,18 +46,8 @@ func TestListBots_WithBots(t *testing.T) { defer ts.Close() // Add some bots - ts.AddBot(Bot{ - ID: 1, - Name: "Test Bot 1", - Enabled: true, - AccountID: 123, - }) - ts.AddBot(Bot{ - ID: 2, - Name: "Test Bot 2", - Enabled: false, - AccountID: 123, - }) + ts.AddBot(NewBot(1, "Test Bot 1", 123, true)) + ts.AddBot(NewBot(2, "Test Bot 2", 123, false)) resp, err := http.Get(ts.URL() + "/ver1/bots") if err != nil { @@ -63,7 +55,7 @@ func TestListBots_WithBots(t *testing.T) { } defer resp.Body.Close() - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -77,8 +69,8 @@ func TestListBots_FilterByScope(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Enabled Bot", Enabled: true, AccountID: 123}) - ts.AddBot(Bot{ID: 2, Name: "Disabled Bot", Enabled: false, AccountID: 123}) + ts.AddBot(NewBot(1, "Enabled Bot", 123, true)) + ts.AddBot(NewBot(2, "Disabled Bot", 123, false)) // Filter for enabled bots resp, err := http.Get(ts.URL() + "/ver1/bots?scope=enabled") @@ -87,7 +79,7 @@ func TestListBots_FilterByScope(t *testing.T) { } defer resp.Body.Close() - var bots []Bot + var bots []tcmock.Bot if err := json.NewDecoder(resp.Body).Decode(&bots); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -95,7 +87,7 @@ func TestListBots_FilterByScope(t *testing.T) { if len(bots) != 1 { t.Fatalf("expected 1 enabled bot, got %d", len(bots)) } - if !bots[0].Enabled { + if !bots[0].IsEnabled { t.Fatal("expected bot to be enabled") } } @@ -120,32 +112,12 @@ func TestGetDeal_Success(t *testing.T) { defer ts.Close() // Add bot and deal - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - err := ts.AddDeal(1, Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - ToCurrency: "BTC", - FromCurrency: "USDT", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []BotEvent{ - { - CreatedAt: "2024-01-15T10:30:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "50000.0", - Size: "0.0002", - OrderType: "base", - OrderSize: 1, - OrderPosition: 1, - IsMarket: false, - }, - }, - }) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + + deal := NewDeal(101, 1, "USDT_BTC", "active") + AddBotEvent(&deal, "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC") + + err := ts.AddDeal(deal) if err != nil { t.Fatalf("failed to add deal: %v", err) } @@ -160,19 +132,22 @@ func TestGetDeal_Success(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } - var deal Deal - if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { t.Fatalf("failed to decode response: %v", err) } - if deal.ID != 101 { - t.Fatalf("expected deal ID 101, got %d", deal.ID) + if dealResp.Id != 101 { + t.Fatalf("expected deal ID 101, got %d", dealResp.Id) + } + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if dealResp.BotEvents[0].Message == nil { + t.Fatal("expected bot event to have a message") } - if deal.Events[0].Action != "place" { - t.Fatalf("expected action 'place', got '%s'", deal.Events[0].Action) + if *dealResp.BotEvents[0].Message != "Placing base order. Price: 50000.0 USDT Size: 0.0002 BTC" { + t.Fatalf("expected specific message, got '%s'", *dealResp.BotEvents[0].Message) } } @@ -180,42 +155,23 @@ func TestAddBotEvent(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - ts.AddDeal(1, Deal{ - ID: 101, - BotID: 1, - Pair: "USDT_BTC", - Status: "active", - CreatedAt: "2024-01-15T10:30:00.000Z", - UpdatedAt: "2024-01-15T10:30:00.000Z", - Events: []BotEvent{}, - }) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + deal := NewDeal(101, 1, "USDT_BTC", "active") + ts.AddDeal(deal) // Add an event - err := ts.AddBotEvent(101, BotEvent{ - CreatedAt: "2024-01-15T10:32:00.000Z", - Action: "place", - Coin: "BTC", - Type: "buy", - Status: "active", - Price: "48750.0", - Size: "0.0004", - OrderType: "safety", - OrderSize: 2, - OrderPosition: 1, - IsMarket: false, - }) + err := ts.AddBotEventToDeal(101, "Placing safety order. Price: 48750.0 USDT Size: 0.0004 BTC") if err != nil { t.Fatalf("failed to add bot event: %v", err) } // Verify event was added - deal, ok := ts.GetDealByID(101) + dealResp, ok := ts.GetDealByID(101) if !ok { t.Fatal("deal not found") } - if len(deal.Events) != 1 { - t.Fatalf("expected 1 event, got %d", len(deal.Events)) + if len(dealResp.BotEvents) != 1 { + t.Fatalf("expected 1 event, got %d", len(dealResp.BotEvents)) } } @@ -245,12 +201,12 @@ func TestListDeals_FilterByBot(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Bot 1", Enabled: true, AccountID: 123}) - ts.AddBot(Bot{ID: 2, Name: "Bot 2", Enabled: true, AccountID: 123}) + ts.AddBot(NewBot(1, "Bot 1", 123, true)) + ts.AddBot(NewBot(2, "Bot 2", 123, true)) - ts.AddDeal(1, Deal{ID: 101, BotID: 1, Status: "active"}) - ts.AddDeal(1, Deal{ID: 102, BotID: 1, Status: "active"}) - ts.AddDeal(2, Deal{ID: 103, BotID: 2, Status: "active"}) + ts.AddDeal(NewDeal(101, 1, "USDT_BTC", "active")) + ts.AddDeal(NewDeal(102, 1, "USDT_ETH", "active")) + ts.AddDeal(NewDeal(103, 2, "USDT_BTC", "active")) resp, err := http.Get(ts.URL() + "/ver1/deals?bot_id=1") if err != nil { @@ -258,7 +214,7 @@ func TestListDeals_FilterByBot(t *testing.T) { } defer resp.Body.Close() - var deals []Deal + var deals []tcmock.Deal if err := json.NewDecoder(resp.Body).Decode(&deals); err != nil { t.Fatalf("failed to decode response: %v", err) } @@ -272,8 +228,8 @@ func TestReset(t *testing.T) { ts := NewTestServer(t) defer ts.Close() - ts.AddBot(Bot{ID: 1, Name: "Test Bot", Enabled: true, AccountID: 123}) - ts.AddDeal(1, Deal{ID: 101, BotID: 1, Status: "active"}) + ts.AddBot(NewBot(1, "Test Bot", 123, true)) + ts.AddDeal(NewDeal(101, 1, "USDT_BTC", "active")) ts.SetRateLimitError(true, 60) ts.Reset() diff --git a/server/state.go b/server/state.go index c6c75fc..8c11dc1 100644 --- a/server/state.go +++ b/server/state.go @@ -1,31 +1,35 @@ package server -import "fmt" +import ( + "fmt" + + "github.com/recomma/3commas-mock/tcmock" +) // Bot Management // AddBot adds a bot to the mock server's state -func (ts *TestServer) AddBot(bot Bot) { +func (ts *TestServer) AddBot(bot tcmock.Bot) { ts.mu.Lock() defer ts.mu.Unlock() - ts.bots[bot.ID] = &bot + ts.bots[bot.Id] = &bot } // GetBot retrieves a bot by ID -func (ts *TestServer) GetBot(botID int) (Bot, bool) { +func (ts *TestServer) GetBot(botID int) (tcmock.Bot, bool) { ts.mu.RLock() defer ts.mu.RUnlock() bot, ok := ts.bots[botID] if !ok { - return Bot{}, false + return tcmock.Bot{}, false } return *bot, true } -// UpdateBot updates a bot's state -func (ts *TestServer) UpdateBot(botID int, updates BotUpdate) error { +// UpdateBotEnabled updates a bot's enabled state +func (ts *TestServer) UpdateBotEnabled(botID int, enabled bool) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -34,13 +38,23 @@ func (ts *TestServer) UpdateBot(botID int, updates BotUpdate) error { return fmt.Errorf("bot %d not found", botID) } - if updates.Enabled != nil { - bot.Enabled = *updates.Enabled - } - if updates.Name != nil { - bot.Name = *updates.Name + bot.IsEnabled = enabled + + return nil +} + +// UpdateBotName updates a bot's name +func (ts *TestServer) UpdateBotName(botID int, name string) error { + ts.mu.Lock() + defer ts.mu.Unlock() + + bot, ok := ts.bots[botID] + if !ok { + return fmt.Errorf("bot %d not found", botID) } + bot.Name = &name + return nil } @@ -53,18 +67,18 @@ func (ts *TestServer) RemoveBot(botID int) { // Remove all deals for this bot for dealID, deal := range ts.deals { - if deal.BotID == botID { + if deal.BotId == botID { delete(ts.deals, dealID) } } } // GetAllBots returns all bots in the mock -func (ts *TestServer) GetAllBots() []Bot { +func (ts *TestServer) GetAllBots() []tcmock.Bot { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Bot, 0, len(ts.bots)) + result := make([]tcmock.Bot, 0, len(ts.bots)) for _, bot := range ts.bots { result = append(result, *bot) } @@ -74,34 +88,33 @@ func (ts *TestServer) GetAllBots() []Bot { // Deal Management // AddDeal adds a deal to the mock server's state -func (ts *TestServer) AddDeal(botID int, deal Deal) error { +func (ts *TestServer) AddDeal(deal tcmock.Deal) error { ts.mu.Lock() defer ts.mu.Unlock() // Check if bot exists - if _, ok := ts.bots[botID]; !ok { - return fmt.Errorf("bot %d not found", botID) + if _, ok := ts.bots[deal.BotId]; !ok { + return fmt.Errorf("bot %d not found", deal.BotId) } - deal.BotID = botID - ts.deals[deal.ID] = &deal + ts.deals[deal.Id] = &deal return nil } // GetDealByID retrieves a deal by ID (state management method) -func (ts *TestServer) GetDealByID(dealID int) (Deal, bool) { +func (ts *TestServer) GetDealByID(dealID int) (tcmock.Deal, bool) { ts.mu.RLock() defer ts.mu.RUnlock() deal, ok := ts.deals[dealID] if !ok { - return Deal{}, false + return tcmock.Deal{}, false } return *deal, true } -// UpdateDeal updates a deal's state -func (ts *TestServer) UpdateDeal(dealID int, updates DealUpdate) error { +// UpdateDealStatus updates a deal's status +func (ts *TestServer) UpdateDealStatus(dealID int, status string) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -110,15 +123,14 @@ func (ts *TestServer) UpdateDeal(dealID int, updates DealUpdate) error { return fmt.Errorf("deal %d not found", dealID) } - if updates.Status != nil { - deal.Status = *updates.Status - } + deal.Status = tcmock.DealStatus(status) return nil } -// AddBotEvent adds a new bot event to an existing deal -func (ts *TestServer) AddBotEvent(dealID int, event BotEvent) error { +// AddBotEventToDeal adds a new bot event to an existing deal +// message: Human-readable event description +func (ts *TestServer) AddBotEventToDeal(dealID int, message string) error { ts.mu.Lock() defer ts.mu.Unlock() @@ -127,7 +139,7 @@ func (ts *TestServer) AddBotEvent(dealID int, event BotEvent) error { return fmt.Errorf("deal %d not found", dealID) } - deal.Events = append(deal.Events, event) + AddBotEvent(deal, message) return nil } @@ -140,11 +152,11 @@ func (ts *TestServer) RemoveDeal(dealID int) { } // GetAllDeals returns all deals in the mock -func (ts *TestServer) GetAllDeals() []Deal { +func (ts *TestServer) GetAllDeals() []tcmock.Deal { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Deal, 0, len(ts.deals)) + result := make([]tcmock.Deal, 0, len(ts.deals)) for _, deal := range ts.deals { result = append(result, *deal) } @@ -152,13 +164,13 @@ func (ts *TestServer) GetAllDeals() []Deal { } // GetBotDeals returns deals for a specific bot -func (ts *TestServer) GetBotDeals(botID int) []Deal { +func (ts *TestServer) GetBotDeals(botID int) []tcmock.Deal { ts.mu.RLock() defer ts.mu.RUnlock() - result := make([]Deal, 0) + result := make([]tcmock.Deal, 0) for _, deal := range ts.deals { - if deal.BotID == botID { + if deal.BotId == botID { result = append(result, *deal) } } diff --git a/server/types.go b/server/types.go index 25be168..690d989 100644 --- a/server/types.go +++ b/server/types.go @@ -1,50 +1,136 @@ package server -// BotEvent represents a bot event with full structured data for testing -// This overrides the simple generated type to provide rich event details -type BotEvent struct { - CreatedAt string `json:"created_at"` - Action string `json:"action"` // "place", "cancel", "cancelled", "modify" - Coin string `json:"coin"` // "BTC", "ETH", etc. - Type string `json:"type"` // "buy", "sell" - Status string `json:"status"` // "active", "filled", "cancelled" - Price string `json:"price"` // Decimal string - Size string `json:"size"` // Decimal string - OrderType string `json:"order_type"` // "base", "safety", "take_profit" - OrderSize int `json:"order_size"` // Order size category - OrderPosition int `json:"order_position"` // Safety order position - IsMarket bool `json:"is_market"` // Whether order is market order -} +import ( + "time" -// Bot represents a simplified bot for testing -type Bot struct { - ID int `json:"id"` - Name string `json:"name"` - Enabled bool `json:"is_enabled"` - AccountID int `json:"account_id"` - // Add other fields as needed -} + "github.com/oapi-codegen/nullable" + "github.com/recomma/3commas-mock/tcmock" +) + +// Helper types and functions for creating test data with the full generated types -// Deal represents a simplified deal for testing -type Deal struct { - ID int `json:"id"` - BotID int `json:"bot_id"` - Pair string `json:"pair"` - Status string `json:"status"` - ToCurrency string `json:"to_currency"` - FromCurrency string `json:"from_currency"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - Events []BotEvent `json:"bot_events"` +// NewBot creates a minimal Bot with required fields populated +// This is a helper to make it easier to create test bots +func NewBot(id int, name string, accountID int, enabled bool) tcmock.Bot { + now := time.Now() + namePtr := &name + strategy := tcmock.BotStrategyLong + return tcmock.Bot{ + Id: id, + Name: namePtr, + AccountId: accountID, + AccountName: "Test Account", + IsEnabled: enabled, + CreatedAt: now, + ActiveDealsCount: 0, + FinishedDealsCount: "0", + ActiveDeals: []tcmock.Deal{}, + ActiveDealsBtcProfit: "0", + ActiveDealsUsdProfit: "0", + BtcFundsLockedInActiveDeals: "0", + FundsLockedInActiveDeals: "0", + FinishedDealsProfitUsd: "0", + Deletable: true, + Strategy: &strategy, + } } -// BotUpdate represents fields that can be updated on a bot -type BotUpdate struct { - Enabled *bool - Name *string +// NewDeal creates a minimal Deal with required fields populated +// This is a helper to make it easier to create test deals +func NewDeal(id int, botID int, pair string, status string) tcmock.Deal { + now := time.Now() + // Extract currency from pair (assumes format like "USDT_BTC") + toCurrency := "BTC" + if len(pair) > 3 { + toCurrency = pair[len(pair)-3:] + } + return tcmock.Deal{ + Id: id, + BotId: botID, + Pair: pair, + Status: tcmock.DealStatus(status), + CreatedAt: now, + UpdatedAt: now, + BotEvents: []struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Message *string `json:"message,omitempty"` + }{}, + AccountId: 1, + AccountName: "Test Account", + BotName: "Test Bot", + FromCurrency: "USDT", + ToCurrency: toCurrency, + BaseOrderVolume: "10", + BaseOrderVolumeType: "quote_currency", + BoughtAmount: "0", + BoughtAveragePrice: "0", + BoughtVolume: "0", + SoldAmount: "0", + SoldAveragePrice: "0", + SoldVolume: "0", + ActualProfitPercentage: "0", + ActualProfit: nullable.NewNullableWithValue("0"), + ActualUsdProfit: nullable.NewNullableWithValue("0"), + FinalProfit: "0", + FinalProfitPercentage: "0", + Cancellable: false, + CompletedManualSafetyOrdersCount: 0, + CompletedSafetyOrdersCount: 0, + CurrentActiveSafetyOrders: 0, + CurrentActiveSafetyOrdersCount: 0, + CurrentPrice: "0", + TakeProfitPrice: "0", + MaxSafetyOrders: 0, + ActiveManualSafetyOrders: 0, + TrailingEnabled: false, + TslEnabled: false, + StopLossPercentage: "0", + ErrorMessage: nullable.NewNullableWithValue(""), + ProfitCurrency: "quote_currency", + StopLossType: "stop_loss", + SafetyOrderStepPercentage: "0", + TakeProfitType: "total", + StopLossTimeoutEnabled: false, + StopLossTimeoutInSeconds: 0, + AddFundable: false, + SmartTradeConvertable: false, + PanicSellable: false, + MarketType: "spot", + OrderbookPriceCurrency: "USDT", + SafetyOrderVolume: "10", + SafetyOrderVolumeType: "quote_currency", + MartingaleStepCoefficient: "1.0", + MartingaleVolumeCoefficient: "1.0", + MinProfitPercentage: "0", + SafetyStrategyList: []map[string]interface{}{}, + SlToBreakevenEnabled: false, + CloseStrategyList: []map[string]interface{}{}, + TakeProfitSteps: []struct { + AmountPercentage *float32 `json:"amount_percentage,omitempty"` + Editable *bool `json:"editable,omitempty"` + ExecutionTimestamp nullable.Nullable[time.Time] `json:"execution_timestamp,omitempty"` + Id *int `json:"id,omitempty"` + InitialAmount *string `json:"initial_amount,omitempty"` + PanicSellable *bool `json:"panic_sellable,omitempty"` + Price *string `json:"price,omitempty"` + ProfitPercentage *float32 `json:"profit_percentage,omitempty"` + Status *string `json:"status,omitempty"` + TradeId *int `json:"trade_id,omitempty"` + }{}, + Type: "simple", + } } -// DealUpdate represents fields that can be updated on a deal -type DealUpdate struct { - Status *string +// AddBotEvent adds a bot event to a deal +// message: Human-readable event description +func AddBotEvent(deal *tcmock.Deal, message string) { + now := time.Now() + msg := message + deal.BotEvents = append(deal.BotEvents, struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Message *string `json:"message,omitempty"` + }{ + CreatedAt: &now, + Message: &msg, + }) } From 607e81ed8529e3496147f39676e59a9dc5b05c7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 18:27:38 +0000 Subject: [PATCH 2/3] feat: add VCR cassette loading support for real API responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to load go-vcr cassettes to populate mock server with real 3commas API responses. This enables testing against actual production data without manual mocking. ## Features **Core API:** - `LoadVCRCassette(path)` - Load single VCR cassette - `LoadVCRCassettes(...paths)` - Load multiple cassettes **What Gets Loaded:** - ✅ Bots with ALL 50+ fields from real API - ✅ Deals with ALL 100+ fields from real API - ✅ **bot_events PRESERVED exactly as recorded** (critical for testing) - ✅ Auto-creates minimal bots when deals reference non-existent bot IDs **Behavior:** - Only processes successful (2xx) responses - Errors on duplicate IDs (ensures clean VCR preparation) - Skips non-GET requests - Pattern matches: /ver1/deals/{id}/show, /ver1/deals, /ver1/bots ## Usage Example ```go func TestWithRealData(t *testing.T) { mockServer := server.NewTestServer(t) defer mockServer.Close() // Load real API response from VCR cassette mockServer.LoadVCRCassette("testdata/fixtures/my_deal") // Deal now available with ALL real data including bot_events resp, _ := http.Get(mockServer.URL() + "/ver1/deals/123/show") var deal tcmock.Deal json.NewDecoder(resp.Body).Decode(&deal) // bot_events preserved from real 3commas API! assert.Len(t, deal.BotEvents, 3) } ``` ## Why This Matters VCR cassettes contain REAL complex data from 3commas that would be tedious to mock manually: - Complex bot_events messages with actual trading activity - All 100+ deal fields with real profit/loss calculations - Actual account names, pairs, and trading strategies This makes testing more realistic and maintainable. ## Dependencies - Added gopkg.in/dnaeon/go-vcr.v3 for YAML parsing ## Tests - TestLoadVCRCassette_SingleDeal - Basic loading and verification - TestLoadVCRCassette_DuplicateError - Duplicate ID detection - TestLoadVCRCassettes_Multiple - Loading multiple cassettes - TestExampleVCRLoading - Example usage pattern --- examples/example_test.go | 50 +++++++ go.mod | 1 + go.sum | 2 + server/vcr.go | 194 +++++++++++++++++++++++++ server/vcr_test.go | 173 ++++++++++++++++++++++ testdata/fixtures/deal_2376446537.yaml | 25 ++++ 6 files changed, 445 insertions(+) create mode 100644 server/vcr.go create mode 100644 server/vcr_test.go create mode 100644 testdata/fixtures/deal_2376446537.yaml diff --git a/examples/example_test.go b/examples/example_test.go index 1649b49..860d382 100644 --- a/examples/example_test.go +++ b/examples/example_test.go @@ -119,3 +119,53 @@ func TestExampleErrorSimulation(t *testing.T) { t.Fatalf("expected status 200, got %d", resp.StatusCode) } } + +// TestExampleVCRLoading demonstrates loading real API responses from VCR cassettes +func TestExampleVCRLoading(t *testing.T) { + mockServer := server.NewTestServer(t) + defer mockServer.Close() + + // Load a VCR cassette with a real recorded deal from 3commas API + // Note: go-vcr appends .yaml automatically, so omit the extension + err := mockServer.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("failed to load VCR cassette: %v", err) + } + + // Now the deal is available via the API with ALL real data + resp, err := http.Get(mockServer.URL() + "/ver1/deals/2376446537/show") + if err != nil { + t.Fatalf("failed to get deal: %v", err) + } + defer resp.Body.Close() + + var deal tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&deal); err != nil { + t.Fatalf("failed to decode deal: %v", err) + } + + // Verify we got real data from the VCR recording + if deal.Pair != "USDT_DOGE" { + t.Fatalf("expected pair USDT_DOGE, got %s", deal.Pair) + } + + // Most importantly: bot_events are preserved from the real API! + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events from VCR, got %d", len(deal.BotEvents)) + } + + // The messages are real ones from 3commas + if deal.BotEvents[0].Message == nil || + *deal.BotEvents[0].Message != "Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)" { + t.Fatal("bot_events not preserved correctly from VCR") + } + + // Bot was auto-created from the deal data + bot, ok := mockServer.GetBot(16511317) + if !ok { + t.Fatal("bot should have been auto-created from deal") + } + if bot.AccountName != "Demo Account 2080398" { + t.Fatalf("expected account name from VCR, got %s", bot.AccountName) + } +} diff --git a/go.mod b/go.mod index 40ab622..21d57cc 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( golang.org/x/sync v0.9.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/tools v0.25.1 // indirect + gopkg.in/dnaeon/go-vcr.v3 v3.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e5713fb..307f38e 100644 --- a/go.sum +++ b/go.sum @@ -171,6 +171,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v3 v3.2.0 h1:Rltp0Vf+Aq0u4rQXgmXgtgoRDStTnFN83cWgSGSoRzM= +gopkg.in/dnaeon/go-vcr.v3 v3.2.0/go.mod h1:2IMOnnlx9I6u9x+YBsM3tAMx6AlOxnJ0pWxQAzZ79Ag= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/server/vcr.go b/server/vcr.go new file mode 100644 index 0000000..16afcde --- /dev/null +++ b/server/vcr.go @@ -0,0 +1,194 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + + "github.com/recomma/3commas-mock/tcmock" + "gopkg.in/dnaeon/go-vcr.v3/cassette" +) + +// URL pattern matchers +var ( + dealShowPattern = regexp.MustCompile(`/ver1/deals/(\d+)/show$`) + dealsListPattern = regexp.MustCompile(`/ver1/deals(\?.*)?$`) + botsListPattern = regexp.MustCompile(`/ver1/bots(\?.*)?$`) +) + +// LoadVCRCassette loads a VCR cassette and populates mock server state +// - Bots and Deals are loaded with ALL their data from real API responses +// - bot_events are PRESERVED exactly as recorded (this is the valuable part!) +// - Duplicate IDs will return an error +// - Non-2xx responses are skipped +func (ts *TestServer) LoadVCRCassette(cassettePath string) error { + // Load cassette from file + c, err := cassette.Load(cassettePath) + if err != nil { + return fmt.Errorf("failed to load VCR cassette %s: %w", cassettePath, err) + } + + // Process each interaction + for i, interaction := range c.Interactions { + // Skip non-successful responses + if interaction.Response.Code < 200 || interaction.Response.Code >= 300 { + continue + } + + // Only process GET requests + if interaction.Request.Method != http.MethodGet { + continue + } + + // Try to match against known patterns + if err := ts.processInteraction(interaction); err != nil { + return fmt.Errorf("failed to process interaction %d from %s: %w", i, cassettePath, err) + } + } + + return nil +} + +// LoadVCRCassettes loads multiple VCR cassettes in sequence +func (ts *TestServer) LoadVCRCassettes(cassettePaths ...string) error { + for _, path := range cassettePaths { + if err := ts.LoadVCRCassette(path); err != nil { + return err + } + } + return nil +} + +// processInteraction processes a single VCR interaction and adds entities to state +func (ts *TestServer) processInteraction(interaction *cassette.Interaction) error { + url := interaction.Request.URL + body := interaction.Response.Body + + // Match against deal show endpoint: /ver1/deals/{id}/show + if matches := dealShowPattern.FindStringSubmatch(url); matches != nil { + return ts.loadDealFromJSON(body) + } + + // Match against deals list endpoint: /ver1/deals + if dealsListPattern.MatchString(url) { + return ts.loadDealsListFromJSON(body) + } + + // Match against bots list endpoint: /ver1/bots + if botsListPattern.MatchString(url) { + return ts.loadBotsListFromJSON(body) + } + + // Unknown endpoint - skip silently + return nil +} + +// loadDealFromJSON unmarshals a single deal and adds it to state +func (ts *TestServer) loadDealFromJSON(jsonBody string) error { + var deal tcmock.Deal + if err := json.Unmarshal([]byte(jsonBody), &deal); err != nil { + return fmt.Errorf("failed to unmarshal deal: %w", err) + } + + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.deals[deal.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate deal ID %d found in VCR cassette", deal.Id) + } + ts.mu.RUnlock() + + // Check if bot exists, create a minimal one if not + ts.mu.RLock() + botExists := ts.bots[deal.BotId] != nil + ts.mu.RUnlock() + + if !botExists { + // Create a minimal bot based on deal data + bot := NewBot(deal.BotId, deal.BotName, deal.AccountId, true) + bot.AccountName = deal.AccountName + ts.AddBot(bot) + } + + // Add deal with all its data (including bot_events preserved) + ts.mu.Lock() + ts.deals[deal.Id] = &deal + ts.mu.Unlock() + + return nil +} + +// loadDealsListFromJSON unmarshals a list of deals and adds them to state +func (ts *TestServer) loadDealsListFromJSON(jsonBody string) error { + var deals []tcmock.Deal + if err := json.Unmarshal([]byte(jsonBody), &deals); err != nil { + return fmt.Errorf("failed to unmarshal deals list: %w", err) + } + + for _, deal := range deals { + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.deals[deal.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate deal ID %d found in VCR cassette", deal.Id) + } + ts.mu.RUnlock() + + // Check if bot exists, create a minimal one if not + ts.mu.RLock() + botExists := ts.bots[deal.BotId] != nil + ts.mu.RUnlock() + + if !botExists { + // Create a minimal bot based on deal data + bot := NewBot(deal.BotId, deal.BotName, deal.AccountId, true) + bot.AccountName = deal.AccountName + ts.AddBot(bot) + } + + // Add deal with all its data (including bot_events preserved) + ts.mu.Lock() + dealCopy := deal // Create a copy to get the correct pointer + ts.deals[dealCopy.Id] = &dealCopy + ts.mu.Unlock() + } + + return nil +} + +// loadBotsListFromJSON unmarshals a list of bots and adds them to state +func (ts *TestServer) loadBotsListFromJSON(jsonBody string) error { + var bots []tcmock.Bot + if err := json.Unmarshal([]byte(jsonBody), &bots); err != nil { + return fmt.Errorf("failed to unmarshal bots list: %w", err) + } + + for _, bot := range bots { + // Check for duplicate + ts.mu.RLock() + if _, exists := ts.bots[bot.Id]; exists { + ts.mu.RUnlock() + return fmt.Errorf("duplicate bot ID %d found in VCR cassette", bot.Id) + } + ts.mu.RUnlock() + + // Add bot with all its data + ts.mu.Lock() + botCopy := bot // Create a copy to get the correct pointer + ts.bots[botCopy.Id] = &botCopy + ts.mu.Unlock() + } + + return nil +} + +// ExtractDealID is a helper to extract deal ID from a deal show URL +func ExtractDealID(url string) (int, error) { + matches := dealShowPattern.FindStringSubmatch(url) + if matches == nil { + return 0, fmt.Errorf("URL does not match deal show pattern: %s", url) + } + return strconv.Atoi(matches[1]) +} diff --git a/server/vcr_test.go b/server/vcr_test.go new file mode 100644 index 0000000..c629f49 --- /dev/null +++ b/server/vcr_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/recomma/3commas-mock/tcmock" +) + +func TestLoadVCRCassette_SingleDeal(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load VCR cassette with a real recorded deal + // Note: go-vcr appends .yaml automatically, so omit extension + err := ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("failed to load VCR cassette: %v", err) + } + + // Verify the deal was loaded + deal, ok := ts.GetDealByID(2376446537) + if !ok { + t.Fatal("deal 2376446537 not found after loading VCR cassette") + } + + // Verify deal fields from real API + if deal.Pair != "USDT_DOGE" { + t.Errorf("expected pair USDT_DOGE, got %s", deal.Pair) + } + if deal.Status != "bought" { + t.Errorf("expected status bought, got %s", deal.Status) + } + if deal.BotId != 16511317 { + t.Errorf("expected bot_id 16511317, got %d", deal.BotId) + } + + // MOST IMPORTANT: Verify bot_events were preserved from real API + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events, got %d", len(deal.BotEvents)) + } + + // Check first event + if deal.BotEvents[0].Message == nil { + t.Fatal("expected first event to have a message") + } + expectedMsg := "Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)" + if *deal.BotEvents[0].Message != expectedMsg { + t.Errorf("expected first event message: %s, got: %s", expectedMsg, *deal.BotEvents[0].Message) + } + + // Verify bot was auto-created + bot, ok := ts.GetBot(16511317) + if !ok { + t.Fatal("bot 16511317 should have been auto-created") + } + if bot.AccountName != "Demo Account 2080398" { + t.Errorf("expected account name from deal, got %s", bot.AccountName) + } + + // Test that the deal is accessible via HTTP API + resp, err := http.Get(ts.URL() + "/ver1/deals/2376446537/show") + if err != nil { + t.Fatalf("failed to GET deal: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + + var dealResp tcmock.Deal + if err := json.NewDecoder(resp.Body).Decode(&dealResp); err != nil { + t.Fatalf("failed to decode deal: %v", err) + } + + // Verify bot_events are returned via API + if len(dealResp.BotEvents) != 3 { + t.Fatalf("expected 3 bot events via API, got %d", len(dealResp.BotEvents)) + } +} + +func TestLoadVCRCassette_DuplicateError(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load cassette first time - should succeed + err := ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err != nil { + t.Fatalf("first load failed: %v", err) + } + + // Load same cassette again - should fail with duplicate error + err = ts.LoadVCRCassette("../testdata/fixtures/deal_2376446537") + if err == nil { + t.Fatal("expected error for duplicate deal ID, got nil") + } + + if err.Error() != "failed to process interaction 0 from ../testdata/fixtures/deal_2376446537: duplicate deal ID 2376446537 found in VCR cassette" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestLoadVCRCassettes_Multiple(t *testing.T) { + ts := NewTestServer(t) + defer ts.Close() + + // Load multiple cassettes at once + err := ts.LoadVCRCassettes( + "../testdata/fixtures/deal_2376446537", + // Add more cassette paths here as you create them + ) + if err != nil { + t.Fatalf("failed to load cassettes: %v", err) + } + + // Verify deal from first cassette + deal, ok := ts.GetDealByID(2376446537) + if !ok { + t.Fatal("deal from first cassette not found") + } + if len(deal.BotEvents) != 3 { + t.Fatalf("expected 3 bot events, got %d", len(deal.BotEvents)) + } +} + +func TestExtractDealID(t *testing.T) { + tests := []struct { + url string + expected int + wantErr bool + }{ + { + url: "https://api.3commas.io/public/api/ver1/deals/2376446537/show", + expected: 2376446537, + wantErr: false, + }, + { + url: "/ver1/deals/123/show", + expected: 123, + wantErr: false, + }, + { + url: "/ver1/deals", + expected: 0, + wantErr: true, + }, + { + url: "/ver1/bots", + expected: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + id, err := ExtractDealID(tt.url) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for URL %s, got nil", tt.url) + } + } else { + if err != nil { + t.Errorf("unexpected error for URL %s: %v", tt.url, err) + } + if id != tt.expected { + t.Errorf("expected ID %d, got %d", tt.expected, id) + } + } + }) + } +} diff --git a/testdata/fixtures/deal_2376446537.yaml b/testdata/fixtures/deal_2376446537.yaml new file mode 100644 index 0000000..da72f4e --- /dev/null +++ b/testdata/fixtures/deal_2376446537.yaml @@ -0,0 +1,25 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.3commas.io + url: https://api.3commas.io/public/api/ver1/deals/2376446537/show + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: '{"from_currency_id":0,"to_currency_id":0,"id":2376446537,"type":"Deal","bot_id":16511317,"max_safety_orders":9,"deal_has_error":false,"account_id":33256512,"active_safety_orders_count":0,"created_at":"2025-09-26T17:26:26.529Z","updated_at":"2025-09-28T08:27:03.877Z","closed_at":null,"finished?":false,"current_active_safety_orders_count":0,"current_active_safety_orders":0,"completed_safety_orders_count":9,"completed_manual_safety_orders_count":0,"cancellable?":true,"panic_sellable?":true,"trailing_enabled":false,"tsl_enabled":false,"stop_loss_timeout_enabled":false,"stop_loss_timeout_in_seconds":0,"active_manual_safety_orders":0,"pair":"USDT_DOGE","status":"bought","localized_status":"Active","take_profit":"2.0","take_profit_steps":[],"base_order_volume":"25.0","safety_order_volume":"25.0","safety_order_step_percentage":"0.21","safety_order_calculation_mode":"base","leverage_type":"not_specified","leverage_custom_value":null,"bought_amount":"1094.0","bought_volume":"251.20651556","bought_average_price":"0.229622043473491773308957952468007313","base_order_average_price":"0.23171148","sold_amount":"0.0","sold_volume":"0.0","sold_average_price":"0","take_profit_type":"total","final_profit":"-2.82176894","martingale_coefficient":"1.0","martingale_volume_coefficient":"1.0","martingale_step_coefficient":"1.0","stop_loss_percentage":"5.0","sl_to_breakeven_enabled":false,"sl_to_breakeven_data":null,"error_message":null,"profit_currency":"quote_currency","stop_loss_type":"stop_loss","safety_order_volume_type":"quote_currency","base_order_volume_type":"quote_currency","from_currency":"USDT","to_currency":"DOGE","final_profit_percentage":"0","usd_final_profit":"-2.82","actual_profit":"-0.62502788","actual_usd_profit":"-0.62502788","failed_message":null,"reserved_base_coin":"251.20651556","reserved_second_coin":"1094.0","trailing_deviation":"0.2","trailing_max_price":null,"tsl_max_price":null,"strategy":"long","last_known_position_info":null,"min_profit_percentage":"0.0","min_profit_type":null,"close_strategy_list":[],"safety_strategy_list":[{"options":{},"strategy":"tv_custom_signal"}],"custom_safety_steps":[],"note":null,"add_fundable":true,"smart_trade_convertable":true,"bot_name":"Bot16511317 has signal","account_name":"Demo Account 2080398","market_type":"spot","current_price":"0.22928","take_profit_price":"0.23445","stop_loss_price":"0.220125906","actual_profit_percentage":"-0.15","reserved_quote_funds":"0.0","reserved_base_funds":"0.0","orderbook_price_currency":"USDT","bot_events":[{"message":"Placing averaging order (9 out of 9). Price: market Size: 25.0008 USDT (110.0 DOGE)","created_at":"2025-09-28T08:27:03.515Z"},{"message":"Averaging order (9 out of 9) executed. Price: market Size: 25.0269019 USDT (110.0 DOGE) #lastAO 😬","created_at":"2025-09-28T08:27:03.827Z"},{"message":"Cancelling TakeProfit trade. Price: 0.23469 USDT Size: 230.93496 USDT (984.0 DOGE)","created_at":"2025-09-28T08:27:03.949Z"}]}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 1.9757445s From 5260c79566f0a9283ef00698a57bc75b038f9abf Mon Sep 17 00:00:00 2001 From: Yorick Terweijden Date: Sat, 15 Nov 2025 19:31:45 +0100 Subject: [PATCH 3/3] chore: fix go mod --- go.mod | 3 ++- go.sum | 6 ++++-- server/vcr.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 21d57cc..20483c5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( github.com/oapi-codegen/nullable v1.1.0 github.com/oapi-codegen/runtime v1.1.2 + gopkg.in/dnaeon/go-vcr.v4 v4.0.5 ) require ( @@ -15,6 +16,7 @@ require ( github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -31,7 +33,6 @@ require ( golang.org/x/sync v0.9.0 // indirect golang.org/x/text v0.20.0 // indirect golang.org/x/tools v0.25.1 // indirect - gopkg.in/dnaeon/go-vcr.v3 v3.2.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 307f38e..6cfbb9e 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -171,8 +173,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/dnaeon/go-vcr.v3 v3.2.0 h1:Rltp0Vf+Aq0u4rQXgmXgtgoRDStTnFN83cWgSGSoRzM= -gopkg.in/dnaeon/go-vcr.v3 v3.2.0/go.mod h1:2IMOnnlx9I6u9x+YBsM3tAMx6AlOxnJ0pWxQAzZ79Ag= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= diff --git a/server/vcr.go b/server/vcr.go index 16afcde..0275da4 100644 --- a/server/vcr.go +++ b/server/vcr.go @@ -8,7 +8,7 @@ import ( "strconv" "github.com/recomma/3commas-mock/tcmock" - "gopkg.in/dnaeon/go-vcr.v3/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" ) // URL pattern matchers