f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
521 lines
14 KiB
Go
521 lines
14 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
)
|
|
|
|
// TestStoreHasSemanticFalse pins that the PostgreSQL store reports no
|
|
// semantic search capability until it gets its own VectorSearcher seam.
|
|
func TestStoreHasSemanticFalse(t *testing.T) {
|
|
s := &Store{}
|
|
assert.False(t, s.HasSemantic(), "PostgreSQL HasSemantic")
|
|
}
|
|
|
|
// TestStoreSearchContentSemanticModesUnavailable pins that "semantic" and
|
|
// "hybrid" are rejected with db.ErrSemanticUnavailable before any query runs
|
|
// -- a zero-value Store (no live *sql.DB) is enough to prove that.
|
|
func TestStoreSearchContentSemanticModesUnavailable(t *testing.T) {
|
|
s := &Store{}
|
|
for _, mode := range []string{"semantic", "hybrid"} {
|
|
_, err := s.SearchContent(context.Background(),
|
|
db.ContentSearchFilter{Pattern: "x", Mode: mode})
|
|
require.Error(t, err, "mode %q", mode)
|
|
assert.True(t, errors.Is(err, db.ErrSemanticUnavailable),
|
|
"mode %q: want ErrSemanticUnavailable, got %v", mode, err)
|
|
}
|
|
}
|
|
|
|
// TestStoreSearchContentSemanticInvalidInputReturns400Before501 pins backend
|
|
// parity (AGENTS.md): an invalid semantic/hybrid request -- cursor pagination
|
|
// or a non-messages source -- must return the same *db.SearchInputError
|
|
// SQLite's ValidateSemanticFilter returns, not db.ErrSemanticUnavailable, even
|
|
// though PostgreSQL has no VectorSearcher seam and would otherwise report the
|
|
// capability gate for any request in these modes.
|
|
func TestStoreSearchContentSemanticInvalidInputReturns400Before501(t *testing.T) {
|
|
s := &Store{}
|
|
cases := []struct {
|
|
name string
|
|
f db.ContentSearchFilter
|
|
}{
|
|
{"cursor rejected", db.ContentSearchFilter{Pattern: "x", Cursor: 1}},
|
|
{"non-messages source rejected", db.ContentSearchFilter{
|
|
Pattern: "x", Sources: []string{"tool_input"},
|
|
}},
|
|
}
|
|
for _, mode := range []string{"semantic", "hybrid"} {
|
|
for _, tc := range cases {
|
|
t.Run(mode+"/"+tc.name, func(t *testing.T) {
|
|
f := tc.f
|
|
f.Mode = mode
|
|
_, err := s.SearchContent(context.Background(), f)
|
|
require.Error(t, err)
|
|
var inputErr *db.SearchInputError
|
|
assert.True(t, errors.As(err, &inputErr),
|
|
"expected *db.SearchInputError, got %T: %v", err, err)
|
|
assert.False(t, errors.Is(err, db.ErrSemanticUnavailable),
|
|
"invalid input must not be masked as ErrSemanticUnavailable")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStripFTSQuotes pins the de-quoting behavior the PostgreSQL Search path
|
|
// relies on. The canonical implementation lives in the db package and is
|
|
// shared with the SQLite and HTTP paths so the backends stay in parity.
|
|
func TestStripFTSQuotes(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{`"hello world"`, "hello world"},
|
|
{`hello`, "hello"},
|
|
{`"error" "401"`, "error 401"},
|
|
{`"error-401"`, "error-401"},
|
|
{`""`, ""},
|
|
{`"a"`, "a"},
|
|
{`already unquoted`, "already unquoted"},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, db.StripFTSQuotes(tt.input),
|
|
"input=%q", tt.input)
|
|
}
|
|
}
|
|
|
|
func TestEscapeLike(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"hello", "hello"},
|
|
{"100%", `100\%`},
|
|
{"under_score", `under\_score`},
|
|
{`back\slash`, `back\\slash`},
|
|
{`%_\`, `\%\_\\`},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, escapeLike(tt.input),
|
|
"input=%q", tt.input)
|
|
}
|
|
}
|
|
|
|
func TestPGMessagesBranchFTSRequiresAllTerms(t *testing.T) {
|
|
pb := ¶mBuilder{}
|
|
branch := pgMessagesBranch(
|
|
db.ContentSearchFilter{
|
|
Pattern: "quick fox",
|
|
Mode: "fts",
|
|
},
|
|
escapeLike("quick fox"),
|
|
pb,
|
|
)
|
|
|
|
assert.Contains(t, branch,
|
|
"m.content ILIKE '%'||$1||'%' ESCAPE E'\\\\'")
|
|
assert.Contains(t, branch,
|
|
"m.content ILIKE '%'||$2||'%' ESCAPE E'\\\\'")
|
|
assert.Equal(t, []any{"quick", "fox"}, pb.args)
|
|
}
|
|
|
|
func TestPGSubstringSnippetFTSModeCentersOnFirstTerm(t *testing.T) {
|
|
body := strings.Repeat("prefix ", 30) + "the quick brown fox jumps"
|
|
|
|
got := pgSubstringSnippet(db.ContentSearchFilter{
|
|
Pattern: "quick fox",
|
|
Mode: "fts",
|
|
}, body)
|
|
|
|
assert.Contains(t, got, "quick")
|
|
assert.Contains(t, got, "fox")
|
|
}
|
|
|
|
func TestMapPGWriteErrorNormalizesReadOnlyPgErrors(t *testing.T) {
|
|
for _, code := range []string{"25006", "42501"} {
|
|
t.Run(code, func(t *testing.T) {
|
|
err := mapPGWriteError("writing test row", &pgconn.PgError{
|
|
Code: code,
|
|
Message: "permission denied",
|
|
})
|
|
|
|
require.ErrorIs(t, err, db.ErrReadOnly)
|
|
assert.Contains(t, err.Error(), "writing test row")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMapPGWriteErrorKeepsNonReadOnlyCause(t *testing.T) {
|
|
cause := errors.New("network unavailable")
|
|
|
|
err := mapPGWriteError("writing test row", cause)
|
|
|
|
require.ErrorIs(t, err, cause)
|
|
assert.False(t, errors.Is(err, db.ErrReadOnly))
|
|
assert.Contains(t, err.Error(), "writing test row")
|
|
}
|
|
|
|
func TestEmptyTrashExcludesSameRowsItDeletes(t *testing.T) {
|
|
state := &emptyTrashProbeState{
|
|
sessions: map[string]bool{
|
|
"already-trashed": true,
|
|
"vibe:session_already_trashed": false,
|
|
"concurrently-trashed": false,
|
|
"vibe:session_concurrently_trashed": false,
|
|
},
|
|
aliases: map[string][]string{
|
|
"already-trashed": {"vibe:session_already_trashed"},
|
|
"concurrently-trashed": {"vibe:session_concurrently_trashed"},
|
|
},
|
|
excluded: map[string]bool{},
|
|
deleted: map[string]bool{},
|
|
}
|
|
store := &Store{pg: newEmptyTrashProbeDB(t, state)}
|
|
|
|
count, err := store.EmptyTrash()
|
|
|
|
require.NoError(t, err, "EmptyTrash")
|
|
assert.Equal(t, 1, count)
|
|
assert.True(t, state.deleted["already-trashed"])
|
|
assert.True(t, state.excluded["already-trashed"])
|
|
assert.True(t, state.excluded["vibe:session_already_trashed"])
|
|
assert.True(t, state.deleted["vibe:session_already_trashed"])
|
|
assert.False(t, state.deleted["concurrently-trashed"])
|
|
assert.False(t, state.excluded["concurrently-trashed"])
|
|
assert.False(t, state.excluded["vibe:session_concurrently_trashed"])
|
|
assert.False(t, state.deleted["vibe:session_concurrently_trashed"])
|
|
}
|
|
|
|
func TestDeleteSessionIfTrashedExcludesRecordedAliases(t *testing.T) {
|
|
state := &emptyTrashProbeState{
|
|
sessions: map[string]bool{
|
|
"trashed": true,
|
|
"vibe:session_trashed": false,
|
|
},
|
|
aliases: map[string][]string{
|
|
"trashed": {"vibe:session_trashed"},
|
|
},
|
|
excluded: map[string]bool{},
|
|
deleted: map[string]bool{},
|
|
}
|
|
store := &Store{pg: newEmptyTrashProbeDB(t, state)}
|
|
|
|
count, err := store.DeleteSessionIfTrashed("trashed")
|
|
|
|
require.NoError(t, err, "DeleteSessionIfTrashed")
|
|
assert.EqualValues(t, 1, count)
|
|
assert.True(t, state.deleted["trashed"])
|
|
assert.True(t, state.excluded["trashed"])
|
|
assert.True(t, state.excluded["vibe:session_trashed"])
|
|
assert.True(t, state.deleted["vibe:session_trashed"])
|
|
}
|
|
|
|
func TestDeleteSessionIfTrashedExcludesReverseAliasCanonical(t *testing.T) {
|
|
state := &emptyTrashProbeState{
|
|
sessions: map[string]bool{
|
|
"vibe:canonical": false,
|
|
"vibe:session_trashed": true,
|
|
},
|
|
aliases: map[string][]string{
|
|
"vibe:canonical": {"vibe:session_trashed"},
|
|
},
|
|
excluded: map[string]bool{},
|
|
deleted: map[string]bool{},
|
|
}
|
|
store := &Store{pg: newEmptyTrashProbeDB(t, state)}
|
|
|
|
count, err := store.DeleteSessionIfTrashed("vibe:session_trashed")
|
|
|
|
require.NoError(t, err, "DeleteSessionIfTrashed")
|
|
assert.EqualValues(t, 1, count)
|
|
assert.True(t, state.deleted["vibe:session_trashed"])
|
|
assert.True(t, state.deleted["vibe:canonical"])
|
|
assert.True(t, state.excluded["vibe:session_trashed"])
|
|
assert.True(t, state.excluded["vibe:canonical"])
|
|
}
|
|
|
|
type emptyTrashProbeDriver struct{}
|
|
|
|
type emptyTrashProbeConn struct {
|
|
state *emptyTrashProbeState
|
|
}
|
|
|
|
type emptyTrashProbeTx struct {
|
|
state *emptyTrashProbeState
|
|
}
|
|
|
|
type emptyTrashProbeRows struct {
|
|
columns []string
|
|
values [][]driver.Value
|
|
next int
|
|
}
|
|
|
|
type emptyTrashProbeState struct {
|
|
mu sync.Mutex
|
|
sessions map[string]bool
|
|
aliases map[string][]string
|
|
excluded map[string]bool
|
|
deleted map[string]bool
|
|
}
|
|
|
|
var (
|
|
emptyTrashProbeRegisterOnce sync.Once
|
|
emptyTrashProbeStatesMu sync.Mutex
|
|
emptyTrashProbeStates = map[string]*emptyTrashProbeState{}
|
|
)
|
|
|
|
func newEmptyTrashProbeDB(
|
|
t *testing.T, state *emptyTrashProbeState,
|
|
) *sql.DB {
|
|
t.Helper()
|
|
emptyTrashProbeRegisterOnce.Do(func() {
|
|
sql.Register("agentsview_empty_trash_probe", emptyTrashProbeDriver{})
|
|
})
|
|
name := t.Name()
|
|
emptyTrashProbeStatesMu.Lock()
|
|
emptyTrashProbeStates[name] = state
|
|
emptyTrashProbeStatesMu.Unlock()
|
|
t.Cleanup(func() {
|
|
emptyTrashProbeStatesMu.Lock()
|
|
delete(emptyTrashProbeStates, name)
|
|
emptyTrashProbeStatesMu.Unlock()
|
|
})
|
|
|
|
pg, err := sql.Open("agentsview_empty_trash_probe", name)
|
|
require.NoError(t, err, "open empty-trash probe db")
|
|
t.Cleanup(func() { _ = pg.Close() })
|
|
return pg
|
|
}
|
|
|
|
func (emptyTrashProbeDriver) Open(name string) (driver.Conn, error) {
|
|
emptyTrashProbeStatesMu.Lock()
|
|
state := emptyTrashProbeStates[name]
|
|
emptyTrashProbeStatesMu.Unlock()
|
|
return &emptyTrashProbeConn{state: state}, nil
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) Prepare(string) (driver.Stmt, error) {
|
|
return nil, errors.New("prepare not implemented")
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) Close() error { return nil }
|
|
|
|
func (c *emptyTrashProbeConn) Begin() (driver.Tx, error) {
|
|
return c.BeginTx(context.Background(), driver.TxOptions{})
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) BeginTx(
|
|
context.Context, driver.TxOptions,
|
|
) (driver.Tx, error) {
|
|
return &emptyTrashProbeTx{state: c.state}, nil
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) CheckNamedValue(
|
|
*driver.NamedValue,
|
|
) error {
|
|
return nil
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) ExecContext(
|
|
_ context.Context, query string, args []driver.NamedValue,
|
|
) (driver.Result, error) {
|
|
normalized := strings.ToLower(query)
|
|
c.state.mu.Lock()
|
|
defer c.state.mu.Unlock()
|
|
|
|
switch {
|
|
case strings.Contains(normalized, "set deleted_at = deleted_at"):
|
|
return driver.RowsAffected(c.state.trashedCountLocked()), nil
|
|
case strings.Contains(normalized, "insert into excluded_sessions"):
|
|
c.state.excludeNamedValuesLocked(args)
|
|
return driver.RowsAffected(1), nil
|
|
case strings.Contains(normalized, "delete from sessions") &&
|
|
strings.Contains(normalized, "id = any($1)") &&
|
|
strings.Contains(normalized, "deleted_at is not null"):
|
|
return driver.RowsAffected(c.state.deleteNamedTrashedLocked(args)), nil
|
|
case strings.Contains(normalized, "delete from sessions") &&
|
|
strings.Contains(normalized, "id = any($1)"):
|
|
return driver.RowsAffected(c.state.deleteNamedExistingLocked(args)), nil
|
|
default:
|
|
return nil, errors.New("unexpected empty-trash exec")
|
|
}
|
|
}
|
|
|
|
func (c *emptyTrashProbeConn) QueryContext(
|
|
_ context.Context, query string, args []driver.NamedValue,
|
|
) (driver.Rows, error) {
|
|
normalized := strings.ToLower(query)
|
|
if !strings.Contains(normalized, "left join session_aliases") ||
|
|
!strings.Contains(normalized, "for update of s") {
|
|
return nil, errors.New("unexpected empty-trash query")
|
|
}
|
|
|
|
c.state.mu.Lock()
|
|
defer c.state.mu.Unlock()
|
|
filterID := ""
|
|
if strings.Contains(normalized, "s.id = $1") && len(args) > 0 {
|
|
filterID, _ = args[0].Value.(string)
|
|
}
|
|
values := c.state.trashedSessionAliasRowsLocked(filterID)
|
|
if _, ok := c.state.sessions["concurrently-trashed"]; ok {
|
|
c.state.sessions["concurrently-trashed"] = true
|
|
}
|
|
return &emptyTrashProbeRows{
|
|
columns: []string{
|
|
"id", "alias_id", "session_id", "alias_id",
|
|
},
|
|
values: values,
|
|
}, nil
|
|
}
|
|
|
|
func (t *emptyTrashProbeTx) Commit() error { return nil }
|
|
|
|
func (t *emptyTrashProbeTx) Rollback() error { return nil }
|
|
|
|
func (s *emptyTrashProbeState) trashedCountLocked() int64 {
|
|
var count int64
|
|
for _, trashed := range s.sessions {
|
|
if trashed {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *emptyTrashProbeState) trashedSessionAliasRowsLocked(
|
|
filterID string,
|
|
) [][]driver.Value {
|
|
values := [][]driver.Value{}
|
|
for id, trashed := range s.sessions {
|
|
if filterID != "" && id != filterID {
|
|
continue
|
|
}
|
|
if !trashed {
|
|
continue
|
|
}
|
|
directAliases := s.aliases[id]
|
|
if len(directAliases) == 0 {
|
|
directAliases = []string{""}
|
|
}
|
|
reverseAliases := s.reverseAliasRowsLocked(id)
|
|
if len(reverseAliases) == 0 {
|
|
for _, aliasID := range directAliases {
|
|
values = append(values, []driver.Value{
|
|
id, nullableDriverString(aliasID), nil, nil,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
for _, aliasID := range directAliases {
|
|
for _, reverseAlias := range reverseAliases {
|
|
values = append(values, []driver.Value{
|
|
id,
|
|
nullableDriverString(aliasID),
|
|
reverseAlias[0],
|
|
nullableDriverString(reverseAlias[1]),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func (s *emptyTrashProbeState) reverseAliasRowsLocked(
|
|
aliasID string,
|
|
) [][2]string {
|
|
rows := [][2]string{}
|
|
for sessionID, aliases := range s.aliases {
|
|
hasAlias := slices.Contains(aliases, aliasID)
|
|
if !hasAlias {
|
|
continue
|
|
}
|
|
for _, alias := range aliases {
|
|
rows = append(rows, [2]string{sessionID, alias})
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func nullableDriverString(value string) driver.Value {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (s *emptyTrashProbeState) excludeNamedValuesLocked(
|
|
args []driver.NamedValue,
|
|
) {
|
|
for _, id := range namedValueStrings(args) {
|
|
s.excluded[id] = true
|
|
}
|
|
}
|
|
|
|
func (s *emptyTrashProbeState) deleteNamedTrashedLocked(
|
|
args []driver.NamedValue,
|
|
) int64 {
|
|
var count int64
|
|
for _, id := range namedValueStrings(args) {
|
|
if !s.sessions[id] {
|
|
continue
|
|
}
|
|
s.deleted[id] = true
|
|
delete(s.sessions, id)
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *emptyTrashProbeState) deleteNamedExistingLocked(
|
|
args []driver.NamedValue,
|
|
) int64 {
|
|
var count int64
|
|
for _, id := range namedValueStrings(args) {
|
|
if _, ok := s.sessions[id]; !ok {
|
|
continue
|
|
}
|
|
s.deleted[id] = true
|
|
delete(s.sessions, id)
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
func namedValueStrings(args []driver.NamedValue) []string {
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
switch value := args[0].Value.(type) {
|
|
case []string:
|
|
return value
|
|
case string:
|
|
return []string{value}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (r *emptyTrashProbeRows) Columns() []string { return r.columns }
|
|
|
|
func (r *emptyTrashProbeRows) Close() error { return nil }
|
|
|
|
func (r *emptyTrashProbeRows) Next(dest []driver.Value) error {
|
|
if r.next >= len(r.values) {
|
|
return io.EOF
|
|
}
|
|
copy(dest, r.values[r.next])
|
|
r.next++
|
|
return nil
|
|
}
|