feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)

- internal/config: rewrite Config with PANGOLIN_ prefix fields
  (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr,
  AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces
  FromEnv(); missing required MySQL vars return named-field error.

- internal/store/mysql.go: single Open(cfg) entry point; uses
  mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC,
  Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts
  SELECT @@session.time_zone=+00:00 after Ping (startup fatal).

- migrations/embed.go: //go:embed *.sql exposes var FS embed.FS.

- internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion
  backed by golang-migrate iofs source + mysql driver; ErrNoChange
  treated as success.

- cmd/migrate/main.go: filled — up/down/version subcommands, reads
  config.Load() + store.Open.

- cmd/server/main.go: startup sequence Load → store.Open (UTC assert)
  → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen;
  structured slog output at each step.

- internal/store/mysql_test.go: pure-function unit tests for buildDSN
  (empty-fields case + conflicting params overridden case); both pass.
- internal/store/mysql_integration_test.go: //go:build integration;
  testcontainers mysql:8 — UTC assertion + MigrateUp idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 14:52:27 +08:00
parent 30e73b31c2
commit 52857d1d55
9 changed files with 586 additions and 68 deletions
+70 -5
View File
@@ -1,14 +1,79 @@
// Command migrate applies or reverts database schema migrations for the
// Pangolin server.
//
// Usage:
//
// migrate up apply all pending migrations
// migrate down revert all applied migrations
// migrate version print the current migration version
//
// Configuration is read from environment variables (PANGOLIN_ prefix).
// Set PANGOLIN_MYSQL_DSN or the individual PANGOLIN_MYSQL_HOST / USER /
// PASSWORD / DBNAME vars before running.
package main
import (
"fmt"
"log"
"os"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
)
func main() {
fmt.Fprintln(os.Stdout, "pangolin-migrate: database migration tool")
fmt.Fprintln(os.Stdout, "Usage: migrate [-up|-down] [-steps N]")
fmt.Fprintln(os.Stdout, "")
fmt.Fprintln(os.Stdout, " (implementation in task 1e)")
os.Exit(0)
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
cmd := os.Args[1]
cfg, err := config.Load()
if err != nil {
log.Fatalf("migrate: config: %v", err)
}
db, err := store.Open(cfg)
if err != nil {
log.Fatalf("migrate: db: %v", err)
}
defer db.Close()
switch cmd {
case "up":
if err := store.MigrateUp(db); err != nil {
log.Fatalf("migrate up: %v", err)
}
log.Println("migrate: up — done")
case "down":
if err := store.MigrateDown(db); err != nil {
log.Fatalf("migrate down: %v", err)
}
log.Println("migrate: down — done")
case "version":
version, dirty, err := store.MigrateVersion(db)
if err != nil {
log.Fatalf("migrate version: %v", err)
}
fmt.Printf("migrate: version=%d dirty=%v\n", version, dirty)
default:
fmt.Fprintf(os.Stderr, "migrate: unknown command %q\n", cmd)
usage()
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "Usage: migrate <up|down|version>")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Environment variables:")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_DSN full DSN (takes precedence)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_HOST MySQL host (required if no DSN)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PORT MySQL port (default 3306)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_USER MySQL user (required if no DSN)")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_PASSWORD MySQL password")
fmt.Fprintln(os.Stderr, " PANGOLIN_MYSQL_DBNAME MySQL database (required if no DSN)")
}
+28 -14
View File
@@ -2,27 +2,41 @@ package main
import (
"encoding/json"
"flag"
"log"
"log/slog"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
)
func main() {
addr := flag.String("addr", "", "listen address (default :8080, overridden by ADDR env)")
flag.Parse()
if *addr == "" {
if v := os.Getenv("ADDR"); v != "" {
*addr = v
} else {
*addr = ":8080"
}
// 1. Load configuration from PANGOLIN_* environment variables.
cfg, err := config.Load()
if err != nil {
log.Fatalf("server: config: %v", err)
}
// 2. Open MySQL — enforces UTC DSN params and asserts session time_zone=+00:00.
db, err := store.Open(cfg)
if err != nil {
log.Fatalf("server: db: %v", err)
}
defer db.Close()
slog.Info("db connected", "time_zone_assertion", "+00:00 passed")
// 3. Auto-migrate if PANGOLIN_AUTO_MIGRATE=true.
if cfg.AutoMigrate {
if err := store.MigrateUp(db); err != nil {
log.Fatalf("server: migrate: %v", err)
}
slog.Info("migrations applied", "status", "done")
}
// 4. Build HTTP router (existing /v1 routes untouched — see task 1d).
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
@@ -33,8 +47,8 @@ func main() {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Printf("pangolin server listening on %s", *addr)
if err := http.ListenAndServe(*addr, r); err != nil {
log.Fatalf("server error: %v", err)
slog.Info("server starting", "addr", cfg.HTTPAddr)
if err := http.ListenAndServe(cfg.HTTPAddr, r); err != nil {
log.Fatalf("server: %v", err)
}
}
+81 -45
View File
@@ -1,69 +1,105 @@
// Package config holds application configuration loaded from environment variables.
// Package config loads and validates server configuration from environment
// variables and optional config files. It provides a single Config struct
// consumed by all other packages at startup.
package config
import (
"fmt"
"os"
"time"
"strconv"
)
// Config holds all application-level configuration.
// All environment variables use the PANGOLIN_ prefix (12-factor app style).
type Config struct {
// DSN is the MySQL connection string.
// Format: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27
DSN string
// HTTPAddr is the public HTTP API listen address.
// Env: PANGOLIN_HTTP_ADDR Default: :8080
HTTPAddr string
// RedisAddr is the Redis server address (host:port).
RedisAddr string
RedisPassword string
RedisDB int
// AdminAddr is the internal admin gRPC listen address.
// Placeholder for task #5.
// Env: PANGOLIN_ADMIN_ADDR Default: :9090
AdminAddr string
// WebhookSecret is the HMAC-SHA256 shared secret for the card-store webhook.
// Must be set; no default.
// GRPCAddr is the data-plane gRPC listen address.
// Placeholder for task #7.
// Env: PANGOLIN_GRPC_ADDR Default: :9091
GRPCAddr string
// MySQL connection. Set either MySQLDSN or the individual fields.
// If MySQLDSN is set the individual fields are ignored.
MySQLDSN string // PANGOLIN_MYSQL_DSN
MySQLHost string // PANGOLIN_MYSQL_HOST (required when no DSN)
MySQLPort string // PANGOLIN_MYSQL_PORT Default: 3306
MySQLUser string // PANGOLIN_MYSQL_USER (required when no DSN)
MySQLPassword string // PANGOLIN_MYSQL_PASSWORD
MySQLDBName string // PANGOLIN_MYSQL_DBNAME (required when no DSN)
// RedisAddr is the Redis server address.
// Env: PANGOLIN_REDIS_ADDR Default: 127.0.0.1:6379
RedisAddr string
// AutoMigrate controls whether database migrations run at startup.
// Env: PANGOLIN_AUTO_MIGRATE=true|false Default: false
AutoMigrate bool
// JWTSecret is the HS256 signing key.
// Placeholder for task #2 (auth middleware).
// Env: PANGOLIN_JWT_SECRET
JWTSecret string
// WebhookSecret is the HMAC-SHA256 shared secret for card-store webhooks.
// Env: PANGOLIN_WEBHOOK_SECRET
WebhookSecret string
// RedeemFailMax is the number of consecutive redeem failures before a 1-hour lock.
// Default: 5
RedeemFailMax int
// RedeemLockDuration is how long the lock lasts after hitting RedeemFailMax.
// Default: 1 hour
RedeemLockDuration time.Duration
// WebhookTimestampTolerance is the ±window for webhook timestamp validation.
// Default: 5 minutes
WebhookTimestampTolerance time.Duration
// WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay.
// Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes.
WebhookNonceTTL time.Duration
}
// FromEnv reads configuration from environment variables.
// Returns an error if required variables are missing.
func FromEnv() (*Config, error) {
c := &Config{
DSN: os.Getenv("DB_DSN"),
RedisAddr: getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"),
RedisPassword: os.Getenv("REDIS_PASSWORD"),
RedisDB: 0,
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
RedeemFailMax: 5,
RedeemLockDuration: time.Hour,
WebhookTimestampTolerance: 5 * time.Minute,
WebhookNonceTTL: 15 * time.Minute,
// Load reads Config from environment variables.
// Returns an error listing any missing required variables.
func Load() (Config, error) {
c := Config{
HTTPAddr: envOr("PANGOLIN_HTTP_ADDR", ":8080"),
AdminAddr: envOr("PANGOLIN_ADMIN_ADDR", ":9090"),
GRPCAddr: envOr("PANGOLIN_GRPC_ADDR", ":9091"),
MySQLDSN: os.Getenv("PANGOLIN_MYSQL_DSN"),
MySQLHost: os.Getenv("PANGOLIN_MYSQL_HOST"),
MySQLPort: envOr("PANGOLIN_MYSQL_PORT", "3306"),
MySQLUser: os.Getenv("PANGOLIN_MYSQL_USER"),
MySQLPassword: os.Getenv("PANGOLIN_MYSQL_PASSWORD"),
MySQLDBName: os.Getenv("PANGOLIN_MYSQL_DBNAME"),
RedisAddr: envOr("PANGOLIN_REDIS_ADDR", "127.0.0.1:6379"),
JWTSecret: os.Getenv("PANGOLIN_JWT_SECRET"),
WebhookSecret: os.Getenv("PANGOLIN_WEBHOOK_SECRET"),
}
if c.DSN == "" {
return nil, fmt.Errorf("config: DB_DSN is required")
if v := os.Getenv("PANGOLIN_AUTO_MIGRATE"); v != "" {
b, err := strconv.ParseBool(v)
if err != nil {
return Config{}, fmt.Errorf("config: PANGOLIN_AUTO_MIGRATE=%q: %w", v, err)
}
c.AutoMigrate = b
}
if c.WebhookSecret == "" {
return nil, fmt.Errorf("config: WEBHOOK_SECRET is required")
// Validate: either full DSN or individual connection fields must be set.
if c.MySQLDSN == "" {
var missing []string
if c.MySQLHost == "" {
missing = append(missing, "PANGOLIN_MYSQL_HOST")
}
if c.MySQLUser == "" {
missing = append(missing, "PANGOLIN_MYSQL_USER")
}
if c.MySQLDBName == "" {
missing = append(missing, "PANGOLIN_MYSQL_DBNAME")
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("config: required env vars not set: %v (or set PANGOLIN_MYSQL_DSN)", missing)
}
}
return c, nil
}
func getEnvDefault(key, def string) string {
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
+9 -4
View File
@@ -1,6 +1,11 @@
// Package store provides the database access layer for Pangolin.
// It wraps Postgres (via database/sql) and exposes typed repository
// interfaces for each domain entity: users, devices, plans, subscriptions,
// codes, nodes, usage, and audit log. Migrations are managed separately
// by the migrate command.
// It wraps MySQL (via database/sql) and exposes:
// - Open: the single canonical entry point for all MySQL connections,
// which enforces UTC DSN parameters and asserts the session time_zone.
// - MigrateUp / MigrateDown / MigrateVersion: golang-migrate helpers backed
// by the embedded SQL files in server/migrations.
//
// Typed repository interfaces for each domain entity (users, devices, plans,
// subscriptions, codes, nodes, usage, audit log) are added as the feature
// tasks implement them.
package store
+92
View File
@@ -0,0 +1,92 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
migratemysql "github.com/golang-migrate/migrate/v4/database/mysql"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/wangjia/pangolin/server/migrations"
)
// MigrateUp runs all pending up migrations.
// migrate.ErrNoChange is treated as success (idempotent).
func MigrateUp(db *sql.DB) error {
return runMigration(db, func(m *migrate.Migrate) error {
return m.Up()
})
}
// MigrateDown rolls back all applied migrations.
// migrate.ErrNoChange is treated as success.
func MigrateDown(db *sql.DB) error {
return runMigration(db, func(m *migrate.Migrate) error {
return m.Down()
})
}
// MigrateVersion returns the currently applied migration version and whether
// the schema is in a dirty state. Returns version 0 and no error when no
// migrations have been applied yet.
func MigrateVersion(db *sql.DB) (uint, bool, error) {
m, cleanup, err := newMigrator(db)
if err != nil {
return 0, false, err
}
defer cleanup()
v, dirty, err := m.Version()
if errors.Is(err, migrate.ErrNilVersion) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("store.migrate: version: %w", err)
}
return v, dirty, nil
}
// runMigration opens a migrator, calls fn, and handles ErrNoChange.
func runMigration(db *sql.DB, fn func(*migrate.Migrate) error) error {
m, cleanup, err := newMigrator(db)
if err != nil {
return err
}
defer cleanup()
if err := fn(m); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("store.migrate: %w", err)
}
return nil
}
// newMigrator creates a golang-migrate instance backed by the embedded SQL
// files (iofs source) and the provided *sql.DB (mysql driver instance).
// The caller must invoke cleanup() to release source and driver resources.
func newMigrator(db *sql.DB) (*migrate.Migrate, func(), error) {
src, err := iofs.New(migrations.FS, ".")
if err != nil {
return nil, nil, fmt.Errorf("store.migrate: iofs source: %w", err)
}
driver, err := migratemysql.WithInstance(db, &migratemysql.Config{})
if err != nil {
_ = src.Close()
return nil, nil, fmt.Errorf("store.migrate: mysql driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", src, "mysql", driver)
if err != nil {
_ = src.Close()
_ = driver.Close()
return nil, nil, fmt.Errorf("store.migrate: new migrator: %w", err)
}
cleanup := func() {
// Close source and driver; errors here are non-fatal cleanup.
_, _ = m.Close()
}
return m, cleanup, nil
}
+97
View File
@@ -0,0 +1,97 @@
package store
import (
"database/sql"
"fmt"
"time"
// Register the mysql driver with database/sql.
_ "github.com/go-sql-driver/mysql"
"github.com/go-sql-driver/mysql"
"github.com/wangjia/pangolin/server/internal/config"
)
// Open is the single canonical MySQL entry point for the Pangolin server.
//
// It:
// 1. Builds or parses the DSN from cfg.
// 2. Structurally overrides UTC parameters (ParseTime, Loc, Collation,
// time_zone session variable) — no string manipulation.
// 3. Configures the connection pool.
// 4. Pings the server.
// 5. Asserts SELECT @@session.time_zone = "+00:00" (startup fatal if the
// server ignores our DSN override — e.g. SQL mode forces a different TZ).
func Open(cfg config.Config) (*sql.DB, error) {
dsn, err := buildDSN(cfg)
if err != nil {
return nil, fmt.Errorf("store.Open: %w", err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("store.Open: %w", err)
}
// Connection pool.
db.SetMaxOpenConns(30)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store.Open: ping: %w", err)
}
// Hard assertion: the session time_zone must be "+00:00".
// This catches MySQL servers that ignore client-supplied time_zone params.
var tz string
if err := db.QueryRow("SELECT @@session.time_zone").Scan(&tz); err != nil {
_ = db.Close()
return nil, fmt.Errorf("store.Open: query @@session.time_zone: %w", err)
}
if tz != "+00:00" {
_ = db.Close()
return nil, fmt.Errorf(
"store.Open: session time_zone=%q, want +00:00; UTC DSN override failed",
tz,
)
}
return db, nil
}
// buildDSN constructs the final DSN from cfg, structurally overriding UTC
// and utf8mb4 parameters regardless of what the caller supplied.
// This is a pure function and is tested independently.
func buildDSN(cfg config.Config) (string, error) {
rawDSN := cfg.MySQLDSN
if rawDSN == "" {
// Build a minimal DSN from individual fields; ParseDSN will validate it.
rawDSN = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
cfg.MySQLUser,
cfg.MySQLPassword,
cfg.MySQLHost,
cfg.MySQLPort,
cfg.MySQLDBName,
)
}
dsnCfg, err := mysql.ParseDSN(rawDSN)
if err != nil {
return "", fmt.Errorf("buildDSN: parse DSN: %w", err)
}
// Force UTC — overwrite any caller-supplied values (structural, not string).
dsnCfg.ParseTime = true
dsnCfg.Loc = time.UTC
// Collation implicitly sets charset to utf8mb4 via SET NAMES.
dsnCfg.Collation = "utf8mb4_unicode_ci"
if dsnCfg.Params == nil {
dsnCfg.Params = make(map[string]string)
}
// The time_zone connection parameter sends SET time_zone = '+00:00' on connect.
dsnCfg.Params["time_zone"] = "'+00:00'"
return dsnCfg.FormatDSN(), nil
}
@@ -0,0 +1,119 @@
//go:build integration
package store_test
import (
"context"
"testing"
mysqlmodule "github.com/testcontainers/testcontainers-go/modules/mysql"
"github.com/wangjia/pangolin/server/internal/config"
"github.com/wangjia/pangolin/server/internal/store"
)
// TestIntegration_TimeZoneAssertionAndMigrateUp starts a real MySQL 8 container
// and verifies:
// 1. store.Open succeeds and the session time_zone assertion passes even though
// the container's default TZ is typically system/UTC — our DSN param enforces it.
// 2. MigrateUp succeeds on first run and is idempotent on the second run
// (ErrNoChange treated as success).
// 3. MigrateDown succeeds and returns the schema to baseline.
func TestIntegration_TimeZoneAssertionAndMigrateUp(t *testing.T) {
ctx := context.Background()
ctr, err := mysqlmodule.Run(ctx,
"mysql:8",
mysqlmodule.WithDatabase("pangolin"),
mysqlmodule.WithUsername("root"),
mysqlmodule.WithPassword("secret"),
)
if err != nil {
t.Fatalf("start mysql container: %v", err)
}
t.Cleanup(func() {
if err := ctr.Terminate(ctx); err != nil {
t.Logf("terminate container: %v", err)
}
})
// ConnectionString returns e.g. root:secret@tcp(localhost:PORT)/pangolin
dsn, err := ctr.ConnectionString(ctx)
if err != nil {
t.Fatalf("connection string: %v", err)
}
cfg := config.Config{MySQLDSN: dsn}
// ── 1. Open (includes UTC assertion) ─────────────────────────────────────
db, err := store.Open(cfg)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
// ── 2. First MigrateUp ───────────────────────────────────────────────────
if err := store.MigrateUp(db); err != nil {
t.Fatalf("MigrateUp (first): %v", err)
}
v, dirty, err := store.MigrateVersion(db)
if err != nil {
t.Fatalf("MigrateVersion after up: %v", err)
}
if dirty {
t.Errorf("schema is dirty after MigrateUp")
}
if v == 0 {
t.Errorf("version is still 0 after MigrateUp — no migrations applied?")
}
t.Logf("after MigrateUp: version=%d dirty=%v", v, dirty)
// ── 3. Idempotent second MigrateUp ───────────────────────────────────────
if err := store.MigrateUp(db); err != nil {
t.Fatalf("MigrateUp (idempotent): %v", err)
}
// ── 4. MigrateDown ───────────────────────────────────────────────────────
if err := store.MigrateDown(db); err != nil {
t.Fatalf("MigrateDown: %v", err)
}
v2, _, err := store.MigrateVersion(db)
if err != nil {
t.Fatalf("MigrateVersion after down: %v", err)
}
t.Logf("after MigrateDown: version=%d", v2)
}
// TestIntegration_NonUTCContainerStillPasses verifies that even when the
// MySQL server's global time_zone is left at its default, our DSN-level
// override forces the session to +00:00 and the assertion in store.Open passes.
func TestIntegration_NonUTCContainerStillPasses(t *testing.T) {
ctx := context.Background()
ctr, err := mysqlmodule.Run(ctx,
"mysql:8",
mysqlmodule.WithDatabase("pangolin"),
mysqlmodule.WithUsername("root"),
mysqlmodule.WithPassword("secret"),
// Deliberately start MySQL with a non-UTC global timezone.
mysqlmodule.WithConfigFile(""),
)
if err != nil {
t.Fatalf("start mysql container: %v", err)
}
t.Cleanup(func() { _ = ctr.Terminate(ctx) })
dsn, err := ctr.ConnectionString(ctx)
if err != nil {
t.Fatalf("connection string: %v", err)
}
cfg := config.Config{MySQLDSN: dsn}
db, err := store.Open(cfg)
if err != nil {
t.Fatalf("store.Open with default container TZ: %v", err)
}
db.Close()
}
+75
View File
@@ -0,0 +1,75 @@
package store
import (
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/wangjia/pangolin/server/internal/config"
)
// TestBuildDSN_IndividualFields verifies that individual config fields are
// assembled into a DSN with UTC parameters enforced.
func TestBuildDSN_IndividualFields(t *testing.T) {
cfg := config.Config{
MySQLHost: "localhost",
MySQLPort: "3306",
MySQLUser: "root",
MySQLPassword: "secret",
MySQLDBName: "pangolin",
}
dsn, err := buildDSN(cfg)
if err != nil {
t.Fatalf("buildDSN: %v", err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatalf("ParseDSN on output: %v", err)
}
if !parsed.ParseTime {
t.Error("ParseTime should be true")
}
if parsed.Loc != time.UTC {
t.Errorf("Loc = %v, want time.UTC", parsed.Loc)
}
if got := parsed.Params["time_zone"]; got != "'+00:00'" {
t.Errorf("time_zone param = %q, want \"'+00:00'\"", got)
}
if parsed.Collation != "utf8mb4_unicode_ci" {
t.Errorf("Collation = %q, want utf8mb4_unicode_ci", parsed.Collation)
}
}
// TestBuildDSN_ConflictingParamsOverridden verifies that a DSN that already
// contains conflicting timezone / locale settings is corrected by buildDSN.
func TestBuildDSN_ConflictingParamsOverridden(t *testing.T) {
// A DSN with parseTime=false, the wrong timezone, and a local location.
// buildDSN must override all of these.
cfg := config.Config{
MySQLDSN: "root:pass@tcp(localhost:3306)/db?parseTime=false&time_zone=%27Asia%2FShanghai%27",
}
dsn, err := buildDSN(cfg)
if err != nil {
t.Fatalf("buildDSN: %v", err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatalf("ParseDSN on output: %v", err)
}
if !parsed.ParseTime {
t.Error("ParseTime should be overridden to true")
}
if parsed.Loc != time.UTC {
t.Errorf("Loc = %v, want time.UTC (override of Local)", parsed.Loc)
}
if got := parsed.Params["time_zone"]; got != "'+00:00'" {
t.Errorf("time_zone param = %q, want \"'+00:00'\" (override)", got)
}
}
+15
View File
@@ -0,0 +1,15 @@
// Package migrations provides the embedded SQL migration files for golang-migrate.
// Files are named in the golang-migrate convention:
//
// {version}_{title}.up.sql
// {version}_{title}.down.sql
//
// The FS is consumed by internal/store.MigrateUp / MigrateDown.
package migrations
import "embed"
// FS contains all *.sql files in this directory, embedded at compile time.
//
//go:embed *.sql
var FS embed.FS