Files
steampipe/pkg/interactive/interactive_client_test.go
Nathan Wallace bf3092396c executeMetaquery error handling closes #4789 (#4834)
* Add test for #4789: executeMetaquery panic instead of error

Added TestExecuteMetaquery_NotInitialised to demonstrate the bug where
executeMetaquery panics with "client is not initalised" instead of
returning an error when called before initialization completes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix #4789: Return error instead of panic in executeMetaquery

Replace panic("client is not initalised") with proper error return
in executeMetaquery. This prevents unrecoverable crashes when the
method is called before client initialization completes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-16 12:00:19 -05:00

658 lines
16 KiB
Go

package interactive
import (
"context"
"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)
}
})
}
}
// TestExecuteMetaquery_NotInitialised tests that executeMetaquery returns
// an error instead of panicking when the client is not initialized.
//
// Bug: #4789
func TestExecuteMetaquery_NotInitialised(t *testing.T) {
// Create an InteractiveClient that is not initialized
c := &InteractiveClient{}
c.initialisationComplete.Store(false)
ctx := context.Background()
// Attempt to execute a metaquery before initialization
// This should return an error, not panic
err := c.executeMetaquery(ctx, ".inspect")
// We expect an error
if err == nil {
t.Error("Expected error when executing metaquery before initialization, but got nil")
}
// The test passes if we get here without a panic
t.Logf("Successfully received error instead of panic: %v", err)
}