262 lines
7.2 KiB
Go
262 lines
7.2 KiB
Go
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
|
|
}
|