Files
steampipe/pkg/interactive/interactive_client_test.go
Nathan Wallace 152420d278 Fix getQueryInfo() 'from ' detection closes #4810 (rebased) (#4884)
* 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>
2025-11-16 08:50:25 -05:00

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)
}
})
}
}