Repository: supabase/cli
Pull request #461 (2022-09-16 08:57:15)

chore: add tests for function deploy
## What kind of change does this PR introduce?

tests

## What is the new behavior?

- refactored deno path to utils
- pass in cancellation context
- extracted string and regex constants
- switch to codegen api client


coveralls: ## Pull Request Test Coverage Report for [Build 3066626349](https://coveralls.io/builds/52528449)

* **0** of **68**   **(0.0%)**  changed or added relevant lines in **3** files are covered.
* No unchanged relevant lines lost coverage.
* Overall coverage increased (+**0.4%**) to **33.253%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [cmd/functions.go](https://coveralls.io/builds/52528449/source?filename=cmd%2Ffunctions.go#L51) | 0 | 2 | 0.0%
| [internal/utils/misc.go](https://coveralls.io/builds/52528449/source?filename=internal%2Futils%2Fmisc.go#L247) | 0 | 25 | 0.0%
| [internal/functions/deploy/deploy.go](https://coveralls.io/builds/52528449/source?filename=internal%2Ffunctions%2Fdeploy%2Fdeploy.go#L21) | 0 | 41 | 0.0%
<!-- | **Total:** | **0** | **68** | **0.0%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/52528449/badge)](https://coveralls.io/builds/52528449) |
| :-- | --: |
| Change from base [Build 3055176431](https://coveralls.io/builds/52475303): |  0.4% |
| Covered Lines: | 1662 |
| Relevant Lines: | 4998 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/cmd/functions.go b/cmd/functions.go
index 519e0663..7272134b 100644
--- a/cmd/functions.go
+++ b/cmd/functions.go
@@ -1,6 +1,10 @@
 package cmd
 
 import (
+	"os"
+	"os/signal"
+
+	"github.com/spf13/afero"
 	"github.com/spf13/cobra"
 	"github.com/supabase/cli/internal/functions/delete"
 	"github.com/supabase/cli/internal/functions/deploy"
@@ -44,7 +48,8 @@ var (
 				return err
 			}
 
-			return deploy.Run(args[0], projectRef, !noVerifyJWT)
+			ctx, _ := signal.NotifyContext(cmd.Context(), os.Interrupt)
+			return deploy.Run(ctx, args[0], projectRef, !noVerifyJWT, afero.NewOsFs())
 		},
 	}
 
diff --git a/internal/functions/deploy/deploy.go b/internal/functions/deploy/deploy.go
index 0678dcfc..26b9844c 100644
--- a/internal/functions/deploy/deploy.go
+++ b/internal/functions/deploy/deploy.go
@@ -3,34 +3,34 @@ package deploy
 import (
 	"bufio"
 	"bytes"
-	"encoding/json"
+	"context"
 	"errors"
 	"fmt"
-	"io"
 	"net/http"
 	"os"
 	"os/exec"
 	"path/filepath"
-	"runtime"
 	"strings"
 
 	"github.com/spf13/afero"
 	"github.com/supabase/cli/internal/login"
 	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
 )
 
-func Run(slug string, projectRefArg string, verifyJWT bool) error {
+func Run(ctx context.Context, slug string, projectRefArg string, verifyJWT bool, fsys afero.Fs) error {
 	// 1. Sanity checks.
+	projectRef := projectRefArg
 	{
-		if _, err := utils.LoadAccessToken(); err != nil && strings.HasPrefix(err.Error(), "Access token not provided. Supply an access token by running") {
-			if err := login.Run(os.Stdin, afero.NewOsFs()); err != nil {
+		if _, err := utils.LoadAccessTokenFS(fsys); err != nil && strings.HasPrefix(err.Error(), "Access token not provided. Supply an access token by running") {
+			if err := login.Run(os.Stdin, fsys); err != nil {
 				return err
 			}
 		} else if err != nil {
 			return err
 		}
 		if len(projectRefArg) == 0 {
-			if err := utils.AssertIsLinked(); err != nil && strings.HasPrefix(err.Error(), "Cannot find project ref. Have you run") {
+			if err := utils.AssertIsLinkedFS(fsys); err != nil && strings.HasPrefix(err.Error(), "Cannot find project ref. Have you run") {
 				fmt.Printf(`You can find your project ref from the project's dashboard home page, e.g. %s/project/<project-ref>.
 Enter your project ref: `, utils.GetSupabaseDashboardURL())
 
@@ -40,23 +40,23 @@ Enter your project ref: `, utils.GetSupabaseDashboardURL())
 					return nil
 				}
 
-				projectRef := strings.TrimSpace(scanner.Text())
-
-				if err := utils.MkdirIfNotExist(filepath.Dir(utils.ProjectRefPath)); err != nil {
-					return err
-				}
-				if err := os.WriteFile(utils.ProjectRefPath, []byte(projectRef), 0644); err != nil {
-					return err
-				}
+				projectRef = strings.TrimSpace(scanner.Text())
 			} else if err != nil {
 				return err
 			}
-		} else {
-			if !utils.ProjectRefPattern.MatchString(projectRefArg) {
-				return errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.")
+		}
+		if !utils.ProjectRefPattern.MatchString(projectRef) {
+			return errors.New("Invalid project ref format. Must be like `abcdefghijklmnopqrst`.")
+		}
+		if len(projectRefArg) == 0 {
+			if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(utils.ProjectRefPath)); err != nil {
+				return err
+			}
+			if err := afero.WriteFile(fsys, utils.ProjectRefPath, []byte(projectRef), 0644); err != nil {
+				return err
 			}
 		}
-		if err := utils.InstallOrUpgradeDeno(); err != nil {
+		if err := utils.InstallOrUpgradeDeno(ctx, fsys); err != nil {
 			return err
 		}
 		if err := utils.ValidateFunctionSlug(slug); err != nil {
@@ -68,28 +68,23 @@ Enter your project ref: `, utils.GetSupabaseDashboardURL())
 	var newFunctionBody string
 	{
 		fmt.Println("Bundling " + utils.Bold(slug))
-
-		home, err := os.UserHomeDir()
+		denoPath, err := utils.GetDenoPath()
 		if err != nil {
 			return err
 		}
-		denoBinName := "deno"
-		if runtime.GOOS == "windows" {
-			denoBinName = "deno.exe"
-		}
-		denoPath := filepath.Join(home, ".supabase", denoBinName)
 
-		functionPath := "supabase/functions/" + slug
-		if _, err := os.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
+		functionPath := filepath.Join(utils.FunctionsDir, slug)
+		if _, err := fsys.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
 			// allow deploy from within supabase/
-			functionPath = "functions/" + slug
-			if _, err := os.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
+			functionPath = filepath.Join("functions", slug)
+			if _, err := fsys.Stat(functionPath); errors.Is(err, os.ErrNotExist) {
 				// allow deploy from current directory
 				functionPath = slug
 			}
 		}
 
-		cmd := exec.Command(denoPath, "bundle", "--no-check=remote", "--quiet", functionPath+"/index.ts")
+		args := []string{"bundle", "--no-check=remote", "--quiet", filepath.Join(functionPath, "index.ts")}
+		cmd := exec.CommandContext(ctx, denoPath, args...)
 		var outBuf, errBuf bytes.Buffer
 		cmd.Stdout = &outBuf
 		cmd.Stderr = &errBuf
@@ -101,115 +96,48 @@ Enter your project ref: `, utils.GetSupabaseDashboardURL())
 	}
 
 	// 3. Deploy new Function.
-	var projectRef string
-	var data struct {
-		Id string `json:"id"`
-	}
+	var deployedFuncId string
 	{
-		// --project-ref overrides value on disk
-		if len(projectRefArg) == 0 {
-			projectRefBytes, err := os.ReadFile(utils.ProjectRefPath)
-			if err != nil {
-				return err
-			}
-			projectRef = string(projectRefBytes)
-		} else {
-			projectRef = projectRefArg
-		}
-
-		accessToken, err := utils.LoadAccessToken()
-		if err != nil {
-			return err
-		}
-
-		req, err := http.NewRequest("GET", utils.GetSupabaseAPIHost()+"/v1/projects/"+projectRef+"/functions/"+slug, nil)
-		if err != nil {
-			return err
-		}
-		req.Header.Add("Authorization", "Bearer "+string(accessToken))
-		resp, err := http.DefaultClient.Do(req)
+		resp, err := utils.GetSupabase().GetFunctionWithResponse(ctx, projectRef, slug, &api.GetFunctionParams{})
 		if err != nil {
 			return err
 		}
-		defer resp.Body.Close()
 
-		switch resp.StatusCode {
+		switch resp.StatusCode() {
 		case http.StatusNotFound: // Function doesn't exist yet, so do a POST
-			jsonBytes, err := json.Marshal(
-				map[string]interface{}{
-					"slug":       slug,
-					"name":       slug,
-					"body":       newFunctionBody,
-					"verify_jwt": verifyJWT,
-				})
-			if err != nil {
-				return err
-			}
-
-			req, err := http.NewRequest(
-				"POST", utils.GetSupabaseAPIHost()+"/v1/projects/"+projectRef+"/functions", bytes.NewReader(jsonBytes))
+			resp, err := utils.GetSupabase().CreateFunctionWithResponse(ctx, projectRef, api.CreateFunctionBody{
+				Body:      newFunctionBody,
+				Name:      slug,
+				Slug:      slug,
+				VerifyJwt: &verifyJWT,
+			})
 			if err != nil {
 				return err
 			}
-			req.Header.Add("Authorization", "Bearer "+string(accessToken))
-			req.Header.Add("Content-Type", "application/json")
-			resp, err := http.DefaultClient.Do(req)
-			if err != nil {
-				return err
-			}
-			defer resp.Body.Close()
-
-			body, err := io.ReadAll(resp.Body)
-			// TODO: remove the StatusOK case after 2022-08-20
-			if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
-				if err != nil {
-					return fmt.Errorf("Failed to create a new Function on the Supabase project: %w", err)
-				}
-				return errors.New("Failed to create a new Function on the Supabase project: " + string(body))
-			}
-			if err := json.Unmarshal(body, &data); err != nil {
-				return fmt.Errorf("Failed to create a new Function on the Supabase project: %w", err)
+			if resp.JSON201 == nil {
+				return errors.New("Failed to create a new Function on the Supabase project: " + string(resp.Body))
 			}
+			deployedFuncId = resp.JSON201.Id
 		case http.StatusOK: // Function already exists, so do a PATCH
-			jsonBytes, err := json.Marshal(map[string]interface{}{"body": newFunctionBody, "verify_jwt": verifyJWT})
+			resp, err := utils.GetSupabase().UpdateFunctionWithResponse(ctx, projectRef, slug, api.UpdateFunctionBody{
+				Body:      &newFunctionBody,
+				VerifyJwt: &verifyJWT,
+			})
 			if err != nil {
 				return err
 			}
-
-			req, err := http.NewRequest(
-				"PATCH", utils.GetSupabaseAPIHost()+"/v1/projects/"+projectRef+"/functions/"+slug, bytes.NewReader(jsonBytes))
-			if err != nil {
-				return err
-			}
-			req.Header.Add("Authorization", "Bearer "+string(accessToken))
-			req.Header.Add("Content-Type", "application/json")
-			resp, err := http.DefaultClient.Do(req)
-			if err != nil {
-				return err
-			}
-			defer resp.Body.Close()
-			body, err := io.ReadAll(resp.Body)
-			if resp.StatusCode != http.StatusOK {
-				if err != nil {
-					return fmt.Errorf("Failed to update an existing Function's body on the Supabase project: %w", err)
-				}
-				return errors.New("Failed to update an existing Function's body on the Supabase project: " + string(body))
-			}
-			if err := json.Unmarshal(body, &data); err != nil {
-				return fmt.Errorf("Failed to update an existing Function's body on the Supabase project: %w", err)
+			if resp.JSON200 == nil {
+				return errors.New("Failed to update an existing Function's body on the Supabase project: " + string(resp.Body))
 			}
+			deployedFuncId = resp.JSON200.Id
 		default:
-			body, err := io.ReadAll(resp.Body)
-			if err != nil {
-				return fmt.Errorf("Unexpected error deploying Function: %w", err)
-			}
-			return errors.New("Unexpected error deploying Function: " + string(body))
+			return errors.New("Unexpected error deploying Function: " + string(resp.Body))
 		}
 	}
 
 	fmt.Println("Deployed Function " + utils.Aqua(slug) + " on project " + utils.Aqua(projectRef))
 
-	url := fmt.Sprintf("%s/project/%v/functions/%v/details", utils.GetSupabaseDashboardURL(), projectRef, data.Id)
+	url := fmt.Sprintf("%s/project/%v/functions/%v/details", utils.GetSupabaseDashboardURL(), projectRef, deployedFuncId)
 	fmt.Println("You can inspect your deployment in the Dashboard: " + url)
 
 	return nil
diff --git a/internal/utils/misc.go b/internal/utils/misc.go
index 33aa0087..e7da746f 100644
--- a/internal/utils/misc.go
+++ b/internal/utils/misc.go
@@ -59,6 +59,7 @@ DO 'BEGIN WHILE (
 	RemoteDbPath   = "supabase/.temp/remote-db-url"
 	CurrBranchPath = "supabase/.branches/_current_branch"
 	MigrationsDir  = "supabase/migrations"
+	FunctionsDir   = "supabase/functions"
 	SeedDataPath   = "supabase/seed.sql"
 )
 
@@ -75,11 +76,17 @@ var (
 	PostgresUrlPattern = regexp.MustCompile(`^postgres(?:ql)?:\/\/postgres:(.*)@(.+)\/postgres$`)
 	MigrateFilePattern = regexp.MustCompile(`([0-9]+)_.*\.sql`)
 	BranchNamePattern  = regexp.MustCompile(`[[:word:]-]+`)
+	FuncSlugPattern    = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`)
 
 	// These schemas are ignored from schema diffs
 	InternalSchemas = []string{"auth", "extensions", "graphql", "graphql_public", "pgbouncer", "pgsodium", "pgsodium_masks", "realtime", "storage", "supabase_functions", "supabase_migrations", "pg_catalog", "pg_toast", "information_schema"}
 )
 
+// Used by unit tests
+var (
+	DenoPathOverride string
+)
+
 func GetCurrentTimestamp() string {
 	// Magic number: https://stackoverflow.com/q/45160822.
 	return time.Now().UTC().Format("20060102150405")
@@ -239,34 +246,40 @@ func LoadProjectRef(fsys afero.Fs) (string, error) {
 	return projectRef, nil
 }
 
-func InstallOrUpgradeDeno() error {
+func GetDenoPath() (string, error) {
+	if len(DenoPathOverride) > 0 {
+		return DenoPathOverride, nil
+	}
 	home, err := os.UserHomeDir()
 	if err != nil {
-		return err
-	}
-	if err := MkdirIfNotExist(filepath.Join(home, ".supabase")); err != nil {
-		return err
+		return "", err
 	}
 	denoBinName := "deno"
 	if runtime.GOOS == "windows" {
 		denoBinName = "deno.exe"
 	}
 	denoPath := filepath.Join(home, ".supabase", denoBinName)
+	return denoPath, nil
+}
 
-	if _, err := os.Stat(denoPath); err == nil {
-		// Upgrade Deno.
-
-		cmd := exec.Command(denoPath, "upgrade", "--version", "1.20.3")
-		if err := cmd.Run(); err != nil {
-			return err
-		}
+func InstallOrUpgradeDeno(ctx context.Context, fsys afero.Fs) error {
+	denoPath, err := GetDenoPath()
+	if err != nil {
+		return err
+	}
 
-		return nil
+	if _, err := fsys.Stat(denoPath); err == nil {
+		// Upgrade Deno.
+		cmd := exec.CommandContext(ctx, denoPath, "upgrade", "--version", "1.20.3")
+		return cmd.Run()
 	} else if !errors.Is(err, os.ErrNotExist) {
 		return err
 	}
 
 	// Install Deno.
+	if err := MkdirIfNotExistFS(fsys, filepath.Dir(denoPath)); err != nil {
+		return err
+	}
 
 	// 1. Determine OS triple
 	var assetFilename string
@@ -291,7 +304,11 @@ func InstallOrUpgradeDeno() error {
 
 	// 2. Download & install Deno binary.
 	{
-		resp, err := http.Get(assetsUrl + assetFilename)
+		req, err := http.NewRequestWithContext(ctx, "GET", assetsUrl+assetFilename, nil)
+		if err != nil {
+			return err
+		}
+		resp, err := http.DefaultClient.Do(req)
 		if err != nil {
 			return err
 		}
@@ -322,7 +339,7 @@ func InstallOrUpgradeDeno() error {
 			return err
 		}
 
-		if err := os.WriteFile(denoPath, denoBytes, 0755); err != nil {
+		if err := afero.WriteFile(fsys, denoPath, denoBytes, 0755); err != nil {
 			return err
 		}
 	}
@@ -360,11 +377,7 @@ func LoadAccessTokenFS(fsys afero.Fs) (string, error) {
 }
 
 func ValidateFunctionSlug(slug string) error {
-	matched, err := regexp.MatchString(`^[A-Za-z][A-Za-z0-9_-]*$`, slug)
-	if err != nil {
-		return err
-	}
-	if !matched {
+	if !FuncSlugPattern.MatchString(slug) {
 		return errors.New("Invalid Function name. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens. (^[A-Za-z][A-Za-z0-9_-]*$)")
 	}
 


Test patch:
diff --git a/internal/functions/deploy/deploy_test.go b/internal/functions/deploy/deploy_test.go
new file mode 100644
index 00000000..08d208c5
--- /dev/null
+++ b/internal/functions/deploy/deploy_test.go
@@ -0,0 +1,86 @@
+package deploy
+
+import (
+	"context"
+	"log"
+	"net/http"
+	"os"
+	"testing"
+
+	"github.com/spf13/afero"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/cli/internal/testing/apitest"
+	"github.com/supabase/cli/internal/utils"
+	"github.com/supabase/cli/pkg/api"
+	"gopkg.in/h2non/gock.v1"
+)
+
+func TestMain(m *testing.M) {
+	// Setup fake deno binary
+	if len(os.Args) > 1 && (os.Args[1] == "bundle" || os.Args[1] == "upgrade") {
+		os.Exit(0)
+	}
+	denoPath, err := os.Executable()
+	if err != nil {
+		log.Fatalln(err)
+	}
+	utils.DenoPathOverride = denoPath
+	// Run test suite
+	os.Exit(m.Run())
+}
+
+func TestDeployCommand(t *testing.T) {
+	t.Run("deploys new function", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Setup valid project ref
+		project := apitest.RandomProjectRef()
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+		// Setup valid deno path
+		_, err := fsys.Create(utils.DenoPathOverride)
+		require.NoError(t, err)
+		// Setup mock api
+		defer gock.OffAll()
+		gock.New("https://api.supabase.io").
+			Get("/v1/projects/" + project + "/functions/test-func").
+			Reply(http.StatusNotFound)
+		gock.New("https://api.supabase.io").
+			Post("/v1/projects/" + project + "/functions").
+			Reply(http.StatusCreated).
+			JSON(api.FunctionResponse{Id: "1"})
+		// Run test
+		assert.NoError(t, Run(context.Background(), "test-func", project, false, fsys))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+
+	t.Run("updates deployed function", func(t *testing.T) {
+		// Setup in-memory fs
+		fsys := afero.NewMemMapFs()
+		// Setup valid project ref
+		project := apitest.RandomProjectRef()
+		// Setup valid access token
+		token := apitest.RandomAccessToken(t)
+		t.Setenv("SUPABASE_ACCESS_TOKEN", string(token))
+		// Setup valid deno path
+		_, err := fsys.Create(utils.DenoPathOverride)
+		require.NoError(t, err)
+		// Setup mock api
+		defer gock.OffAll()
+		gock.New("https://api.supabase.io").
+			Get("/v1/projects/" + project + "/functions/test-func").
+			Reply(http.StatusOK).
+			JSON(api.FunctionResponse{Id: "1"})
+		gock.New("https://api.supabase.io").
+			Patch("/v1/projects/" + project + "/functions").
+			Reply(http.StatusOK).
+			JSON(api.FunctionResponse{Id: "1"})
+		// Run test
+		assert.NoError(t, Run(context.Background(), "test-func", project, false, fsys))
+		// Validate api
+		assert.Empty(t, apitest.ListUnmatchedRequests())
+	})
+}