package db import ( "context" "database/sql" "fmt" "log/slog" "testing" "time" "sneak.berlin/go/neoirc/internal/dbtest" ) // NewTestDatabase creates an in-memory database for testing. func NewTestDatabase(tb testing.TB) (*Database, error) { tb.Helper() dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)" conn, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open test db: %w", err) } database := &Database{ //nolint:exhaustruct // test helper, params not needed conn: conn, log: slog.Default(), } err = database.runMigrations(context.Background()) if err != nil { closeErr := conn.Close() if closeErr != nil { return nil, fmt.Errorf( "close after migration failure: %w", closeErr, ) } return nil, fmt.Errorf( "run test migrations: %w", err, ) } return database, nil } // Close closes the underlying database connection. func (database *Database) Close() error { err := database.conn.Close() if err != nil { return fmt.Errorf("close database: %w", err) } return nil } // ExecForTest runs a raw statement, such as a trigger that // makes a write fail. func (database *Database) ExecForTest( ctx context.Context, query string, ) error { _, err := database.conn.ExecContext(ctx, query) if err != nil { return fmt.Errorf("exec for test: %w", err) } return nil } // GetDB returns the underlying sql.DB connection. func (database *Database) GetDB() *sql.DB { return database.conn } // GetOrCreateChannel returns channel id, creating if needed. // Uses INSERT OR IGNORE to avoid TOCTOU races. func (database *Database) GetOrCreateChannel( ctx context.Context, name string, ) (int64, error) { now := time.Now() _, err := database.conn.ExecContext(ctx, `INSERT OR IGNORE INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)`, name, now, now) if err != nil { return 0, fmt.Errorf("create channel: %w", err) } var channelID int64 err = database.conn.QueryRowContext( ctx, "SELECT id FROM channels WHERE name = ?", name, ).Scan(&channelID) if err != nil { return 0, fmt.Errorf("get channel: %w", err) } return channelID, nil } // JoinChannel adds a session to a channel. func (database *Database) JoinChannel( ctx context.Context, channelID, sessionID int64, ) error { _, err := database.conn.ExecContext(ctx, `INSERT OR IGNORE INTO channel_members (channel_id, session_id, joined_at) VALUES (?, ?, ?)`, channelID, sessionID, time.Now()) if err != nil { return fmt.Errorf("join channel: %w", err) } return nil } // HasChannelInvite checks if a session has been invited // to a channel. func (database *Database) HasChannelInvite( ctx context.Context, channelID, sessionID int64, ) (bool, error) { var count int err := database.conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_invites WHERE channel_id = ? AND session_id = ?`, channelID, sessionID, ).Scan(&count) if err != nil { return false, fmt.Errorf( "check invite: %w", err, ) } return count > 0, nil }