package interactive import ( "strings" "sync" "testing" "github.com/c-bata/go-prompt" pconstants "github.com/turbot/pipe-fittings/v2/constants" "github.com/turbot/steampipe/v2/pkg/cmdconfig" ) // TestGetTableAndConnectionSuggestions_ReturnsEmptySliceNotNil tests that // getTableAndConnectionSuggestions returns an empty slice instead of nil // when no matching connection is found in the schema. // // This is important for proper API contract - functions that return slices // should return empty slices rather than nil to avoid unexpected nil pointer // issues in calling code. // // Bug: #4710 // PR: #4734 func TestGetTableAndConnectionSuggestions_ReturnsEmptySliceNotNil(t *testing.T) { tests := []struct { name string word string expected bool // true if we expect non-nil result }{ { name: "empty word should return non-nil", word: "", expected: true, }, { name: "unqualified table should return non-nil", word: "table", expected: true, }, { name: "non-existent connection should return non-nil", word: "nonexistent.table", expected: true, }, { name: "qualified table with dot should return non-nil", word: "aws.instances", expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a minimal InteractiveClient with empty suggestions c := &InteractiveClient{ suggestions: &autoCompleteSuggestions{ schemas: []prompt.Suggest{}, unqualifiedTables: []prompt.Suggest{}, tablesBySchema: make(map[string][]prompt.Suggest), }, } result := c.getTableAndConnectionSuggestions(tt.word) if tt.expected && result == nil { t.Errorf("getTableAndConnectionSuggestions(%q) returned nil, expected non-nil empty slice", tt.word) } // Additional check: even if not nil, should be empty in these test cases if result != nil && len(result) != 0 { t.Errorf("getTableAndConnectionSuggestions(%q) returned non-empty slice %v, expected empty slice", tt.word, result) } }) } } // TestShouldExecute tests the shouldExecute logic for query execution func TestShouldExecute(t *testing.T) { // Save and restore viper settings originalMultiline := cmdconfig.Viper().GetBool(pconstants.ArgMultiLine) defer func() { cmdconfig.Viper().Set(pconstants.ArgMultiLine, originalMultiline) }() tests := []struct { name string query string multiline bool shouldExec bool description string }{ { name: "simple query without semicolon in non-multiline", query: "SELECT * FROM users", multiline: false, shouldExec: true, description: "In non-multiline mode, execute without semicolon", }, { name: "simple query with semicolon in non-multiline", query: "SELECT * FROM users;", multiline: false, shouldExec: true, description: "In non-multiline mode, execute with semicolon", }, { name: "simple query without semicolon in multiline", query: "SELECT * FROM users", multiline: true, shouldExec: false, description: "In multiline mode, don't execute without semicolon", }, { name: "simple query with semicolon in multiline", query: "SELECT * FROM users;", multiline: true, shouldExec: true, description: "In multiline mode, execute with semicolon", }, { name: "metaquery without semicolon in multiline", query: ".help", multiline: true, shouldExec: true, description: "Metaqueries execute without semicolon even in multiline", }, { name: "metaquery with semicolon in multiline", query: ".help;", multiline: true, shouldExec: true, description: "Metaqueries execute with semicolon in multiline", }, { name: "empty query", query: "", multiline: false, shouldExec: true, description: "Empty query executes in non-multiline", }, { name: "empty query in multiline", query: "", multiline: true, shouldExec: false, description: "Empty query doesn't execute in multiline", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &InteractiveClient{} cmdconfig.Viper().Set(pconstants.ArgMultiLine, tt.multiline) result := c.shouldExecute(tt.query) if result != tt.shouldExec { t.Errorf("shouldExecute(%q) in multiline=%v = %v, want %v\nReason: %s", tt.query, tt.multiline, result, tt.shouldExec, tt.description) } }) } } // TestShouldExecuteEdgeCases tests edge cases for shouldExecute func TestShouldExecuteEdgeCases(t *testing.T) { originalMultiline := cmdconfig.Viper().GetBool(pconstants.ArgMultiLine) defer func() { cmdconfig.Viper().Set(pconstants.ArgMultiLine, originalMultiline) }() c := &InteractiveClient{} cmdconfig.Viper().Set(pconstants.ArgMultiLine, true) tests := []struct { name string query string }{ { name: "very long query with semicolon", query: strings.Repeat("SELECT * FROM users WHERE id = 1 AND ", 100) + "1=1;", }, { name: "unicode characters with semicolon", query: "SELECT '你好世界';", }, { name: "emoji with semicolon", query: "SELECT '🔥💥';", }, { name: "null bytes", query: "SELECT '\x00';", }, { name: "control characters", query: "SELECT '\n\r\t';", }, { name: "SQL injection with semicolon", query: "'; DROP TABLE users; --", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("shouldExecute(%q) panicked: %v", tt.query, r) } }() _ = c.shouldExecute(tt.query) }) } } // TestBreakMultilinePrompt tests the breakMultilinePrompt function func TestBreakMultilinePrompt(t *testing.T) { c := &InteractiveClient{ interactiveBuffer: []string{"SELECT *", "FROM users", "WHERE"}, } c.breakMultilinePrompt(nil) if len(c.interactiveBuffer) != 0 { t.Errorf("breakMultilinePrompt() didn't clear buffer, got %d items, want 0", len(c.interactiveBuffer)) } } // TestBreakMultilinePromptEmpty tests breaking an already empty buffer func TestBreakMultilinePromptEmpty(t *testing.T) { c := &InteractiveClient{ interactiveBuffer: []string{}, } defer func() { if r := recover(); r != nil { t.Errorf("breakMultilinePrompt() panicked on empty buffer: %v", r) } }() c.breakMultilinePrompt(nil) if len(c.interactiveBuffer) != 0 { t.Errorf("breakMultilinePrompt() didn't maintain empty buffer, got %d items, want 0", len(c.interactiveBuffer)) } } // TestBreakMultilinePromptNil tests breaking with nil buffer func TestBreakMultilinePromptNil(t *testing.T) { c := &InteractiveClient{ interactiveBuffer: nil, } defer func() { if r := recover(); r != nil { t.Errorf("breakMultilinePrompt() panicked on nil buffer: %v", r) } }() c.breakMultilinePrompt(nil) if c.interactiveBuffer == nil { t.Error("breakMultilinePrompt() didn't initialize nil buffer") } if len(c.interactiveBuffer) != 0 { t.Errorf("breakMultilinePrompt() didn't create empty buffer, got %d items, want 0", len(c.interactiveBuffer)) } } // TestIsInitialised tests the isInitialised method func TestIsInitialised(t *testing.T) { tests := []struct { name string initialisationComplete bool expected bool }{ { name: "initialized", initialisationComplete: true, expected: true, }, { name: "not initialized", initialisationComplete: false, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &InteractiveClient{} c.initialisationComplete.Store(tt.initialisationComplete) result := c.isInitialised() if result != tt.expected { t.Errorf("isInitialised() = %v, want %v", result, tt.expected) } }) } } // TestClientNil tests the client() method when initData is nil func TestClientNil(t *testing.T) { c := &InteractiveClient{ initData: nil, } client := c.client() if client != nil { t.Errorf("client() with nil initData should return nil, got %v", client) } } // TestAfterPromptCloseAction tests the AfterPromptCloseAction enum func TestAfterPromptCloseAction(t *testing.T) { // Test that the enum values are distinct if AfterPromptCloseExit == AfterPromptCloseRestart { t.Error("AfterPromptCloseExit and AfterPromptCloseRestart should have different values") } // Test that they have the expected values if AfterPromptCloseExit != 0 { t.Errorf("AfterPromptCloseExit should be 0, got %d", AfterPromptCloseExit) } if AfterPromptCloseRestart != 1 { t.Errorf("AfterPromptCloseRestart should be 1, got %d", AfterPromptCloseRestart) } } // TestGetFirstWordSuggestionsEmptyWord tests getFirstWordSuggestions with empty input func TestGetFirstWordSuggestionsEmptyWord(t *testing.T) { c := &InteractiveClient{ suggestions: newAutocompleteSuggestions(), } defer func() { if r := recover(); r != nil { t.Errorf("getFirstWordSuggestions panicked on empty input: %v", r) } }() suggestions := c.getFirstWordSuggestions("") // Should return suggestions (select, with, metaqueries) if len(suggestions) == 0 { t.Error("getFirstWordSuggestions(\"\") should return suggestions") } } // TestGetFirstWordSuggestionsQualifiedQuery tests qualified query suggestions func TestGetFirstWordSuggestionsQualifiedQuery(t *testing.T) { c := &InteractiveClient{ suggestions: newAutocompleteSuggestions(), } // Add mock data c.suggestions.queriesByMod = map[string][]prompt.Suggest{ "mymod": { {Text: "mymod.query1", Description: "Query"}, }, } tests := []struct { name string input string }{ { name: "qualified with known mod", input: "mymod.", }, { name: "qualified with unknown mod", input: "unknownmod.", }, { name: "single word", input: "select", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("getFirstWordSuggestions(%q) panicked: %v", tt.input, r) } }() suggestions := c.getFirstWordSuggestions(tt.input) if suggestions == nil { t.Errorf("getFirstWordSuggestions(%q) returned nil", tt.input) } }) } } // TestGetTableAndConnectionSuggestionsEdgeCases tests edge cases func TestGetTableAndConnectionSuggestionsEdgeCases(t *testing.T) { c := &InteractiveClient{ suggestions: newAutocompleteSuggestions(), } // Add mock data c.suggestions.schemas = []prompt.Suggest{ {Text: "public", Description: "Schema"}, } c.suggestions.unqualifiedTables = []prompt.Suggest{ {Text: "users", Description: "Table"}, } c.suggestions.tablesBySchema = map[string][]prompt.Suggest{ "public": { {Text: "public.users", Description: "Table"}, }, } tests := []struct { name string input string }{ { name: "unqualified", input: "users", }, { name: "qualified with known schema", input: "public.users", }, { name: "empty string", input: "", }, { name: "just dot", input: ".", }, { name: "unicode", input: "用户.表", }, { name: "emoji", input: "schema🔥.table", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("getTableAndConnectionSuggestions(%q) panicked: %v", tt.input, r) } }() suggestions := c.getTableAndConnectionSuggestions(tt.input) if suggestions == nil { t.Errorf("getTableAndConnectionSuggestions(%q) returned nil", tt.input) } }) } } // TestCancelActiveQueryIfAny tests the cancellation logic func TestCancelActiveQueryIfAny(t *testing.T) { t.Run("no active query", func(t *testing.T) { c := &InteractiveClient{ cancelActiveQuery: nil, } defer func() { if r := recover(); r != nil { t.Errorf("cancelActiveQueryIfAny() panicked with nil cancelFunc: %v", r) } }() c.cancelActiveQueryIfAny() if c.cancelActiveQuery != nil { t.Error("cancelActiveQueryIfAny() set cancelActiveQuery when it was nil") } }) t.Run("with active query", func(t *testing.T) { cancelled := false cancelFunc := func() { cancelled = true } c := &InteractiveClient{ cancelActiveQuery: cancelFunc, } c.cancelActiveQueryIfAny() if !cancelled { t.Error("cancelActiveQueryIfAny() didn't call the cancel function") } if c.cancelActiveQuery != nil { t.Error("cancelActiveQueryIfAny() didn't set cancelActiveQuery to nil") } }) t.Run("multiple calls", func(t *testing.T) { callCount := 0 cancelFunc := func() { callCount++ } c := &InteractiveClient{ cancelActiveQuery: cancelFunc, } // First call should cancel c.cancelActiveQueryIfAny() if callCount != 1 { t.Errorf("First cancelActiveQueryIfAny() call count = %d, want 1", callCount) } // Second call should be a no-op c.cancelActiveQueryIfAny() if callCount != 1 { t.Errorf("Second cancelActiveQueryIfAny() call count = %d, want 1 (should be idempotent)", callCount) } }) } // TestInitialisationComplete_RaceCondition tests that concurrent access to // the initialisationComplete flag does not cause data races. // // This test simulates the real-world scenario where: // - One goroutine (init goroutine) writes to initialisationComplete // - Other goroutines (query executor, notification handler) read from it // // Bug: #4803 func TestInitialisationComplete_RaceCondition(t *testing.T) { c := &InteractiveClient{} c.initialisationComplete.Store(false) var wg sync.WaitGroup // Simulate initialization goroutine writing to the flag wg.Add(1) go func() { defer wg.Done() for i := 0; i < 100; i++ { c.initialisationComplete.Store(true) c.initialisationComplete.Store(false) } }() // Simulate query executor reading the flag wg.Add(1) go func() { defer wg.Done() for i := 0; i < 100; i++ { _ = c.isInitialised() } }() // Simulate notification handler reading the flag wg.Add(1) go func() { defer wg.Done() for i := 0; i < 100; i++ { // Check the flag directly (as handleConnectionUpdateNotification does) if !c.initialisationComplete.Load() { continue } } }() wg.Wait() } // TestGetQueryInfo_FromDetection tests that getQueryInfo correctly detects // when the user is editing a table name after typing "from ". // // This is important for autocomplete - when a user types "from " (with a space), // the system should recognize they are about to enter a table name and enable // table suggestions. // // Bug: #4810 func TestGetQueryInfo_FromDetection(t *testing.T) { tests := []struct { name string input string expectedTable string expectedEditTable bool }{ { name: "just_from", input: "from ", expectedTable: "", expectedEditTable: true, // Should be true - user is about to enter table name }, { name: "from_with_table", input: "from my_table", expectedTable: "my_table", expectedEditTable: false, // Not editing, already entered }, { name: "from_keyword_only", input: "from", expectedTable: "", expectedEditTable: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := getQueryInfo(tt.input) if result.Table != tt.expectedTable { t.Errorf("getQueryInfo(%q).Table = %q, expected %q", tt.input, result.Table, tt.expectedTable) } if result.EditingTable != tt.expectedEditTable { t.Errorf("getQueryInfo(%q).EditingTable = %v, expected %v", tt.input, result.EditingTable, tt.expectedEditTable) } }) } }