Changeset 0.23.0 (#153)
This commit is contained in:
196
database/migrations/005_channels.sql
Normal file
196
database/migrations/005_channels.sql
Normal file
@@ -0,0 +1,196 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 005 Channels & Conversations
|
||||
-- ==========================================
|
||||
-- Channels, messages, participants, models, cursors, folders, user prefs.
|
||||
-- ICD §3 (Channels & Conversations)
|
||||
-- Note: project_id and workspace_id FKs added by 010_projects.sql
|
||||
-- ==========================================
|
||||
|
||||
-- =========================================
|
||||
-- FOLDERS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
|
||||
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNELS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
description TEXT,
|
||||
type VARCHAR(20) DEFAULT 'direct'
|
||||
CHECK (type IN ('direct', 'group', 'workflow')),
|
||||
model VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived BOOLEAN DEFAULT false,
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
folder_id UUID,
|
||||
folder TEXT,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
tags TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
|
||||
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
|
||||
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Deferred FK: channels.folder_id → folders.id
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_channels_folder'
|
||||
) THEN
|
||||
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN channels.type IS 'direct=single-user chat, group=multi-user/multi-model, workflow=staged channel';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- MESSAGES
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL
|
||||
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model VARCHAR(100),
|
||||
tokens_used INTEGER,
|
||||
tool_calls JSONB,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type VARCHAR(10) DEFAULT 'user',
|
||||
participant_id VARCHAR(255),
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
|
||||
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL PARTICIPANTS & MODELS
|
||||
-- =========================================
|
||||
-- ICD §3.7: Polymorphic participant system.
|
||||
-- participant_type determines what participant_id references:
|
||||
-- user → users.id
|
||||
-- persona → personas.id
|
||||
-- session → opaque session token
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
participant_type VARCHAR(10) NOT NULL DEFAULT 'user'
|
||||
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||
participant_id UUID NOT NULL,
|
||||
role VARCHAR(10) NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'member', 'observer')),
|
||||
display_name VARCHAR(200),
|
||||
avatar_url TEXT,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, participant_type, participant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id VARCHAR(255) NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
persona_id UUID REFERENCES personas(id) ON DELETE SET NULL,
|
||||
display_name VARCHAR(100),
|
||||
system_prompt TEXT,
|
||||
settings JSONB DEFAULT '{}'::jsonb,
|
||||
is_default BOOLEAN DEFAULT false,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- CHANNEL CURSORS (forking navigation)
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
|
||||
-- =========================================
|
||||
-- USER MODEL SETTINGS
|
||||
-- =========================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden BOOLEAN DEFAULT false,
|
||||
preferred_temperature FLOAT,
|
||||
preferred_max_tokens INT,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
|
||||
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
128
database/migrations/sqlite/005_channels.sql
Normal file
128
database/migrations/sqlite/005_channels.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- Chat Switchboard — 005 Channels & Conversations (SQLite)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, name, parent_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT DEFAULT 'direct' CHECK (type IN ('direct', 'group', 'workflow')),
|
||||
model TEXT,
|
||||
system_prompt TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
folder TEXT,
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE SET NULL,
|
||||
settings TEXT DEFAULT '{}',
|
||||
tags TEXT DEFAULT '[]',
|
||||
project_id TEXT,
|
||||
workspace_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_project ON channels(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_workspace ON channels(workspace_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system', 'tool')),
|
||||
content TEXT NOT NULL,
|
||||
model TEXT,
|
||||
tokens_used INTEGER,
|
||||
tool_calls TEXT,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
parent_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
sibling_index INTEGER DEFAULT 0,
|
||||
participant_type TEXT DEFAULT 'user',
|
||||
participant_id TEXT,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
participant_type TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (participant_type IN ('user', 'persona', 'session')),
|
||||
participant_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'member', 'observer')),
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
joined_at TEXT DEFAULT (datetime('now')),
|
||||
last_read_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, participant_type, participant_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_channel ON channel_participants(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_participants_ref ON channel_participants(participant_type, participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_models (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE SET NULL,
|
||||
persona_id TEXT REFERENCES personas(id) ON DELETE SET NULL,
|
||||
display_name TEXT,
|
||||
system_prompt TEXT,
|
||||
settings TEXT DEFAULT '{}',
|
||||
is_default INTEGER DEFAULT 0,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_cursors (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
active_leaf_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(channel_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_model_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
provider_config_id TEXT REFERENCES provider_configs(id) ON DELETE CASCADE,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
preferred_temperature REAL,
|
||||
preferred_max_tokens INTEGER,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, model_id, provider_config_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
|
||||
513
database/testhelper.go
Normal file
513
database/testhelper.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestDB holds a connection to the test database.
|
||||
// Use SetupTestDB in TestMain and pass this to subtests.
|
||||
var TestDB *sql.DB
|
||||
|
||||
const testDBName = "chat_switchboard_ci"
|
||||
|
||||
// SetupTestDB connects to the appropriate database backend (Postgres or
|
||||
// SQLite based on DB_DRIVER env), runs migrations, and sets database.DB
|
||||
// so handlers can use it.
|
||||
//
|
||||
// Call in TestMain:
|
||||
//
|
||||
// func TestMain(m *testing.M) {
|
||||
// teardown := database.SetupTestDB()
|
||||
// code := m.Run()
|
||||
// teardown()
|
||||
// os.Exit(code)
|
||||
// }
|
||||
//
|
||||
// For Postgres: requires PGHOST+PGUSER or TEST_DATABASE_URL.
|
||||
// For SQLite: set DB_DRIVER=sqlite (uses temp file, no external deps).
|
||||
//
|
||||
// Returns a cleanup function.
|
||||
func SetupTestDB() func() {
|
||||
driver := os.Getenv("DB_DRIVER")
|
||||
if driver == "sqlite" {
|
||||
return setupSQLiteTestDB()
|
||||
}
|
||||
return setupPostgresTestDB()
|
||||
}
|
||||
|
||||
// ── SQLite test setup ───────────────────────
|
||||
|
||||
func setupSQLiteTestDB() func() {
|
||||
CurrentDialect = DialectSQLite
|
||||
|
||||
// Use a temp file so multiple connections share the same DB.
|
||||
tmpFile, err := os.CreateTemp("", "switchboard-test-*.db")
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot create temp SQLite DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
dbPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
DB, err = sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot open SQLite DB: %v — skipping DB tests", err)
|
||||
os.Remove(dbPath)
|
||||
return func() {}
|
||||
}
|
||||
if err := DB.Ping(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
log.Printf("⚠ Cannot ping SQLite DB: %v — skipping DB tests", err)
|
||||
os.Remove(dbPath)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// SQLite: single writer
|
||||
DB.SetMaxOpenConns(1)
|
||||
|
||||
TestDB = DB
|
||||
|
||||
// Run SQLite migrations
|
||||
if err := Migrate(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
os.Remove(dbPath)
|
||||
log.Fatalf("❌ SQLite migration failed on test DB: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ SQLite test database ready: %s", dbPath)
|
||||
|
||||
return func() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
}
|
||||
os.Remove(dbPath)
|
||||
log.Printf("✓ Removed SQLite test database: %s", dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Postgres test setup (original) ──────────
|
||||
|
||||
func setupPostgresTestDB() func() {
|
||||
CurrentDialect = DialectPostgres
|
||||
|
||||
mainDSN := os.Getenv("TEST_DATABASE_URL")
|
||||
host := envOr("PGHOST", "")
|
||||
port := envOr("PGPORT", "5432")
|
||||
user := envOr("PGUSER", "")
|
||||
pass := envOr("PGPASSWORD", "")
|
||||
|
||||
if mainDSN == "" {
|
||||
if host == "" || user == "" {
|
||||
log.Println("⚠ TEST_DATABASE_URL / PGHOST+PGUSER not set — skipping DB tests")
|
||||
return func() {}
|
||||
}
|
||||
mainDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
|
||||
host, port, user, pass)
|
||||
}
|
||||
|
||||
adminDB, err := sql.Open("postgres", mainDSN)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Cannot connect to admin DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
if err := adminDB.Ping(); err != nil {
|
||||
adminDB.Close()
|
||||
log.Printf("⚠ Cannot ping admin DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
createdByUs := false
|
||||
var dbExists bool
|
||||
err = adminDB.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", testDBName).Scan(&dbExists)
|
||||
if err != nil {
|
||||
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
|
||||
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
|
||||
} else {
|
||||
createdByUs = true
|
||||
log.Printf("✓ Created test database: %s", testDBName)
|
||||
}
|
||||
} else {
|
||||
log.Printf("✓ Test database %s already exists (keeping extensions)", testDBName)
|
||||
}
|
||||
|
||||
var testDSN string
|
||||
if host != "" {
|
||||
testDSN = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
host, port, user, pass, testDBName)
|
||||
} else {
|
||||
testDSN = replaceDBName(mainDSN, testDBName)
|
||||
}
|
||||
|
||||
DB, err = sql.Open("postgres", testDSN)
|
||||
if err != nil {
|
||||
adminDB.Close()
|
||||
log.Printf("⚠ Cannot connect to test DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
if err := DB.Ping(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
adminDB.Close()
|
||||
log.Printf("⚠ Cannot ping test DB: %v — skipping DB tests", err)
|
||||
return func() {}
|
||||
}
|
||||
TestDB = DB
|
||||
|
||||
for _, ext := range []string{"uuid-ossp", "pgcrypto", "vector"} {
|
||||
DB.Exec(fmt.Sprintf(`CREATE EXTENSION IF NOT EXISTS "%s"`, ext))
|
||||
}
|
||||
|
||||
// Wipe all tables so migrations apply cleanly
|
||||
DB.Exec(`
|
||||
DO $$ DECLARE r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
if err := Migrate(); err != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
if createdByUs {
|
||||
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
|
||||
}
|
||||
adminDB.Close()
|
||||
log.Fatalf("❌ Migration failed on test DB: %v", err)
|
||||
}
|
||||
|
||||
return func() {
|
||||
if DB != nil {
|
||||
DB.Close()
|
||||
DB = nil
|
||||
TestDB = nil
|
||||
}
|
||||
if createdByUs {
|
||||
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
|
||||
log.Printf("✓ Dropped test database: %s", testDBName)
|
||||
}
|
||||
adminDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialect helpers ─────────────────────────
|
||||
|
||||
// PH returns a placeholder for the Nth parameter (1-indexed).
|
||||
// Postgres: $1, $2, ... SQLite: ?, ?, ...
|
||||
// Exported for use by test files in other packages (e.g. handlers).
|
||||
func PH(n int) string {
|
||||
if IsSQLite() {
|
||||
return "?"
|
||||
}
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
|
||||
// placeholders returns "?, ?, ?" or "$1, $2, $3" for n params.
|
||||
func placeholders(n int) string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = PH(i + 1)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// nowSQL returns the SQL expression for "current timestamp".
|
||||
func nowSQL() string {
|
||||
if IsSQLite() {
|
||||
return "datetime('now')"
|
||||
}
|
||||
return "NOW()"
|
||||
}
|
||||
|
||||
// ── Shared test helpers ─────────────────────
|
||||
|
||||
// RequireTestDB skips a test if no test database is available.
|
||||
func RequireTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if DB == nil || TestDB == nil {
|
||||
t.Skip("requires TEST_DATABASE_URL or PGHOST+PGUSER environment variables (or DB_DRIVER=sqlite)")
|
||||
}
|
||||
}
|
||||
|
||||
// TruncateAll truncates all application tables for test isolation.
|
||||
func TruncateAll(t *testing.T) {
|
||||
t.Helper()
|
||||
if DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
tables := []string{
|
||||
"resource_grants",
|
||||
"group_members",
|
||||
"groups",
|
||||
"notifications",
|
||||
"usage_log",
|
||||
"model_pricing",
|
||||
"notes",
|
||||
"audit_log",
|
||||
"channel_cursors",
|
||||
"messages",
|
||||
"channel_models",
|
||||
"channel_participants",
|
||||
"channel_knowledge_bases",
|
||||
"persona_knowledge_bases",
|
||||
"project_notes",
|
||||
"project_knowledge_bases",
|
||||
"project_channels",
|
||||
"kb_chunks",
|
||||
"kb_documents",
|
||||
"knowledge_bases",
|
||||
"channels",
|
||||
"projects",
|
||||
"user_model_settings",
|
||||
"model_catalog",
|
||||
"persona_grants",
|
||||
"personas",
|
||||
"provider_configs",
|
||||
"team_members",
|
||||
"teams",
|
||||
"refresh_tokens",
|
||||
"users",
|
||||
"platform_policies",
|
||||
"global_config",
|
||||
"global_settings",
|
||||
"folders",
|
||||
"attachments",
|
||||
"extension_user_settings",
|
||||
"extensions",
|
||||
}
|
||||
|
||||
if IsSQLite() {
|
||||
DB.Exec("PRAGMA foreign_keys = OFF")
|
||||
for _, table := range tables {
|
||||
DB.Exec(fmt.Sprintf("DELETE FROM %s", table))
|
||||
}
|
||||
DB.Exec("PRAGMA foreign_keys = ON")
|
||||
} else {
|
||||
for _, table := range tables {
|
||||
DB.Exec(fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-seed default config rows.
|
||||
if IsSQLite() {
|
||||
DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'),
|
||||
('model_roles', '{"utility":{"primary":null,"fallback":null},"embedding":{"primary":null,"fallback":null},"generation":{"primary":null,"fallback":null}}')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
DB.Exec(`
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
} else {
|
||||
DB.Exec(`
|
||||
INSERT INTO global_settings (key, value) VALUES
|
||||
('registration', '{"enabled": true}'::jsonb),
|
||||
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
|
||||
('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb),
|
||||
('model_roles', '{
|
||||
"utility": { "primary": null, "fallback": null },
|
||||
"embedding": { "primary": null, "fallback": null },
|
||||
"generation": { "primary": null, "fallback": null }
|
||||
}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
DB.Exec(`
|
||||
INSERT INTO platform_policies (key, value) VALUES
|
||||
('allow_user_byok', 'false'),
|
||||
('allow_user_personas', 'false'),
|
||||
('allow_raw_model_access', 'true'),
|
||||
('allow_registration', 'true'),
|
||||
('default_user_active', 'false'),
|
||||
('allow_team_providers', 'true')
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// SeedTestUser creates a test user and returns the user ID.
|
||||
func SeedTestUser(t *testing.T, username, email string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`
|
||||
INSERT INTO users (id, username, email, password_hash, role)
|
||||
VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
|
||||
`, id, username, email)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestUser: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role)
|
||||
VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user')
|
||||
RETURNING id
|
||||
`, username, email).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestUser: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// SeedTestChannel creates a test channel owned by userID and returns the channel ID.
|
||||
func SeedTestChannel(t *testing.T, userID, title string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO channels (id, user_id, title) VALUES (?, ?, ?)`, id, userID, title)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestChannel: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`INSERT INTO channels (user_id, title) VALUES ($1, $2) RETURNING id`, userID, title).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestChannel: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// SeedTestTeam creates a test team and returns the team ID.
|
||||
func SeedTestTeam(t *testing.T, name, createdBy string) string {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO teams (id, name, created_by) VALUES (?, ?, ?)`, id, name, createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeam: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`INSERT INTO teams (name, created_by) VALUES ($1, $2) RETURNING id`, name, createdBy).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeam: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// SeedTestTeamMember adds a user to a team.
|
||||
func SeedTestTeamMember(t *testing.T, teamID, userID, role string) {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
_, err := DB.Exec(`INSERT INTO team_members (id, team_id, user_id, role) VALUES (?, ?, ?, ?)`,
|
||||
uuid.New().String(), teamID, userID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeamMember: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES (%s, %s, %s)
|
||||
`, PH(1), PH(2), PH(3))
|
||||
_, err := DB.Exec(q, teamID, userID, role)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestTeamMember: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SeedTestGroup creates a test group and returns the group ID.
|
||||
func SeedTestGroup(t *testing.T, name, scope string, teamID *string, createdBy string) string {
|
||||
t.Helper()
|
||||
var teamArg interface{}
|
||||
if teamID != nil {
|
||||
teamArg = *teamID
|
||||
}
|
||||
if IsSQLite() {
|
||||
id := uuid.New().String()
|
||||
_, err := DB.Exec(`INSERT INTO groups (id, name, scope, team_id, created_by) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, name, scope, teamArg, createdBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestGroup: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
var id string
|
||||
err := DB.QueryRow(`INSERT INTO groups (name, scope, team_id, created_by) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
name, scope, teamArg, createdBy).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedTestGroup: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// SeedGroupMember adds a user to a group.
|
||||
func SeedGroupMember(t *testing.T, groupID, userID, addedBy string) {
|
||||
t.Helper()
|
||||
if IsSQLite() {
|
||||
_, err := DB.Exec(`INSERT INTO group_members (id, group_id, user_id, added_by) VALUES (?, ?, ?, ?)`,
|
||||
uuid.New().String(), groupID, userID, addedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedGroupMember: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
INSERT INTO group_members (group_id, user_id, added_by)
|
||||
VALUES (%s, %s, %s)
|
||||
`, PH(1), PH(2), PH(3))
|
||||
_, err := DB.Exec(q, groupID, userID, addedBy)
|
||||
if err != nil {
|
||||
t.Fatalf("SeedGroupMember: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func replaceDBName(dsn, newDB string) string {
|
||||
if strings.Contains(dsn, "dbname=") {
|
||||
parts := strings.Fields(dsn)
|
||||
for i, p := range parts {
|
||||
if strings.HasPrefix(p, "dbname=") {
|
||||
parts[i] = "dbname=" + newDB
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(dsn, "://") {
|
||||
idx := strings.LastIndex(dsn, "/")
|
||||
qIdx := strings.Index(dsn, "?")
|
||||
if qIdx > idx {
|
||||
return dsn[:idx+1] + newDB + dsn[qIdx:]
|
||||
}
|
||||
return dsn[:idx+1] + newDB
|
||||
}
|
||||
return dsn + " dbname=" + newDB
|
||||
}
|
||||
Reference in New Issue
Block a user