package dbmigration import ( "context" "database/sql" "errors" "os" "path/filepath" "testing" "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/utils/credential" _ "modernc.org/sqlite" ) type testRunner struct{} func (testRunner) Run(_ context.Context, _ Plan, update func(Status) error) error { if err := update(StatusCopying); err != nil { return err } if err := update(StatusVerifying); err != nil { return err } return update(StatusSwitching) } func testDatabase(t *testing.T) config.DatabaseConfig { t.Helper() vault, err := credential.NewVault(t.TempDir()) if err != nil { t.Fatal(err) } ciphertext, err := vault.Encrypt("password") if err != nil { t.Fatal(err) } return config.DatabaseConfig{ Type: config.DatabaseTypeMySQL, Host: "db.internal", Port: 3306, Username: "smail", Name: "smail", Password: config.StoredSecret{Ciphertext: ciphertext, Version: vault.Version(), UpdatedAt: time.Unix(1, 0).UTC()}, } } func TestPreviewAndExecuteUseOnlyDatabaseName(t *testing.T) { service := NewService(testRunner{}) service.now = func() time.Time { return time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC) } record, token, err := service.Preview(context.Background(), testDatabase(t), 7, "smail_next") if err != nil { t.Fatal(err) } if record.Status != StatusPreviewed || record.Source.Name != "smail" || record.Target.Name != "smail_next" { t.Fatalf("unexpected preview: %+v", record) } if record.Source.Host != record.Target.Host || record.Source.Port != record.Target.Port || record.Source.Username != record.Target.Username { t.Fatalf("immutable database settings changed: %+v", record) } if token == "" { t.Fatal("preview token is empty") } completed, err := service.Execute(context.Background(), record.ID, token) if err != nil { t.Fatal(err) } if completed.Status != StatusSucceeded { t.Fatalf("migration status = %s, want succeeded", completed.Status) } if _, err := service.Execute(context.Background(), record.ID, token); !errors.Is(err, ErrMigrationState) { t.Fatalf("replayed token returned %v, want state error", err) } } func TestPreviewRejectsUnsafeTargetName(t *testing.T) { service := NewService(testRunner{}) if _, _, err := service.Preview(context.Background(), testDatabase(t), 1, "smail;drop"); !errors.Is(err, ErrInvalidTargetName) { t.Fatalf("unsafe target returned %v", err) } } func TestPreviewHonorsCanceledContextWithoutCreatingRecord(t *testing.T) { service := NewService(testRunner{}) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, _, err := service.Preview(ctx, testDatabase(t), 1, "smail_next"); !errors.Is(err, context.Canceled) { t.Fatalf("canceled preview returned %v, want context canceled", err) } service.mu.RLock() defer service.mu.RUnlock() if len(service.records) != 0 { t.Fatalf("canceled preview created %d records", len(service.records)) } } func TestFailedExecutionKeepsSafeState(t *testing.T) { service := NewService(nil) record, token, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_next") if err != nil { t.Fatal(err) } failed, err := service.Execute(context.Background(), record.ID, token) if !errors.Is(err, ErrExecutorUnavailable) { t.Fatalf("execution returned %v, want executor unavailable", err) } if failed.Status != StatusFailed || failed.ErrorCode != "EXECUTOR_UNAVAILABLE" { t.Fatalf("unexpected failed state: %+v", failed) } } func TestMigrationErrorCodeMapsExecutorFailures(t *testing.T) { tests := []struct { err error code string }{ {err: ErrSQLTargetExists, code: "TARGET_EXISTS"}, {err: ErrSQLCopyUnavailable, code: "COPIER_UNAVAILABLE"}, {err: ErrSQLConnectionUnavailable, code: "CONNECTION_UNAVAILABLE"}, {err: ErrSQLPermissionDenied, code: "PERMISSION_DENIED"}, {err: ErrSQLUnsupportedObject, code: "UNSUPPORTED_OBJECT"}, {err: ErrSQLDataVerification, code: "DATA_VERIFICATION_FAILED"}, {err: ErrSQLSourceBusy, code: "SOURCE_BUSY"}, {err: ErrSwitchUnavailable, code: "SWITCH_UNAVAILABLE"}, {err: ErrSwitchOutcomeUnknown, code: "SWITCH_OUTCOME_UNKNOWN"}, } for _, test := range tests { t.Run(test.code, func(t *testing.T) { if got := migrationErrorCode(test.err); got != test.code { t.Fatalf("migration error code = %q, want %q", got, test.code) } }) } } func TestSQLitePreviewChangesOnlyLocalReference(t *testing.T) { service := NewService(testRunner{}) source := config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"} record, _, err := service.Preview(context.Background(), source, 1, "smail-next.db") if err != nil { t.Fatal(err) } if record.Target.LocalRef != "smail-next.db" || record.Target.Type != config.DatabaseTypeSQLite || record.Target.Name != "" { t.Fatalf("unexpected sqlite target: %+v", record.Target) } } type invalidTransitionRunner struct{} func (invalidTransitionRunner) Run(_ context.Context, _ Plan, update func(Status) error) error { return update(StatusCreated) } func TestMigrationRejectsBackwardStatusTransition(t *testing.T) { service := NewService(invalidTransitionRunner{}) record, token, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_next") if err != nil { t.Fatal(err) } failed, err := service.Execute(context.Background(), record.ID, token) if !errors.Is(err, ErrMigrationState) { t.Fatalf("execution returned %v, want state error", err) } if failed.Status != StatusFailed || failed.ErrorCode != "MIGRATION_FAILED" { t.Fatalf("unexpected failed migration: %+v", failed) } } func TestMigrationRecordsRemainIndependentDuringConcurrentExecution(t *testing.T) { service := NewService(testRunner{}) first, firstToken, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_first") if err != nil { t.Fatal(err) } second, secondToken, err := service.Preview(context.Background(), testDatabase(t), 1, "smail_second") if err != nil { t.Fatal(err) } results := make(chan struct { record Record err error }, 2) go func() { record, err := service.Execute(context.Background(), first.ID, firstToken) results <- struct { record Record err error }{record: record, err: err} }() go func() { record, err := service.Execute(context.Background(), second.ID, secondToken) results <- struct { record Record err error }{record: record, err: err} }() for range 2 { result := <-results if result.err != nil { t.Fatalf("concurrent execution failed: %v", result.err) } if result.record.Status != StatusSucceeded { t.Fatalf("unexpected concurrent status: %+v", result.record) } if result.record.Target.Name != "smail_first" && result.record.Target.Name != "smail_second" { t.Fatalf("unexpected concurrent target: %+v", result.record.Target) } } firstState, err := service.Get(first.ID) if err != nil { t.Fatal(err) } secondState, err := service.Get(second.ID) if err != nil { t.Fatal(err) } if firstState.Target.Name != "smail_first" || secondState.Target.Name != "smail_second" { t.Fatalf("migration records crossed: first=%+v second=%+v", firstState.Target, secondState.Target) } } func TestExecuteAuthorizedPassesActorToSQLiteSwitch(t *testing.T) { var received config.AuditActor var receivedGeneration uint64 root := t.TempDir() switchConfig := func(_ context.Context, generation uint64, _ config.DatabaseConfig, actor config.AuditActor) error { receivedGeneration = generation received = actor return nil } executor := NewAuthorizedSQLiteExecutor(root, switchConfig) service := NewService(executor) actor := config.AuditActor{UserID: "admin-1", Role: "super_admin", SessionID: "session-1"} if _, err := service.ExecuteAuthorized(context.Background(), "missing", "missing", actor); !errors.Is(err, ErrMigrationNotFound) { t.Fatalf("missing migration error = %v, want migration not found", err) } if _, err := service.ExecuteAuthorized(context.Background(), "missing", "missing", config.AuditActor{UserID: "admin-1"}); !errors.Is(err, config.ErrAuthorizationRequired) { t.Fatalf("missing session error = %v, want authorization required", err) } root = t.TempDir() executor = NewAuthorizedSQLiteExecutor(root, switchConfig) service = NewService(executor) configDirectory := filepath.Join(root, "data") if err := os.Mkdir(configDirectory, 0700); err != nil { t.Fatal(err) } database, err := sql.Open("sqlite", filepath.Join(configDirectory, "smail.db")) if err != nil { t.Fatal(err) } if _, err := database.Exec("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT NOT NULL)"); err != nil { database.Close() t.Fatal(err) } if err := database.Close(); err != nil { t.Fatal(err) } record, token, err := service.Preview(context.Background(), config.DatabaseConfig{Type: config.DatabaseTypeSQLite, LocalRef: "smail.db"}, 1, "next.db") if err != nil { t.Fatal(err) } completed, err := service.ExecuteAuthorized(context.Background(), record.ID, token, actor) if err != nil { t.Fatal(err) } if completed.Status != StatusSucceeded || received != actor || receivedGeneration != record.CurrentGeneration { t.Fatalf("authorized execution = %+v, received actor = %+v, generation = %d", completed, received, receivedGeneration) } }