- Rename Go module switchboard-core → armature (155+ files) - Rename Docker image → gobha/armature - Rename K8s resources, secrets, deployments - Rename Prometheus metrics switchboard_* → armature_* - Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_* - Rename DB names switchboard_core* → armature* - Update all frontend branding, notification templates, docs - Update CI scripts, e2e tests, Keycloak realm, nginx conf - Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh - Rename k8s/switchboard.yaml → k8s/armature.yaml - Rename chart alerting/dashboard files - Fix: DockerHub push uses env: binding for secret injection - Helm chart updated (name, labels, template functions, dashboard, alerting) - Replace favicon/icon assets with Armature brand No functional changes. Pure mechanical rename + CI fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
351 lines
10 KiB
Go
351 lines
10 KiB
Go
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)
|
|
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) != "BOOLEAN" {
|
|
t.Error("expected BOOLEAN for bool in postgres")
|
|
}
|
|
if mapColType("timestamp", true) != "TIMESTAMPTZ" {
|
|
t.Error("expected TIMESTAMPTZ for timestamp in postgres")
|
|
}
|
|
if mapColType("int", true) != "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); 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); 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); 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"}},
|
|
})
|
|
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); 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)
|
|
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); 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")
|
|
}
|
|
}
|