Smail/server/services/dbmigration/service_test.go

180 lines
5.6 KiB
Go

package dbmigration
import (
"context"
"errors"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/utils/credential"
)
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 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 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)
}
}