package deploy_test import ( "context" "errors" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/notify" ) func setupDeployService(t *testing.T) (*deploy.Service, *database.Database) { t.Helper() tmpDir := t.TempDir() globals.SetAppname("upaas-test") globals.SetVersion("test") globalsInst, err := globals.New(fx.Lifecycle(nil)) require.NoError(t, err) loggerInst, err := logger.New(fx.Lifecycle(nil), logger.Params{Globals: globalsInst}) require.NoError(t, err) cfg := &config.Config{Port: 8080, DataDir: tmpDir, SessionSecret: "test-secret-key-at-least-32-chars"} dbInst, err := database.New(fx.Lifecycle(nil), database.Params{Logger: loggerInst, Config: cfg}) require.NoError(t, err) dockerClient, err := docker.New(fx.Lifecycle(nil), docker.Params{Logger: loggerInst, Config: cfg}) require.NoError(t, err) notifySvc, err := notify.New(fx.Lifecycle(nil), notify.ServiceParams{Logger: loggerInst}) require.NoError(t, err) svc, err := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{ Logger: loggerInst, Config: cfg, Database: dbInst, Docker: dockerClient, Notify: notifySvc, }) require.NoError(t, err) return svc, dbInst } func createDeployTestApp(t *testing.T, dbInst *database.Database) *models.App { t.Helper() app := models.NewApp(dbInst) app.ID = "test-cancel-app" app.Name = "test-cancel-app" app.RepoURL = "git@example.com:user/repo.git" app.Branch = "main" app.DockerfilePath = "Dockerfile" app.SSHPrivateKey = "private-key" app.SSHPublicKey = "public-key" app.Status = models.AppStatusPending err := app.Save(context.Background()) require.NoError(t, err) return app } func TestConcurrentDeploysOnSameApp(t *testing.T) { t.Parallel() svc, dbInst := setupDeployService(t) app := createDeployTestApp(t, dbInst) // Run two deploys concurrently - both will fail (no docker) but we verify // the lock mechanism works (no panics, no data races) var wg sync.WaitGroup results := make([]error, 2) for i := range 2 { wg.Add(1) go func(idx int) { defer wg.Done() results[idx] = svc.Deploy(context.Background(), app, nil) }(i) } wg.Wait() // At least one should have run (and failed due to docker not connected) // The other either ran too or got ErrDeploymentInProgress gotInProgress := false gotOther := false for _, err := range results { require.Error(t, err) if errors.Is(err, deploy.ErrDeploymentInProgress) { gotInProgress = true } else { gotOther = true } } // At least one must have actually attempted the deploy assert.True(t, gotOther, "at least one deploy should have attempted execution") // If timing worked out, one should have been rejected // (but this is racy - both might complete sequentially) _ = gotInProgress } func TestCancelAppDeployReturnsFalseWhenNoDeploy(t *testing.T) { t.Parallel() svc, _ := setupDeployService(t) // No deploy in progress, should return false cancelled := svc.CancelAppDeploy("nonexistent-app") assert.False(t, cancelled) } func TestCancelAppDeployCancelsInProgressDeploy(t *testing.T) { t.Parallel() svc, dbInst := setupDeployService(t) app := createDeployTestApp(t, dbInst) deployStarted := make(chan struct{}) deployDone := make(chan error, 1) // Start a deploy that will hold the lock go func() { // Signal when deploy starts (will acquire lock quickly) close(deployStarted) deployDone <- svc.Deploy(context.Background(), app, nil) }() <-deployStarted // Give it time to acquire the lock and register the cancel func time.Sleep(100 * time.Millisecond) // Cancel should work if deploy is still in progress _ = svc.CancelAppDeploy(app.ID) // Wait for deploy to finish select { case <-deployDone: // Deploy finished (either cancelled or failed for other reason) case <-time.After(5 * time.Second): t.Fatal("deploy did not finish after cancellation") } } func TestErrDeployCancelledExists(t *testing.T) { t.Parallel() // Verify the sentinel error exists require.Error(t, deploy.ErrDeployCancelled) assert.Equal(t, "deployment cancelled by newer deploy", deploy.ErrDeployCancelled.Error()) }