import { describe, it, expect } from "vitest"; import { IdentifierError } from "../../../src/database/validate.js"; import { isRangeFilter, isInFilter, isStartsWithFilter, getIndexedFields, validateWhereClause, validateOrderByClause, jsonExtract, buildCondition, buildWhereClause, buildOrderByClause, StorageQueryError, } from "../../../src/plugins/storage-query.js"; import { createTestDatabase } from "../../utils/test-db.js"; describe("storage-query", () => { const db = createTestDatabase(); describe("type guards", () => { describe("isRangeFilter", () => { it("should return true for range filters with gt", () => { expect(isRangeFilter({ gt: 10 })).toBe(true); }); it("should return true for range filters with gte", () => { expect(isRangeFilter({ gte: 10 })).toBe(true); }); it("should return true for range filters with lt", () => { expect(isRangeFilter({ lt: 10 })).toBe(true); }); it("should return true for range filters with lte", () => { expect(isRangeFilter({ lte: 10 })).toBe(true); }); it("should return true for combined range filters", () => { expect(isRangeFilter({ gt: 5, lt: 10 })).toBe(true); expect(isRangeFilter({ gte: 5, lte: 10 })).toBe(true); }); it("should return false for plain values", () => { expect(isRangeFilter("foo")).toBe(false); expect(isRangeFilter(42)).toBe(false); expect(isRangeFilter(null)).toBe(false); }); it("should return false for other filter types", () => { expect(isRangeFilter({ in: [1, 2, 3] })).toBe(false); expect(isRangeFilter({ startsWith: "foo" })).toBe(false); }); }); describe("isInFilter", () => { it("should return true for in filters", () => { expect(isInFilter({ in: [1, 2, 3] })).toBe(true); expect(isInFilter({ in: ["a", "b", "c"] })).toBe(true); expect(isInFilter({ in: [] })).toBe(true); }); it("should return false for non-array in values", () => { expect(isInFilter({ in: "foo" } as any)).toBe(false); }); it("should return false for other filter types", () => { expect(isInFilter({ gt: 10 })).toBe(false); expect(isInFilter({ startsWith: "foo" })).toBe(false); expect(isInFilter("foo")).toBe(false); }); }); describe("isStartsWithFilter", () => { it("should return true for startsWith filters", () => { expect(isStartsWithFilter({ startsWith: "foo" })).toBe(true); expect(isStartsWithFilter({ startsWith: "" })).toBe(true); }); it("should return false for non-string startsWith values", () => { expect(isStartsWithFilter({ startsWith: 123 } as any)).toBe(false); }); it("should return false for other filter types", () => { expect(isStartsWithFilter({ gt: 10 })).toBe(false); expect(isStartsWithFilter({ in: ["a", "b"] })).toBe(false); expect(isStartsWithFilter("foo")).toBe(false); }); }); }); describe("getIndexedFields", () => { it("should extract fields from simple indexes", () => { const indexes = ["status", "category"]; const fields = getIndexedFields(indexes); expect(fields).toEqual(new Set(["status", "category"])); }); it("should extract fields from composite indexes", () => { const indexes = [["status", "createdAt"], "category"]; const fields = getIndexedFields(indexes); expect(fields).toEqual(new Set(["status", "createdAt", "category"])); }); it("should handle empty indexes", () => { const fields = getIndexedFields([]); expect(fields).toEqual(new Set()); }); it("should deduplicate fields", () => { const indexes = ["status", ["status", "createdAt"]]; const fields = getIndexedFields(indexes); expect(fields).toEqual(new Set(["status", "createdAt"])); }); }); describe("validateWhereClause", () => { const indexedFields = new Set(["status", "category", "createdAt"]); const pluginId = "test-plugin"; const collection = "items"; it("should pass for indexed fields", () => { expect(() => validateWhereClause( { status: "active", category: "blog" }, indexedFields, pluginId, collection, ), ).not.toThrow(); }); it("should throw for non-indexed fields", () => { expect(() => validateWhereClause({ title: "foo" }, indexedFields, pluginId, collection), ).toThrow(StorageQueryError); }); it("should include helpful suggestion in error", () => { try { validateWhereClause({ title: "foo" }, indexedFields, pluginId, collection); expect.fail("Should have thrown"); } catch (e) { expect(e).toBeInstanceOf(StorageQueryError); const error = e as StorageQueryError; expect(error.field).toBe("title"); expect(error.suggestion).toContain("title"); expect(error.suggestion).toContain(pluginId); } }); it("should pass for empty where clause", () => { expect(() => validateWhereClause({}, indexedFields, pluginId, collection)).not.toThrow(); }); }); describe("validateOrderByClause", () => { const indexedFields = new Set(["status", "createdAt"]); const pluginId = "test-plugin"; const collection = "items"; it("should pass for indexed fields", () => { expect(() => validateOrderByClause({ createdAt: "desc" }, indexedFields, pluginId, collection), ).not.toThrow(); }); it("should throw for non-indexed fields", () => { expect(() => validateOrderByClause({ title: "asc" }, indexedFields, pluginId, collection), ).toThrow(StorageQueryError); }); }); describe("jsonExtract", () => { it("should generate correct SQLite JSON extraction syntax", () => { expect(jsonExtract(db, "status")).toBe("json_extract(data, '$.status')"); expect(jsonExtract(db, "created_at")).toBe("json_extract(data, '$.created_at')"); }); it("should accept camelCase field names (used in plugin JSON data)", () => { expect(jsonExtract(db, "createdAt")).toBe("json_extract(data, '$.createdAt')"); expect(jsonExtract(db, "myField")).toBe("json_extract(data, '$.myField')"); expect(jsonExtract(db, "UPPERCASE")).toBe("json_extract(data, '$.UPPERCASE')"); }); it("should reject invalid field names to prevent SQL injection", () => { expect(() => jsonExtract(db, "'); DROP TABLE users--")).toThrow(IdentifierError); expect(() => jsonExtract(db, "field.with.dots")).toThrow(IdentifierError); expect(() => jsonExtract(db, "field-with-hyphens")).toThrow(IdentifierError); expect(() => jsonExtract(db, "")).toThrow(IdentifierError); expect(() => jsonExtract(db, "1startsWithNumber")).toThrow(IdentifierError); }); }); describe("buildCondition", () => { it("should handle null values", () => { const result = buildCondition(db, "status", null); expect(result.sql).toBe("json_extract(data, '$.status') IS NULL"); expect(result.params).toEqual([]); }); it("should handle string values", () => { const result = buildCondition(db, "status", "active"); expect(result.sql).toBe("json_extract(data, '$.status') = ?"); expect(result.params).toEqual(["active"]); }); it("should handle number values", () => { const result = buildCondition(db, "count", 42); expect(result.sql).toBe("json_extract(data, '$.count') = ?"); expect(result.params).toEqual([42]); }); it("should handle boolean values", () => { const result = buildCondition(db, "active", true); expect(result.sql).toBe("json_extract(data, '$.active') = ?"); expect(result.params).toEqual([true]); }); it("should handle IN filters", () => { const result = buildCondition(db, "status", { in: ["a", "b", "c"] }); expect(result.sql).toBe("json_extract(data, '$.status') IN (?, ?, ?)"); expect(result.params).toEqual(["a", "b", "c"]); }); it("should handle startsWith filters", () => { const result = buildCondition(db, "name", { startsWith: "foo" }); expect(result.sql).toBe("json_extract(data, '$.name') LIKE ?"); expect(result.params).toEqual(["foo%"]); }); it("should handle range filters with gt", () => { const result = buildCondition(db, "age", { gt: 18 }); expect(result.sql).toBe("json_extract(data, '$.age') > ?"); expect(result.params).toEqual([18]); }); it("should handle range filters with gte", () => { const result = buildCondition(db, "age", { gte: 18 }); expect(result.sql).toBe("json_extract(data, '$.age') >= ?"); expect(result.params).toEqual([18]); }); it("should handle range filters with lt", () => { const result = buildCondition(db, "age", { lt: 65 }); expect(result.sql).toBe("json_extract(data, '$.age') < ?"); expect(result.params).toEqual([65]); }); it("should handle range filters with lte", () => { const result = buildCondition(db, "age", { lte: 65 }); expect(result.sql).toBe("json_extract(data, '$.age') <= ?"); expect(result.params).toEqual([65]); }); it("should handle combined range filters", () => { const result = buildCondition(db, "age", { gte: 18, lt: 65 }); expect(result.sql).toBe( "json_extract(data, '$.age') >= ? AND json_extract(data, '$.age') < ?", ); expect(result.params).toEqual([18, 65]); }); }); describe("buildWhereClause", () => { it("should return empty result for empty where", () => { const result = buildWhereClause(db, {}); expect(result.sql).toBe(""); expect(result.params).toEqual([]); }); it("should handle single condition", () => { const result = buildWhereClause(db, { status: "active" }); expect(result.sql).toBe("json_extract(data, '$.status') = ?"); expect(result.params).toEqual(["active"]); }); it("should combine multiple conditions with AND", () => { const result = buildWhereClause(db, { status: "active", category: "blog", }); expect(result.sql).toBe( "json_extract(data, '$.status') = ? AND json_extract(data, '$.category') = ?", ); expect(result.params).toEqual(["active", "blog"]); }); it("should handle mixed filter types", () => { const result = buildWhereClause(db, { status: { in: ["active", "pending"] }, name: { startsWith: "test" }, count: { gte: 5 }, }); expect(result.sql).toContain("IN (?, ?)"); expect(result.sql).toContain("LIKE ?"); expect(result.sql).toContain(">= ?"); expect(result.params).toEqual(["active", "pending", "test%", 5]); }); }); describe("buildOrderByClause", () => { it("should return empty string for empty orderBy", () => { const result = buildOrderByClause(db, {}); expect(result).toBe(""); }); it("should handle single field ascending", () => { const result = buildOrderByClause(db, { createdAt: "asc" }); expect(result).toBe("ORDER BY json_extract(data, '$.createdAt') ASC"); }); it("should handle single field descending", () => { const result = buildOrderByClause(db, { createdAt: "desc" }); expect(result).toBe("ORDER BY json_extract(data, '$.createdAt') DESC"); }); it("should handle multiple fields", () => { const result = buildOrderByClause(db, { category: "asc", createdAt: "desc", }); expect(result).toBe( "ORDER BY json_extract(data, '$.category') ASC, json_extract(data, '$.createdAt') DESC", ); }); }); });