mirror of
https://github.com/turbot/steampipe.git
synced 2026-02-27 11:01:05 -05:00
* 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>
658 lines
16 KiB
Go
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)
|
|
}
|