github.com/TBD54566975/ftl@v0.219.0/integration/actions_test.go (about) 1 //go:build integration 2 3 package simple_test 4 5 import ( 6 "bytes" 7 "database/sql" 8 "encoding/json" 9 "fmt" 10 "io" 11 "net/http" 12 "net/url" 13 "os" 14 "path/filepath" 15 "strings" 16 "testing" 17 "time" 18 19 "connectrpc.com/connect" 20 "github.com/alecthomas/assert/v2" 21 _ "github.com/jackc/pgx/v5/stdlib" // SQL driver 22 "github.com/kballard/go-shellquote" 23 "github.com/otiai10/copy" 24 25 ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" 26 schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" 27 ftlexec "github.com/TBD54566975/ftl/internal/exec" 28 "github.com/TBD54566975/ftl/internal/log" 29 "github.com/TBD54566975/scaffolder" 30 ) 31 32 // scaffold a directory relative to the testdata directory to a directory relative to the working directory. 33 func scaffold(src, dest string, tmplCtx any) action { 34 return func(t testing.TB, ic testContext) error { 35 infof("Scaffolding %s -> %s", src, dest) 36 return scaffolder.Scaffold(filepath.Join(ic.testData, src), filepath.Join(ic.workDir, dest), tmplCtx) 37 } 38 } 39 40 // Copy a module from the testdata directory to the working directory. 41 // 42 // Ensures that replace directives are correctly handled. 43 func copyModule(module string) action { 44 return chain( 45 copyDir(module, module), 46 func(t testing.TB, ic testContext) error { 47 return ftlexec.Command(ic, log.Debug, filepath.Join(ic.workDir, module), "go", "mod", "edit", "-replace", "github.com/TBD54566975/ftl="+ic.rootDir).RunBuffered(ic) 48 }, 49 ) 50 } 51 52 // Copy a directory from the testdata directory to the working directory. 53 func copyDir(src, dest string) action { 54 return func(t testing.TB, ic testContext) error { 55 infof("Copying %s -> %s", src, dest) 56 return copy.Copy(filepath.Join(ic.testData, src), filepath.Join(ic.workDir, dest)) 57 } 58 } 59 60 // chain multiple actions together. 61 func chain(actions ...action) action { 62 return func(t testing.TB, ic testContext) error { 63 for _, action := range actions { 64 err := action(t, ic) 65 if err != nil { 66 return err 67 } 68 } 69 return nil 70 } 71 } 72 73 // chdir changes the test working directory to the subdirectory for the duration of the action. 74 func chdir(dir string, a action) action { 75 return func(t testing.TB, ic testContext) error { 76 dir := filepath.Join(ic.workDir, dir) 77 infof("Changing directory to %s", dir) 78 cwd, err := os.Getwd() 79 if err != nil { 80 return err 81 } 82 ic.workDir = dir 83 err = os.Chdir(dir) 84 if err != nil { 85 return err 86 } 87 defer os.Chdir(cwd) 88 return a(t, ic) 89 } 90 } 91 92 // debugShell opens a new Terminal window in the test working directory. 93 func debugShell() action { 94 return func(t testing.TB, ic testContext) error { 95 infof("Starting debug shell") 96 return ftlexec.Command(ic, log.Debug, ic.workDir, "open", "-n", "-W", "-a", "Terminal", ".").RunBuffered(ic) 97 } 98 } 99 100 // exec runs a command from the test working directory. 101 func exec(cmd string, args ...string) action { 102 return func(t testing.TB, ic testContext) error { 103 infof("Executing: %s %s", cmd, shellquote.Join(args...)) 104 err := ftlexec.Command(ic, log.Debug, ic.workDir, cmd, args...).RunBuffered(ic) 105 if err != nil { 106 return err 107 } 108 return nil 109 } 110 } 111 112 // execWithOutput runs a command from the test working directory. 113 // The output is captured and is returned as part of the error. 114 func execWithOutput(cmd string, args ...string) action { 115 return func(t testing.TB, ic testContext) error { 116 infof("Executing: %s %s", cmd, shellquote.Join(args...)) 117 output, err := ftlexec.Capture(ic, ic.workDir, cmd, args...) 118 if err != nil { 119 return fmt.Errorf("command execution failed: %s, output: %s", err, string(output)) 120 } 121 return nil 122 } 123 } 124 125 // expectError wraps an action and expects it to return an error with the given message. 126 func expectError(action action, expectedErrorMsg string) action { 127 return func(t testing.TB, ic testContext) error { 128 err := action(t, ic) 129 if err == nil { 130 return fmt.Errorf("expected error %q, but got nil", expectedErrorMsg) 131 } 132 if !strings.Contains(err.Error(), expectedErrorMsg) { 133 return fmt.Errorf("expected error %q, but got %q", expectedErrorMsg, err.Error()) 134 } 135 return nil 136 } 137 } 138 139 // Deploy a module from the working directory and wait for it to become available. 140 func deploy(module string) action { 141 return chain( 142 exec("ftl", "deploy", module), 143 wait(module), 144 ) 145 } 146 147 // Build modules from the working directory and wait for it to become available. 148 func build(modules ...string) action { 149 args := []string{"build"} 150 args = append(args, modules...) 151 return exec("ftl", args...) 152 } 153 154 // wait for the given module to deploy. 155 func wait(module string) action { 156 return func(t testing.TB, ic testContext) error { 157 infof("Waiting for %s to become ready", module) 158 ic.AssertWithRetry(t, func(t testing.TB, ic testContext) error { 159 status, err := ic.controller.Status(ic, connect.NewRequest(&ftlv1.StatusRequest{})) 160 if err != nil { 161 return err 162 } 163 for _, deployment := range status.Msg.Deployments { 164 if deployment.Name == module { 165 return nil 166 } 167 } 168 return fmt.Errorf("deployment of module %q not found", module) 169 }) 170 return nil 171 } 172 } 173 174 func sleep(duration time.Duration) action { 175 return func(t testing.TB, ic testContext) error { 176 infof("Sleeping for %s", duration) 177 time.Sleep(duration) 178 return nil 179 } 180 } 181 182 // Assert that a file exists in the working directory. 183 func fileExists(path string) action { 184 return func(t testing.TB, ic testContext) error { 185 infof("Checking that %s exists", path) 186 _, err := os.Stat(filepath.Join(ic.workDir, path)) 187 return err 188 } 189 } 190 191 // Assert that a file exists in the working directory and contains the given text. 192 func fileContains(path, content string) action { 193 return func(t testing.TB, ic testContext) error { 194 infof("Checking that %s contains %q", path, content) 195 data, err := os.ReadFile(filepath.Join(ic.workDir, path)) 196 if err != nil { 197 return err 198 } 199 if !strings.Contains(string(data), content) { 200 return fmt.Errorf("%q not found in %q", content, string(data)) 201 } 202 return nil 203 } 204 } 205 206 type obj map[string]any 207 208 // Call a verb. 209 func call(module, verb string, request obj, check func(response obj) error) action { 210 return func(t testing.TB, ic testContext) error { 211 infof("Calling %s.%s", module, verb) 212 data, err := json.Marshal(request) 213 if err != nil { 214 return fmt.Errorf("failed to marshal request: %w", err) 215 } 216 resp, err := ic.verbs.Call(ic, connect.NewRequest(&ftlv1.CallRequest{ 217 Verb: &schemapb.Ref{Module: module, Name: verb}, 218 Body: data, 219 })) 220 if err != nil { 221 return fmt.Errorf("failed to call verb: %w", err) 222 } 223 var response obj 224 if resp.Msg.GetError() != nil { 225 return fmt.Errorf("verb failed: %s", resp.Msg.GetError().GetMessage()) 226 } 227 err = json.Unmarshal(resp.Msg.GetBody(), &response) 228 if err != nil { 229 return fmt.Errorf("failed to unmarshal response: %w", err) 230 } 231 return check(response) 232 } 233 } 234 235 // Query a single row from a database. 236 func queryRow(database string, query string, expected ...interface{}) action { 237 return func(t testing.TB, ic testContext) error { 238 infof("Querying %s: %s", database, query) 239 db, err := sql.Open("pgx", fmt.Sprintf("postgres://postgres:secret@localhost:54320/%s?sslmode=disable", database)) 240 if err != nil { 241 return err 242 } 243 defer db.Close() 244 actual := make([]any, len(expected)) 245 for i := range actual { 246 actual[i] = new(any) 247 } 248 err = db.QueryRowContext(ic, query).Scan(actual...) 249 if err != nil { 250 return err 251 } 252 for i := range actual { 253 actual[i] = *actual[i].(*any) 254 } 255 for i, a := range actual { 256 if a != expected[i] { 257 return fmt.Errorf("expected %v, got %v", expected, actual) 258 } 259 } 260 return nil 261 } 262 } 263 264 // Create a database for use by a module. 265 func createDBAction(module, dbName string, isTest bool) action { 266 return func(t testing.TB, ic testContext) error { 267 createDB(t, module, dbName, isTest) 268 return nil 269 } 270 } 271 272 func createDB(t testing.TB, module, dbName string, isTestDb bool) { 273 // insert test suffix if needed when actually setting up db 274 if isTestDb { 275 dbName += "_test" 276 } 277 infof("Creating database %s", dbName) 278 db, err := sql.Open("pgx", "postgres://postgres:secret@localhost:54320/ftl?sslmode=disable") 279 assert.NoError(t, err, "failed to open database connection") 280 t.Cleanup(func() { 281 err := db.Close() 282 assert.NoError(t, err) 283 }) 284 285 err = db.Ping() 286 assert.NoError(t, err, "failed to ping database") 287 288 _, err = db.Exec("DROP DATABASE IF EXISTS " + dbName) 289 assert.NoError(t, err, "failed to delete existing database") 290 291 _, err = db.Exec("CREATE DATABASE " + dbName) 292 assert.NoError(t, err, "failed to create database") 293 294 t.Cleanup(func() { 295 // Terminate any dangling connections. 296 _, err := db.Exec(` 297 SELECT pid, pg_terminate_backend(pid) 298 FROM pg_stat_activity 299 WHERE datname = $1 AND pid <> pg_backend_pid()`, 300 dbName) 301 assert.NoError(t, err) 302 _, err = db.Exec("DROP DATABASE " + dbName) 303 assert.NoError(t, err) 304 }) 305 } 306 307 // Create a directory in the working directory 308 func mkdir(dir string) action { 309 return func(t testing.TB, ic testContext) error { 310 infof("Creating directory %s", dir) 311 return os.MkdirAll(filepath.Join(ic.workDir, dir), 0700) 312 } 313 } 314 315 type httpResponse struct { 316 status int 317 headers map[string][]string 318 jsonBody map[string]any 319 bodyBytes []byte 320 } 321 322 func jsonData(t testing.TB, body interface{}) []byte { 323 b, err := json.Marshal(body) 324 assert.NoError(t, err) 325 return b 326 } 327 328 // httpCall makes an HTTP call to the running FTL ingress endpoint. 329 func httpCall(method string, path string, body []byte, onResponse func(resp *httpResponse) error) action { 330 return func(t testing.TB, ic testContext) error { 331 infof("HTTP %s %s", method, path) 332 baseURL, err := url.Parse(fmt.Sprintf("http://localhost:8892/ingress")) 333 if err != nil { 334 return err 335 } 336 337 r, err := http.NewRequestWithContext(ic, method, baseURL.JoinPath(path).String(), bytes.NewReader(body)) 338 if err != nil { 339 return err 340 } 341 342 r.Header.Add("Content-Type", "application/json") 343 344 client := http.Client{} 345 resp, err := client.Do(r) 346 if err != nil { 347 return err 348 } 349 defer resp.Body.Close() 350 351 bodyBytes, err := io.ReadAll(resp.Body) 352 if err != nil { 353 return err 354 } 355 356 var resBody map[string]any 357 // ignore the error here since some responses are just `[]byte`. 358 _ = json.Unmarshal(bodyBytes, &resBody) 359 360 return onResponse(&httpResponse{ 361 status: resp.StatusCode, 362 headers: resp.Header, 363 jsonBody: resBody, 364 bodyBytes: bodyBytes, 365 }) 366 } 367 } 368 369 func testModule(module string) action { 370 return chdir(module, exec("go", "test", "-v", ".")) 371 }