package deployer import ( "context" "errors" "sync/atomic" "testing" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // fakeBackuper records pre-deploy backup calls so the dispatch wiring can be // asserted. err (when set) simulates a backup failure. type fakeBackuper struct { count atomic.Int32 lastType atomic.Value // string err error } func (f *fakeBackuper) CreateBackup(backupType string) (store.Backup, error) { f.count.Add(1) f.lastType.Store(backupType) if f.err != nil { return store.Backup{}, f.err } return store.Backup{ID: "b1", Filename: "tinyforge-pre-deploy.db"}, nil } func setAutoBackup(t *testing.T, d *Deployer, enabled bool) { t.Helper() s, err := d.store.GetSettings() if err != nil { t.Fatalf("get settings: %v", err) } s.AutoBackupBeforeDeploy = enabled if err := d.store.UpdateSettings(s); err != nil { t.Fatalf("update settings: %v", err) } } // Regression: the pre-deploy backup hook was orphaned after the cutover (no // caller on DispatchPlugin), making auto_backup_before_deploy a silent no-op. func TestDispatchPlugin_PreDeployBackup_FiresWhenEnabled(t *testing.T) { resetFake(t) d := newTestDeployer(t) b := &fakeBackuper{} d.SetPreDeployBackuper(b) setAutoBackup(t, d, true) if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil { t.Fatalf("dispatch: %v", err) } if got := b.count.Load(); got != 1 { t.Fatalf("CreateBackup called %d times, want 1", got) } if bt, _ := b.lastType.Load().(string); bt != "pre-deploy" { t.Fatalf("backup type = %q, want pre-deploy", bt) } if got := dispatchTestSource.deployCount.Load(); got != 1 { t.Fatalf("Deploy ran %d times, want 1", got) } } func TestDispatchPlugin_PreDeployBackup_SkippedWhenDisabled(t *testing.T) { resetFake(t) d := newTestDeployer(t) b := &fakeBackuper{} d.SetPreDeployBackuper(b) setAutoBackup(t, d, false) if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil { t.Fatalf("dispatch: %v", err) } if got := b.count.Load(); got != 0 { t.Fatalf("CreateBackup called %d times, want 0 (setting off)", got) } } func TestDispatchPlugin_PreDeployBackup_NilBackuperNoPanic(t *testing.T) { resetFake(t) d := newTestDeployer(t) setAutoBackup(t, d, true) // enabled, but no backuper wired if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil { t.Fatalf("dispatch must not panic/fail with a nil backuper: %v", err) } if got := dispatchTestSource.deployCount.Load(); got != 1 { t.Fatalf("Deploy ran %d times, want 1", got) } } func TestDispatchPlugin_PreDeployBackup_FailOpen(t *testing.T) { resetFake(t) d := newTestDeployer(t) b := &fakeBackuper{err: errors.New("disk full")} d.SetPreDeployBackuper(b) setAutoBackup(t, d, true) // A failed backup is logged but must NOT block the deploy. if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil { t.Fatalf("deploy must succeed when backup fails (fail-open): %v", err) } if got := dispatchTestSource.deployCount.Load(); got != 1 { t.Fatalf("Deploy ran %d times, want 1 (despite backup failure)", got) } }