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  }