mirror of
https://github.com/turbot/steampipe.git
synced 2025-12-19 18:12:43 -05:00
* Add test for #4810: getQueryInfo() fails to detect 'from ' correctly This test demonstrates that getQueryInfo("from ") incorrectly returns EditingTable = false when it should return true. This prevents autocomplete from suggesting tables after users type "from ". The test currently fails as expected, proving the bug exists. Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix #4810: Correct getQueryInfo() 'from ' detection for autocomplete This commit fixes a bug where getQueryInfo("from ") incorrectly returned EditingTable = false, preventing autocomplete from suggesting tables after users type "from ". The fix involves two changes: 1. Modified getPreviousWord() to correctly return "from" when the input is "from " (single word followed by space). Previously, it returned an empty string because it couldn't find a space before "from". 2. Modified isEditingTable() to check that the text ends with a space. This ensures we only enable table suggestions when the user has typed "from " (ready for a table name), not when they're in the middle of typing "from" or after they've already started typing a table name like "from my_table". The combination of these changes ensures: - "from " → EditingTable = true (autocomplete shows tables) - "from my_table" → EditingTable = false (autocomplete doesn't interfere) - "from" → EditingTable = false (no space yet, not ready for table name) All existing tests pass, and the new test from the previous commit now passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
633 lines
15 KiB
Go
633 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|