package db import ( "database/sql" "net/url" "os" "path/filepath" "strings" "testing" mysql "github.com/go-sql-driver/mysql" _ "modernc.org/sqlite" "ymhut-box/server/feedback-mailer/internal/config" ) func TestOpenMigratesLegacyFeedbackColumns(t *testing.T) { dir := t.TempDir() storage := filepath.Join(dir, "storage") if err := os.MkdirAll(storage, 0o750); err != nil { t.Fatal(err) } dbPath := filepath.Join(storage, "feedback.sqlite") conn, err := sql.Open("sqlite", dbPath) if err != nil { t.Fatal(err) } _, err = conn.Exec(`CREATE TABLE feedbacks ( code TEXT PRIMARY KEY, received_at TEXT NOT NULL, title TEXT NOT NULL, type TEXT NOT NULL, severity TEXT NOT NULL, contact TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL, package_path TEXT NOT NULL, package_sha256 TEXT NOT NULL, remote_addr TEXT NOT NULL, summary_text TEXT NOT NULL, updated_at TEXT NOT NULL )`) if closeErr := conn.Close(); closeErr != nil { t.Fatal(closeErr) } if err != nil { t.Fatal(err) } cfg := config.Default(dir) cfg.StorageDir = storage cfg.DatabasePath = dbPath store, err := Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() for _, column := range []string{"public_reply", "included_files", "mail_sent", "encrypted_package_path", "plain_package_sha256", "assignee", "due_at", "sla_level", "source_channel", "risk_score", "resolution"} { if !hasColumn(t, store.db, "feedbacks", column) { t.Fatalf("expected migrated column %q", column) } } for _, table := range []string{"feedback_comments", "feedback_tags", "audit_logs", "webhook_deliveries"} { if !hasTable(t, store.db, table) { t.Fatalf("expected migrated table %q", table) } } if mode := store.WALMode(); mode != "wal" { t.Fatalf("expected wal mode, got %q", mode) } } func TestTicketExtensionsRoundTrip(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) store, err := Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() record := FeedbackRecord{ Code: "FB-20260606-ABC123", ReceivedAt: Now(), Title: "Crash", Type: "issue", Severity: "major", Contact: "dev@example.com", Body: "Steps", Status: "new", StatusDetail: "received", PackagePath: "a.zip", EncryptedPackagePath: "a.ymfb", PackageSha256: strings.Repeat("a", 64), PlainPackageSha256: strings.Repeat("b", 64), RemoteAddr: "127.0.0.1", SummaryText: "summary", IncludedFiles: "feedback.json", UpdatedAt: Now(), LastActivityAt: Now(), Tags: []string{"crash", "UI"}, } if err := store.InsertFeedback(record); err != nil { t.Fatal(err) } if err := store.UpdateFeedback(record.Code, FeedbackUpdate{ Status: "investigating", Category: "issue", Priority: "major", StatusDetail: "checking", Assignee: "alice", SLALevel: "elevated", Note: "internal", PublicReply: "reply", Actor: "alice", Tags: []string{"crash", "priority"}, }); err != nil { t.Fatal(err) } if _, err := store.InsertComment(FeedbackComment{FeedbackCode: record.Code, Author: "alice", Body: "comment", Internal: true}); err != nil { t.Fatal(err) } if err := store.InsertAudit(AuditLog{Actor: "alice", Type: "feedback.updated", Target: record.Code, Message: "updated"}); err != nil { t.Fatal(err) } if _, err := store.InsertWebhookDelivery(WebhookDelivery{WebhookName: "ops", Event: "feedback.updated", Status: "pending"}); err != nil { t.Fatal(err) } detail, err := store.GetFeedbackDetail(record.Code) if err != nil { t.Fatal(err) } if detail.Assignee != "alice" || detail.SLALevel != "elevated" || len(detail.Comments) != 1 || len(detail.Tags) != 2 { t.Fatalf("unexpected detail: %+v", detail) } page, err := store.ListFeedbacks(1, 20, FeedbackFilters{Assignee: "alice", Tag: "priority", SLA: "elevated"}) if err != nil { t.Fatal(err) } if page.Total != 1 { t.Fatalf("expected filtered ticket, got %+v", page) } } func TestApplyDatabaseConfigSwitchesSQLitePath(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) store, err := Open(cfg) if err != nil { t.Fatal(err) } defer store.Close() next := cfg.Database next.Provider = "sqlite" next.SQLitePath = filepath.Join(dir, "storage", "next.sqlite") if err := store.ApplyDatabaseConfig(next); err != nil { t.Fatal(err) } if store.Status().ActiveProvider != "sqlite" { t.Fatalf("expected sqlite active provider, got %+v", store.Status()) } if _, err := os.Stat(next.SQLitePath); err != nil { t.Fatalf("expected new sqlite database at %s: %v", next.SQLitePath, err) } if !hasTable(t, store.DB(), "feedbacks") { t.Fatal("expected migrated feedbacks table on switched sqlite database") } } func TestDatabaseDSNEncodesRemoteCredentials(t *testing.T) { dir := t.TempDir() sqliteDSN, err := databaseDSN(config.DatabaseConfig{ Provider: "sqlite", SQLitePath: "storage/feedback.sqlite", }, dir) if err != nil { t.Fatal(err) } if sqliteDSN != filepath.Join(dir, "storage", "feedback.sqlite") { t.Fatalf("relative sqlite path should resolve from service root, got %q", sqliteDSN) } mysqlDSN, err := databaseDSN(config.DatabaseConfig{ Provider: "mysql", Host: "db.example.com", Port: 3307, Name: "feedback_db", User: "feedback_user", Password: "p@ss/word", }, dir) if err != nil { t.Fatal(err) } parsedMySQL, err := mysql.ParseDSN(mysqlDSN) if err != nil { t.Fatal(err) } if parsedMySQL.User != "feedback_user" || parsedMySQL.Passwd != "p@ss/word" || parsedMySQL.DBName != "feedback_db" || parsedMySQL.Addr != "db.example.com:3307" { t.Fatalf("mysql DSN did not preserve settings: %+v", parsedMySQL) } postgresDSN, err := databaseDSN(config.DatabaseConfig{ Provider: "postgres", Host: "pg.example.com", Port: 5433, Name: "feedback/db", User: "feedback:user", Password: "p@ss/word", SSLMode: "require", }, dir) if err != nil { t.Fatal(err) } parsed, err := url.Parse(postgresDSN) if err != nil { t.Fatal(err) } if parsed.Scheme != "postgres" || parsed.Host != "pg.example.com:5433" || parsed.Query().Get("sslmode") != "require" { t.Fatalf("postgres DSN was not safely formatted: %s", postgresDSN) } if password, ok := parsed.User.Password(); !ok || password != "p@ss/word" || parsed.User.Username() != "feedback:user" { t.Fatalf("postgres credentials were not preserved: %s", postgresDSN) } } func hasColumn(t *testing.T, conn *sql.DB, table, column string) bool { t.Helper() rows, err := conn.Query(`PRAGMA table_info(` + table + `)`) if err != nil { t.Fatal(err) } defer rows.Close() for rows.Next() { var cid int var name, typ string var notNull int var dflt sql.NullString var pk int if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { t.Fatal(err) } if name == column { return true } } return false } func hasTable(t *testing.T, conn *sql.DB, table string) bool { t.Helper() row := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table) var name string if err := row.Scan(&name); err != nil { return false } return name == table }