package handlers import ( "context" "database/sql" "strings" "testing" _ "modernc.org/sqlite" "armature/store" ) // ── mock ExtDataStore ──────────────────────────────────────────────────────── // memExtDataStore is an in-memory ExtDataStore for testing. type memExtDataStore struct { tables map[string][]string // packageID → []logicalName } func newMemExtDataStore() *memExtDataStore { return &memExtDataStore{tables: map[string][]string{}} } func (m *memExtDataStore) RegisterTable(_ context.Context, packageID, tableName string) error { m.tables[packageID] = append(m.tables[packageID], tableName) return nil } func (m *memExtDataStore) ListTables(_ context.Context, packageID string) ([]string, error) { return m.tables[packageID], nil } func (m *memExtDataStore) DeletePackageTables(_ context.Context, packageID string) error { delete(m.tables, packageID) return nil } // ── helpers ────────────────────────────────────────────────────────────────── func newSchemaTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { db.Close() }) return db } func storesWithExtData(m *memExtDataStore) store.Stores { return store.Stores{ExtData: m} } // ── ParseDBTables ──────────────────────────────────────────────────────────── func TestParseDBTables_Valid(t *testing.T) { manifest := map[string]any{ "db_tables": map[string]any{ "logs": map[string]any{ "columns": map[string]any{ "message": "text", "count": "int", }, "indexes": []any{[]any{"message"}}, }, }, } tables, ok := ParseDBTables(manifest) if !ok { t.Fatal("expected ok=true") } if _, has := tables["logs"]; !has { t.Fatal("expected 'logs' in parsed tables") } if tables["logs"].Columns["message"] != "text" { t.Errorf("expected column message=text, got %q", tables["logs"].Columns["message"]) } if len(tables["logs"].Indexes) != 1 { t.Errorf("expected 1 index, got %d", len(tables["logs"].Indexes)) } } func TestParseDBTables_Missing(t *testing.T) { _, ok := ParseDBTables(map[string]any{"title": "hi"}) if ok { t.Fatal("expected ok=false when db_tables absent") } } func TestParseDBTables_InvalidTableName(t *testing.T) { manifest := map[string]any{ "db_tables": map[string]any{ "bad name": map[string]any{}, "good_name": map[string]any{"columns": map[string]any{"x": "text"}}, }, } tables, ok := ParseDBTables(manifest) if !ok { t.Fatal("expected ok=true (good_name is valid)") } if _, has := tables["bad name"]; has { t.Error("invalid table name should be skipped") } if _, has := tables["good_name"]; !has { t.Error("valid table name should be present") } } func TestParseDBTables_InvalidColumnName(t *testing.T) { manifest := map[string]any{ "db_tables": map[string]any{ "events": map[string]any{ "columns": map[string]any{ "bad col": "text", "ok_col": "text", }, }, }, } tables, ok := ParseDBTables(manifest) if !ok { t.Fatal("expected ok=true") } if _, has := tables["events"].Columns["bad col"]; has { t.Error("invalid column name should be skipped") } if _, has := tables["events"].Columns["ok_col"]; !has { t.Error("valid column name should be present") } } // ── mapColType ─────────────────────────────────────────────────────────────── func TestMapColType_SQLite(t *testing.T) { cases := []struct{ in, want string }{ {"text", "TEXT"}, {"int", "INTEGER"}, {"integer", "INTEGER"}, {"real", "REAL"}, {"float", "REAL"}, {"bool", "INTEGER"}, {"boolean", "INTEGER"}, {"timestamp", "TEXT"}, {"unknown", "TEXT"}, } for _, c := range cases { got := mapColType(c.in, false, false) if got != c.want { t.Errorf("mapColType(%q, sqlite) = %q, want %q", c.in, got, c.want) } } } func TestMapColType_Postgres(t *testing.T) { if mapColType("bool", true, false) != "BOOLEAN" { t.Error("expected BOOLEAN for bool in postgres") } if mapColType("timestamp", true, false) != "TIMESTAMPTZ" { t.Error("expected TIMESTAMPTZ for timestamp in postgres") } if mapColType("int", true, false) != "INTEGER" { t.Error("expected INTEGER for int in postgres") } } // ── extPhysicalTable ───────────────────────────────────────────────────────── func TestExtPhysicalTable(t *testing.T) { got := extPhysicalTable("my-ext", "logs") if got != "ext_my_ext_logs" { t.Errorf("got %q, want ext_my_ext_logs", got) } got = extPhysicalTable("simple", "events") if got != "ext_simple_events" { t.Errorf("got %q, want ext_simple_events", got) } } // ── CreateExtTables ────────────────────────────────────────────────────────── func TestCreateExtTables_CreatesTable(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{ "logs": { Columns: map[string]string{"message": "text", "count": "int"}, }, } if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "test-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } // Verify table exists by inserting a row. _, err := db.ExecContext(ctx, `INSERT INTO ext_test_pkg_logs (id, message, count) VALUES ('1', 'hello', 42)`) if err != nil { t.Errorf("table not created or wrong schema: %v", err) } } func TestCreateExtTables_WithIndexes(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{ "events": { Columns: map[string]string{"user_id": "text", "kind": "text"}, Indexes: [][]string{{"user_id"}, {"user_id", "kind"}}, }, } if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "idx-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } // Verify indexes exist via sqlite_master. rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='ext_idx_pkg_events'`) if err != nil { t.Fatalf("query indexes: %v", err) } defer rows.Close() var idxNames []string for rows.Next() { var name string rows.Scan(&name) idxNames = append(idxNames, name) } if len(idxNames) < 2 { t.Errorf("expected at least 2 indexes, got %d: %v", len(idxNames), idxNames) } } func TestCreateExtTables_CatalogRegistration(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{ "logs": {Columns: map[string]string{"msg": "text"}}, "errors": {Columns: map[string]string{"detail": "text"}}, } if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "cat-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } registered := m.tables["cat-pkg"] if len(registered) != 2 { t.Errorf("expected 2 registered tables, got %d: %v", len(registered), registered) } } func TestCreateExtTables_NilDBIsNoop(t *testing.T) { m := newMemExtDataStore() ctx := context.Background() // Should not panic or error. err := CreateExtTables(ctx, nil, false, storesWithExtData(m), "pkg", map[string]TableDef{ "t": {Columns: map[string]string{"x": "text"}}, }, false) if err != nil { t.Errorf("expected nil error for nil db, got: %v", err) } } // ── DropExtTables ───────────────────────────────────────────────────────────── func TestDropExtTables_DropsAndClearsCatalog(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() // Create table first. tables := map[string]TableDef{ "items": {Columns: map[string]string{"name": "text"}}, } if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "drop-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } // Drop. if err := DropExtTables(ctx, db, storesWithExtData(m), "drop-pkg"); err != nil { t.Fatalf("DropExtTables: %v", err) } // Table should be gone. var count int db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='ext_drop_pkg_items'`, ).Scan(&count) if count != 0 { t.Error("expected table to be dropped, still present") } // Catalog should be empty. if len(m.tables["drop-pkg"]) != 0 { t.Errorf("expected empty catalog, got: %v", m.tables["drop-pkg"]) } } func TestDropExtTables_MultipleTables(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{ "alpha": {Columns: map[string]string{"v": "text"}}, "beta": {Columns: map[string]string{"v": "text"}}, } CreateExtTables(ctx, db, false, storesWithExtData(m), "multi-pkg", tables, false) DropExtTables(ctx, db, storesWithExtData(m), "multi-pkg") for _, name := range []string{"ext_multi_pkg_alpha", "ext_multi_pkg_beta"} { var count int db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name, ).Scan(&count) if count != 0 { t.Errorf("expected %s to be dropped", name) } } } func TestCreateExtTables_DDLContainsAutoColumns(t *testing.T) { // Verify that the DDL-generated table has 'id' and 'created_at' columns // even when no columns are declared in the manifest. db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{"empty": {Columns: map[string]string{}}} if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "auto-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } // Insert with just id — should work (created_at has default). _, err := db.ExecContext(ctx, `INSERT INTO ext_auto_pkg_empty (id) VALUES ('x')`) if err != nil { t.Errorf("expected auto-columns (id, created_at) to exist: %v", err) } // Verify created_at was populated. var ca string db.QueryRowContext(ctx, `SELECT created_at FROM ext_auto_pkg_empty WHERE id='x'`).Scan(&ca) if strings.TrimSpace(ca) == "" { t.Error("expected created_at to be populated by default") } } // ── parseVectorDim ────────────────────────────────────────────────────────── func TestParseVectorDim(t *testing.T) { cases := []struct { input string dim int ok bool }{ {"vector(384)", 384, true}, {"vector(1)", 1, true}, {"vector(4096)", 4096, true}, {"Vector(128)", 128, true}, // case insensitive {"vector(0)", 0, false}, {"vector(5000)", 0, false}, // exceeds 4096 {"vector()", 0, false}, {"vector(abc)", 0, false}, {"text", 0, false}, {"vector", 0, false}, } for _, c := range cases { dim, ok := parseVectorDim(c.input) if ok != c.ok || dim != c.dim { t.Errorf("parseVectorDim(%q) = (%d, %v), want (%d, %v)", c.input, dim, ok, c.dim, c.ok) } } } // ── mapColType vector ─────────────────────────────────────────────────────── func TestMapColType_VectorSQLite(t *testing.T) { got := mapColType("vector(384)", false, false) if got != "TEXT" { t.Errorf("vector on SQLite: got %q, want TEXT", got) } } func TestMapColType_VectorPgNoPgvector(t *testing.T) { got := mapColType("vector(384)", true, false) if got != "JSONB" { t.Errorf("vector on PG without pgvector: got %q, want JSONB", got) } } func TestMapColType_VectorPgvector(t *testing.T) { got := mapColType("vector(384)", true, true) if got != "vector(384)" { t.Errorf("vector on PG with pgvector: got %q, want vector(384)", got) } } // ── CreateExtTables with vector column ────────────────────────────────────── func TestCreateExtTables_VectorColumn(t *testing.T) { db := newSchemaTestDB(t) m := newMemExtDataStore() ctx := context.Background() tables := map[string]TableDef{ "docs": { Columns: map[string]string{"title": "text", "embedding": "vector(384)"}, }, } if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "vec-pkg", tables, false); err != nil { t.Fatalf("CreateExtTables: %v", err) } // Verify table exists and embedding is TEXT (SQLite fallback). _, err := db.ExecContext(ctx, `INSERT INTO ext_vec_pkg_docs (id, title, embedding) VALUES ('1', 'test', '[0.1,0.2]')`) if err != nil { t.Errorf("table not created or wrong schema: %v", err) } }