🏡 index : quine-global/quintodrome.git

package persistence

import (
	"context"
	"strings"

	"github.com/Masterminds/squirrel"
	"github.com/deluan/rest"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

var _ = Describe("sqlRestful", func() {
	Describe("parseRestFilters", func() {
		var r sqlRepository
		var options rest.QueryOptions

		BeforeEach(func() {
			r = sqlRepository{}
		})

		It("returns nil if filters is empty", func() {
			options.Filters = nil
			Expect(r.parseRestFilters(context.Background(), options)).To(BeNil())
		})

		It(`returns nil if tries a filter with legacySearchExpr("'")`, func() {
			DeferCleanup(configtest.SetupConfig())
			conf.Server.Search.Backend = "legacy"
			r.filterMappings = map[string]filterFunc{
				"name": fullTextFilter("table"),
			}
			options.Filters = map[string]any{"name": "'"}
			Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
		})

		It("does not add nill filters", func() {
			r.filterMappings = map[string]filterFunc{
				"name": func(string, any) squirrel.Sqlizer {
					return nil
				},
			}
			options.Filters = map[string]any{"name": "joe"}
			Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
		})

		It("returns a '=' condition for 'id' filter", func() {
			options.Filters = map[string]any{"id": "123"}
			Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
		})

		It("returns a 'in' condition for multiples 'id' filters", func() {
			options.Filters = map[string]any{"id": []string{"123", "456"}}
			Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
		})

		It("returns a 'like' condition for other filters", func() {
			options.Filters = map[string]any{"name": "joe"}
			Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
		})

		It("uses the custom filter", func() {
			r.filterMappings = map[string]filterFunc{
				"test": func(field string, value any) squirrel.Sqlizer {
					return squirrel.Gt{field: value}
				},
			}
			options.Filters = map[string]any{"test": 100}
			Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
		})
	})

	Describe("fullTextFilter function", func() {
		var filter filterFunc
		var tableName string
		var mbidFields []string

		BeforeEach(func() {
			DeferCleanup(configtest.SetupConfig())
			conf.Server.Search.Backend = "legacy"
			tableName = "test_table"
			mbidFields = []string{"mbid", "artist_mbid"}
			filter = fullTextFilter(tableName, mbidFields...)
		})

		Context("when value is a valid UUID", func() {
			It("returns only the mbid filter (precedence over full text)", func() {
				uuid := "550e8400-e29b-41d4-a716-446655440000"
				result := filter("search", uuid)

				expected := squirrel.Or{
					squirrel.Eq{"test_table.mbid": uuid},
					squirrel.Eq{"test_table.artist_mbid": uuid},
				}
				Expect(result).To(Equal(expected))
			})

			It("falls back to full text when no mbid fields are provided", func() {
				noMbidFilter := fullTextFilter(tableName)
				uuid := "550e8400-e29b-41d4-a716-446655440000"
				result := noMbidFilter("search", uuid)

				// mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%"))
			})
		})

		Context("when value is not a valid UUID", func() {
			It("returns full text search condition only", func() {
				result := filter("search", "beatles")

				// mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% beatles%"))
			})

			It("handles multi-word search terms", func() {
				result := filter("search", "the beatles abbey road")

				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				// All words should be present as LIKE conditions
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(HaveLen(4))
				Expect(args).To(ContainElement("% the%"))
				Expect(args).To(ContainElement("% beatles%"))
				Expect(args).To(ContainElement("% abbey%"))
				Expect(args).To(ContainElement("% road%"))
			})
		})

		Context("when SearchFullString config changes behavior", func() {
			It("uses different separator with SearchFullString=false", func() {
				conf.Server.Search.FullString = false
				result := filter("search", "test query")

				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(HaveLen(2))
				Expect(args).To(ContainElement("% test%"))
				Expect(args).To(ContainElement("% query%"))
			})

			It("uses no separator with SearchFullString=true", func() {
				conf.Server.Search.FullString = true
				result := filter("search", "test query")

				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(HaveLen(2))
				Expect(args).To(ContainElement("%test%"))
				Expect(args).To(ContainElement("%query%"))
			})
		})

		Context("single-character queries (regression: must not be rejected)", func() {
			It("returns valid filter for single-char query with legacy backend", func() {
				conf.Server.Search.Backend = "legacy"
				result := filter("search", "a")
				Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("LIKE"))
				Expect(args).ToNot(BeEmpty())
			})

			It("returns valid filter for single-char query with FTS backend", func() {
				conf.Server.Search.Backend = "fts"
				conf.Server.Search.FullString = false
				ftsFilter := fullTextFilter(tableName, mbidFields...)
				result := ftsFilter("search", "a")
				Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped")
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("MATCH"))
				Expect(args).ToNot(BeEmpty())
			})
		})

		Context("edge cases", func() {
			It("returns nil for empty string", func() {
				result := filter("search", "")
				Expect(result).To(BeNil())
			})

			It("returns nil for string with only whitespace", func() {
				result := filter("search", "   ")
				Expect(result).To(BeNil())
			})

			It("handles special characters that are sanitized", func() {
				result := filter("search", "don't")

				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% dont%"))
			})

			It("returns nil for single quote (SQL injection protection)", func() {
				result := filter("search", "'")
				Expect(result).To(BeNil())
			})

			It("handles mixed case UUIDs", func() {
				uuid := "550E8400-E29B-41D4-A716-446655440000"
				result := filter("search", uuid)

				// Should return only mbid filter (uppercase UUID should work)
				expected := squirrel.Or{
					squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)},
					squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)},
				}
				Expect(result).To(Equal(expected))
			})

			It("handles invalid UUID format gracefully", func() {
				result := filter("search", "550e8400-invalid-uuid")

				// Should return full text filter since UUID is invalid
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% 550e8400-invalid-uuid%"))
			})

			It("handles empty mbid fields array", func() {
				emptyMbidFilter := fullTextFilter(tableName, []string{}...)
				result := emptyMbidFilter("search", "test")

				// mbidExpr with empty fields returns nil, so search strategy result is returned directly
				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% test%"))
			})

			It("converts value to lowercase before processing", func() {
				result := filter("search", "TEST")

				sql, args, err := result.ToSql()
				Expect(err).ToNot(HaveOccurred())
				Expect(sql).To(ContainSubstring("test_table.full_text LIKE"))
				Expect(args).To(ContainElement("% test%"))
			})
		})
	})

})