From 0812a5ba540027636bdf483661c67c4e2a1c2ee2 Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 01:36:41 +0100 Subject: [PATCH 01/12] chore: add parse db url tests --- internal/utils/flags/db_url_test.go | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 internal/utils/flags/db_url_test.go diff --git a/internal/utils/flags/db_url_test.go b/internal/utils/flags/db_url_test.go new file mode 100644 index 0000000000..0742c8f462 --- /dev/null +++ b/internal/utils/flags/db_url_test.go @@ -0,0 +1,114 @@ +package flags + +import ( + "os" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" +) + +func TestParseDatabaseConfig(t *testing.T) { + t.Run("parses direct connection from db-url flag", func(t *testing.T) { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("db-url", "postgres://postgres:password@localhost:5432/postgres", "") + flagSet.Set("db-url", "postgres://admin:secret@db.example.com:6432/app") + + fsys := afero.NewMemMapFs() + + err := ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, "db.example.com", DbConfig.Host) + assert.Equal(t, uint16(6432), DbConfig.Port) + assert.Equal(t, "admin", DbConfig.User) + assert.Equal(t, "secret", DbConfig.Password) + assert.Equal(t, "app", DbConfig.Database) + }) + + t.Run("parses local connection", func(t *testing.T) { + + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.Bool("local", false, "") + flagSet.Set("local", "true") + + fsys := afero.NewMemMapFs() + + utils.Config.Hostname = "localhost" + utils.Config.Db.Port = 54322 + utils.Config.Db.Password = "local-password" + + err := ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, "localhost", DbConfig.Host) + assert.Equal(t, uint16(54322), DbConfig.Port) + assert.Equal(t, "postgres", DbConfig.User) + assert.Equal(t, "local-password", DbConfig.Password) + assert.Equal(t, "postgres", DbConfig.Database) + }) + + t.Run("parses linked connection", func(t *testing.T) { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.Bool("linked", false, "") + flagSet.Set("linked", "true") + + fsys := afero.NewMemMapFs() + + project := apitest.RandomProjectRef() + err := afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) + require.NoError(t, err) + + err = ParseDatabaseConfig(flagSet, fsys) + + assert.NoError(t, err) + assert.Equal(t, utils.GetSupabaseDbHost(project), DbConfig.Host) + }) +} + +func TestPromptPassword(t *testing.T) { + t.Run("returns user input when provided", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + go func() { + defer w.Close() + w.Write([]byte("test-password")) + }() + + password := PromptPassword(r) + + assert.Equal(t, "test-password", password) + }) + + t.Run("generates password when input is empty", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + go func() { + defer w.Close() + w.Write([]byte("")) + }() + + password := PromptPassword(r) + + assert.Len(t, password, PASSWORD_LENGTH) + assert.NotEqual(t, "", password) + }) +} + +func TestGetDbConfigOptionalPassword(t *testing.T) { + t.Run("uses environment variable when available", func(t *testing.T) { + viper.Set("DB_PASSWORD", "env-password") + projectRef := apitest.RandomProjectRef() + + config := GetDbConfigOptionalPassword(projectRef) + + assert.Equal(t, "env-password", config.Password) + }) +} From e31e04c1833c92c4a91157f5ab45281f9e5e6017 Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 01:47:49 +0100 Subject: [PATCH 02/12] chore: add cloudflare basic tests --- internal/utils/cloudflare/dns_test.go | 128 ++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 internal/utils/cloudflare/dns_test.go diff --git a/internal/utils/cloudflare/dns_test.go b/internal/utils/cloudflare/dns_test.go new file mode 100644 index 0000000000..ee4d00c299 --- /dev/null +++ b/internal/utils/cloudflare/dns_test.go @@ -0,0 +1,128 @@ +package cloudflare + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" +) + +func TestDNSQuery(t *testing.T) { + t.Run("successfully queries A records", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + MatchHeader("accept", "application/dns-json"). + Reply(http.StatusOK). + JSON(DNSResponse{ + Answer: []DNSAnswer{ + { + Name: "example.com", + Type: TypeA, + Ttl: 300, + Data: "93.184.216.34", + }, + }, + }) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.NoError(t, err) + assert.Len(t, resp.Answer, 1) + assert.Equal(t, "93.184.216.34", resp.Answer[0].Data) + assert.Equal(t, TypeA, resp.Answer[0].Type) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("successfully queries specific DNS type", func(t *testing.T) { + api := NewCloudflareAPI() + dnsType := TypeCNAME + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "www.example.com"). + MatchParam("type", "5"). // TypeCNAME = 5 + MatchHeader("accept", "application/dns-json"). + Reply(http.StatusOK). + JSON(DNSResponse{ + Answer: []DNSAnswer{ + { + Name: "www.example.com", + Type: TypeCNAME, + Ttl: 3600, + Data: "example.com", + }, + }, + }) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "www.example.com", + Type: &dnsType, + }) + + assert.NoError(t, err) + assert.Len(t, resp.Answer, 1) + assert.Equal(t, "example.com", resp.Answer[0].Data) + assert.Equal(t, TypeCNAME, resp.Answer[0].Type) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles network error", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + ReplyError(gock.ErrCannotMatch) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.Error(t, err) + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles service unavailable", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + Reply(http.StatusServiceUnavailable) + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.ErrorContains(t, err, "status 503") + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles malformed response", func(t *testing.T) { + api := NewCloudflareAPI() + defer gock.OffAll() + gock.New("https://1.1.1.1"). + Get("/dns-query"). + MatchParam("name", "example.com"). + Reply(http.StatusOK). + JSON("invalid json") + + resp, err := api.DNSQuery(context.Background(), DNSParams{ + Name: "example.com", + }) + + assert.ErrorContains(t, err, "failed to parse") + assert.Empty(t, resp.Answer) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} From d624fadd417dd15c68825db2f61a0629035342df Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 02:04:12 +0100 Subject: [PATCH 03/12] chore: add cred store tests --- internal/utils/credentials/store_test.go | 171 +++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 internal/utils/credentials/store_test.go diff --git a/internal/utils/credentials/store_test.go b/internal/utils/credentials/store_test.go new file mode 100644 index 0000000000..f13dc99081 --- /dev/null +++ b/internal/utils/credentials/store_test.go @@ -0,0 +1,171 @@ +package credentials + +import ( + "os" + "testing" + + "github.com/go-errors/errors" + "github.com/stretchr/testify/assert" + "github.com/zalando/go-keyring" +) + +func TestKeyringStore(t *testing.T) { + t.Run("stores and retrieves password", func(t *testing.T) { + defer MockInit()() + project := "test-project" + password := "test-password" + + err := StoreProvider.Set(project, password) + assert.NoError(t, err) + + retrieved, err := StoreProvider.Get(project) + assert.NoError(t, err) + assert.Equal(t, password, retrieved) + }) + + t.Run("returns error for non-existent project", func(t *testing.T) { + defer MockInit()() + project := "non-existent" + + retrieved, err := StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + assert.Empty(t, retrieved) + }) + + t.Run("deletes specific project password", func(t *testing.T) { + defer MockInit()() + project := "test-project" + password := "test-password" + + err := StoreProvider.Set(project, password) + assert.NoError(t, err) + err = StoreProvider.Delete(project) + assert.NoError(t, err) + + _, err = StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + }) + + t.Run("deletes all project passwords", func(t *testing.T) { + defer MockInit()() + projects := []string{"project1", "project2"} + + for _, project := range projects { + err := StoreProvider.Set(project, "password") + assert.NoError(t, err) + } + + err := StoreProvider.DeleteAll() + assert.NoError(t, err) + + for _, project := range projects { + _, err := StoreProvider.Get(project) + assert.ErrorIs(t, err, keyring.ErrNotFound) + } + }) +} + +func setupWSLEnvironment(t *testing.T) func() { + tmpFile, err := os.CreateTemp("", "osrelease") + assert.NoError(t, err) + + _, err = tmpFile.WriteString("Linux version 5.10.16.3-microsoft-standard-WSL2") + assert.NoError(t, err) + + oldProcPath := "/proc/sys/kernel/osrelease" + if err := os.Rename(oldProcPath, oldProcPath+".bak"); err == nil { + // Only setup symlink if we can backup original + err = os.Symlink(tmpFile.Name(), oldProcPath) + if err != nil { + t.Skip("Cannot create symlink for testing WSL detection") + } + } else { + t.Skip("Cannot backup original osrelease file") + } + + return func() { + os.Remove(tmpFile.Name()) + os.Remove(oldProcPath) + os.Rename(oldProcPath+".bak", oldProcPath) + } +} + +func TestWSLSupport(t *testing.T) { + t.Run("Get returns not supported in WSL", func(t *testing.T) { + cleanup := setupWSLEnvironment(t) + defer cleanup() + + store := &KeyringStore{} + _, err := store.Get("test") + assert.ErrorIs(t, err, ErrNotSupported) + }) + + t.Run("Set returns not supported in WSL", func(t *testing.T) { + cleanup := setupWSLEnvironment(t) + defer cleanup() + + store := &KeyringStore{} + err := store.Set("test", "pass") + assert.ErrorIs(t, err, ErrNotSupported) + }) + + t.Run("Delete returns not supported in WSL", func(t *testing.T) { + cleanup := setupWSLEnvironment(t) + defer cleanup() + + store := &KeyringStore{} + err := store.Delete("test") + assert.ErrorIs(t, err, ErrNotSupported) + }) + + t.Run("DeleteAll returns not supported in WSL", func(t *testing.T) { + cleanup := setupWSLEnvironment(t) + defer cleanup() + + store := &KeyringStore{} + err := store.DeleteAll() + assert.ErrorIs(t, err, ErrNotSupported) + }) +} + +func TestKeyringErrors(t *testing.T) { + t.Run("handles Get error", func(t *testing.T) { + oldStore := StoreProvider + defer func() { StoreProvider = oldStore }() + mockErr := errors.New("mock error") + StoreProvider = &mockProvider{mockError: mockErr} + + _, err := StoreProvider.Get("test") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles Set error", func(t *testing.T) { + oldStore := StoreProvider + defer func() { StoreProvider = oldStore }() + mockErr := errors.New("mock error") + StoreProvider = &mockProvider{mockError: mockErr} + + err := StoreProvider.Set("test", "pass") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles Delete error", func(t *testing.T) { + oldStore := StoreProvider + defer func() { StoreProvider = oldStore }() + mockErr := errors.New("mock error") + StoreProvider = &mockProvider{mockError: mockErr} + + err := StoreProvider.Delete("test") + assert.ErrorIs(t, err, mockErr) + }) + + t.Run("handles DeleteAll error", func(t *testing.T) { + oldStore := StoreProvider + defer func() { StoreProvider = oldStore }() + mockErr := errors.New("mock error") + StoreProvider = &mockProvider{mockError: mockErr} + + err := StoreProvider.DeleteAll() + assert.ErrorIs(t, err, mockErr) + }) +} From 70dc74d60e7aea49f9ae6f48d26794e83d8fd7d3 Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 02:15:29 +0100 Subject: [PATCH 04/12] chore: add some tenant keys tests --- internal/utils/tenant/client_test.go | 151 +++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 internal/utils/tenant/client_test.go diff --git a/internal/utils/tenant/client_test.go b/internal/utils/tenant/client_test.go new file mode 100644 index 0000000000..6057f502f2 --- /dev/null +++ b/internal/utils/tenant/client_test.go @@ -0,0 +1,151 @@ +package tenant + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +func TestApiKey(t *testing.T) { + t.Run("creates api key from response", func(t *testing.T) { + resp := []api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + {Name: "service_role", ApiKey: "service-key"}, + } + + keys := NewApiKey(resp) + + assert.Equal(t, "anon-key", keys.Anon) + assert.Equal(t, "service-key", keys.ServiceRole) + assert.False(t, keys.IsEmpty()) + }) + + t.Run("handles empty response", func(t *testing.T) { + resp := []api.ApiKeyResponse{} + + keys := NewApiKey(resp) + + assert.Empty(t, keys.Anon) + assert.Empty(t, keys.ServiceRole) + assert.True(t, keys.IsEmpty()) + }) + + t.Run("handles partial response", func(t *testing.T) { + resp := []api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + } + + keys := NewApiKey(resp) + + assert.Equal(t, "anon-key", keys.Anon) + assert.Empty(t, keys.ServiceRole) + assert.False(t, keys.IsEmpty()) + }) +} + +func TestGetApiKeys(t *testing.T) { + t.Run("retrieves api keys successfully", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{ + {Name: "anon", ApiKey: "anon-key"}, + {Name: "service_role", ApiKey: "service-key"}, + }) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.NoError(t, err) + assert.Equal(t, "anon-key", keys.Anon) + assert.Equal(t, "service-key", keys.ServiceRole) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles network error", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + ReplyError(gock.ErrCannotMatch) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get api keys") + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles unauthorized error", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusUnauthorized). + JSON(map[string]string{"message": "unauthorized"}) + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.ErrorIs(t, err, ErrAuthToken) + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles missing anon key", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{}) // should this error if response has only service_role key? + + keys, err := GetApiKeys(context.Background(), projectRef) + + assert.ErrorIs(t, err, errMissingKey) + assert.True(t, keys.IsEmpty()) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + +func TestNewTenantAPI(t *testing.T) { + t.Run("creates tenant api client", func(t *testing.T) { + projectRef := apitest.RandomProjectRef() + anonKey := "test-key" + + api := NewTenantAPI(context.Background(), projectRef, anonKey) + + assert.NotNil(t, api.Fetcher) + + defer gock.OffAll() + gock.New("https://"+utils.GetSupabaseHost(projectRef)). + Get("/test"). + MatchHeader("apikey", anonKey). + MatchHeader("User-Agent", "SupabaseCLI/"+utils.Version). + Reply(http.StatusOK) + + _, err := api.Send(context.Background(), http.MethodGet, "/test", nil) + + assert.NoError(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} From f68a1b246f95c2b2476cf9b4724f7189054ccc02 Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 03:25:50 +0100 Subject: [PATCH 05/12] chore: more utils tests --- internal/utils/config_test.go | 168 ++++++++++++++++++++++++ internal/utils/container_output_test.go | 166 +++++++++++++++++++++++ internal/utils/deno_test.go | 111 ++++++++++++++++ internal/utils/tenant/database_test.go | 88 +++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 internal/utils/config_test.go create mode 100644 internal/utils/container_output_test.go create mode 100644 internal/utils/tenant/database_test.go diff --git a/internal/utils/config_test.go b/internal/utils/config_test.go new file mode 100644 index 0000000000..9ac835170f --- /dev/null +++ b/internal/utils/config_test.go @@ -0,0 +1,168 @@ +package utils + +import ( + "os" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetId(t *testing.T) { + t.Run("generates container id", func(t *testing.T) { + Config.ProjectId = "test-project" + name := "test-service" + + id := GetId(name) + + assert.Equal(t, "supabase_test-service_test-project", id) + }) +} + +func TestUpdateDockerIds(t *testing.T) { + t.Run("updates all container ids", func(t *testing.T) { + Config.ProjectId = "test-project" + viper.Set("network-id", "custom-network") + defer viper.Reset() + + UpdateDockerIds() + + assert.Equal(t, "custom-network", NetId) + assert.Equal(t, "supabase_db_test-project", DbId) + assert.Equal(t, "supabase_kong_test-project", KongId) + assert.Equal(t, "supabase_auth_test-project", GotrueId) + assert.Equal(t, "supabase_inbucket_test-project", InbucketId) + assert.Equal(t, "supabase_realtime_test-project", RealtimeId) + assert.Equal(t, "supabase_rest_test-project", RestId) + assert.Equal(t, "supabase_storage_test-project", StorageId) + assert.Equal(t, "supabase_imgproxy_test-project", ImgProxyId) + assert.Equal(t, "supabase_differ_test-project", DifferId) + assert.Equal(t, "supabase_pg_meta_test-project", PgmetaId) + assert.Equal(t, "supabase_studio_test-project", StudioId) + assert.Equal(t, "supabase_edge_runtime_test-project", EdgeRuntimeId) + assert.Equal(t, "supabase_analytics_test-project", LogflareId) + assert.Equal(t, "supabase_vector_test-project", VectorId) + assert.Equal(t, "supabase_pooler_test-project", PoolerId) + }) + + t.Run("generates network id if not set", func(t *testing.T) { + Config.ProjectId = "test-project" + viper.Reset() + + UpdateDockerIds() + + assert.Equal(t, "supabase_network_test-project", NetId) + }) +} + +func TestInitConfig(t *testing.T) { + t.Run("creates new config file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + } + + err := InitConfig(params, fsys) + + assert.NoError(t, err) + exists, err := afero.Exists(fsys, ConfigPath) + assert.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("creates config with orioledb", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + UseOrioleDB: true, + } + + err := InitConfig(params, fsys) + + assert.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + assert.NoError(t, err) + assert.Contains(t, string(content), "15.1.0.150") + }) + + t.Run("fails if config exists and no overwrite", func(t *testing.T) { + fsys := afero.NewMemMapFs() + err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) + require.NoError(t, err) + params := InitParams{ + ProjectId: "test-project", + } + + err = InitConfig(params, fsys) + + assert.ErrorIs(t, err, os.ErrExist) + }) + + t.Run("overwrites existing config when specified", func(t *testing.T) { + fsys := afero.NewMemMapFs() + err := afero.WriteFile(fsys, ConfigPath, []byte("existing"), 0644) + require.NoError(t, err) + params := InitParams{ + ProjectId: "test-project", + Overwrite: true, + } + + err = InitConfig(params, fsys) + + assert.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + assert.NoError(t, err) + assert.NotEqual(t, "existing", string(content)) + }) +} + +func TestGetApiUrl(t *testing.T) { + t.Run("returns external url when configured", func(t *testing.T) { + Config.Api.ExternalUrl = "https://api.example.com" + + url := GetApiUrl("/test") + + assert.Equal(t, "https://api.example.com/test", url) + }) + + t.Run("builds url from hostname and port", func(t *testing.T) { + Config.Hostname = "localhost" + Config.Api.Port = 8000 + Config.Api.ExternalUrl = "" + + url := GetApiUrl("/test") + + assert.Equal(t, "http://localhost:8000/test", url) + }) +} + +func TestRootFS(t *testing.T) { + t.Run("opens file from root fs", func(t *testing.T) { + fsys := afero.NewMemMapFs() + content := []byte("test content") + err := afero.WriteFile(fsys, "/test.txt", content, 0644) + require.NoError(t, err) + + rootfs := NewRootFS(fsys) + f, err := rootfs.Open("/test.txt") + assert.NoError(t, err) + defer f.Close() + + buf := make([]byte, len(content)) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, len(content), n) + assert.Equal(t, content, buf) + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + rootfs := NewRootFS(fsys) + + _, err := rootfs.Open("/non-existent.txt") + + assert.Error(t, err) + }) +} diff --git a/internal/utils/container_output_test.go b/internal/utils/container_output_test.go new file mode 100644 index 0000000000..14595d83a4 --- /dev/null +++ b/internal/utils/container_output_test.go @@ -0,0 +1,166 @@ +package utils + +import ( + "bytes" + "encoding/json" + "io" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/stdcopy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessDiffOutput(t *testing.T) { + t.Run("processes valid diff entries", func(t *testing.T) { + input := []DiffEntry{ + { + Type: "table", + Status: "Different", + DiffDdl: "ALTER TABLE test;", + GroupName: "public", + }, + { + Type: "extension", + Status: "Different", + DiffDdl: "CREATE EXTENSION test;", + GroupName: "public", + }, + } + inputBytes, err := json.Marshal(input) + require.NoError(t, err) + + output, err := ProcessDiffOutput(inputBytes) + + assert.NoError(t, err) + assert.Contains(t, string(output), "ALTER TABLE test;") + assert.Contains(t, string(output), "CREATE EXTENSION test;") + }) + + t.Run("filters out internal schemas", func(t *testing.T) { + input := []DiffEntry{ + { + Type: "table", + Status: "Different", + DiffDdl: "ALTER TABLE test;", + GroupName: "auth", + }, + { + Type: "extension", + Status: "Different", + DiffDdl: "CREATE EXTENSION test;", + GroupName: "auth", + }, + } + inputBytes, err := json.Marshal(input) + require.NoError(t, err) + + output, err := ProcessDiffOutput(inputBytes) + + assert.NoError(t, err) + assert.Nil(t, output) + }) +} + +func TestProcessPsqlOutput(t *testing.T) { + t.Run("processes psql output", func(t *testing.T) { + var buf bytes.Buffer + writer := stdcopy.NewStdWriter(&buf, stdcopy.Stdout) + _, err := writer.Write([]byte("test output\n")) + require.NoError(t, err) + + var lastLine *string + p := NewMockProgram(func(msg tea.Msg) { + if m, ok := msg.(PsqlMsg); ok { + lastLine = m + } + }) + + err = ProcessPsqlOutput(&buf, p) + + assert.NoError(t, err) + assert.Nil(t, lastLine) + }) + + t.Run("handles stderr output", func(t *testing.T) { + var buf bytes.Buffer + writer := stdcopy.NewStdWriter(&buf, stdcopy.Stderr) + _, err := writer.Write([]byte("error message\n")) + require.NoError(t, err) + + p := NewMockProgram(nil) + + err = ProcessPsqlOutput(&buf, p) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error message") + }) +} + +func TestProcessPullOutput(t *testing.T) { + t.Run("processes docker pull messages", func(t *testing.T) { + messages := []jsonmessage.JSONMessage{ + {Status: "Pulling from library/postgres"}, + {ID: "layer1", Status: "Pulling fs layer"}, + {ID: "layer1", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 50, Total: 100}}, + {ID: "layer2", Status: "Pulling fs layer"}, + {ID: "layer2", Status: "Downloading", Progress: &jsonmessage.JSONProgress{Current: 75, Total: 100}}, + } + + // Create a pipe to write messages + r, w := io.Pipe() + enc := json.NewEncoder(w) + go func() { + for _, msg := range messages { + enc.Encode(msg) + } + w.Close() + }() + + var status string + var progressFirst *float64 + var progress *float64 + p := NewMockProgram(func(msg tea.Msg) { + switch m := msg.(type) { + case StatusMsg: + status = string(m) + case ProgressMsg: + progress = m + if progressFirst == nil { + progressFirst = m + } + } + }) + + err := ProcessPullOutput(r, p) + + assert.NoError(t, err) + assert.Equal(t, "Pulling from library/postgres...", status) + assert.Equal(t, *progressFirst, 0.5) + assert.Nil(t, progress) + }) +} + +type MockProgram struct { + handler func(tea.Msg) +} + +func NewMockProgram(handler func(tea.Msg)) *MockProgram { + return &MockProgram{handler: handler} +} + +func (m *MockProgram) Start() error { + return nil +} + +func (m *MockProgram) Send(msg tea.Msg) { + if m.handler != nil { + m.handler(msg) + } +} + +func (m *MockProgram) Quit() { + return +} diff --git a/internal/utils/deno_test.go b/internal/utils/deno_test.go index a20b3acacd..c5b140a8f7 100644 --- a/internal/utils/deno_test.go +++ b/internal/utils/deno_test.go @@ -1,6 +1,10 @@ package utils import ( + "context" + "os" + "path/filepath" + "runtime" "testing" "github.com/spf13/afero" @@ -34,3 +38,110 @@ import "./child/index.ts"` }) }) } + +func TestGetDenoPath(t *testing.T) { + t.Run("returns override path when set", func(t *testing.T) { + override := "/custom/path/to/deno" + DenoPathOverride = override + defer func() { DenoPathOverride = "" }() + + path, err := GetDenoPath() + + assert.NoError(t, err) + assert.Equal(t, override, path) + }) + + t.Run("returns default path", func(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + expected := filepath.Join(home, ".supabase", "deno") + if runtime.GOOS == "windows" { + expected += ".exe" + } + + path, err := GetDenoPath() + + assert.NoError(t, err) + assert.Equal(t, expected, path) + }) +} + +func TestIsScriptModified(t *testing.T) { + t.Run("detects modified script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + original := []byte("original content") + modified := []byte("modified content") + require.NoError(t, afero.WriteFile(fsys, destPath, modified, 0644)) + + isModified, err := isScriptModified(fsys, destPath, original) + + assert.NoError(t, err) + assert.True(t, isModified) + }) + + t.Run("detects unmodified script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + content := []byte("test content") + require.NoError(t, afero.WriteFile(fsys, destPath, content, 0644)) + + isModified, err := isScriptModified(fsys, destPath, content) + + assert.NoError(t, err) + assert.False(t, isModified) + }) + + t.Run("handles non-existent script", func(t *testing.T) { + fsys := afero.NewMemMapFs() + destPath := "/path/to/script.ts" + content := []byte("test content") + + isModified, err := isScriptModified(fsys, destPath, content) + + assert.NoError(t, err) + assert.True(t, isModified) + }) +} + +func TestCopyDenoScripts(t *testing.T) { + t.Run("copies deno scripts", func(t *testing.T) { + fsys := afero.NewMemMapFs() + home, err := os.UserHomeDir() + require.NoError(t, err) + denoDir := filepath.Join(home, ".supabase") + require.NoError(t, fsys.MkdirAll(denoDir, 0755)) + + scripts, err := CopyDenoScripts(context.Background(), fsys) + + assert.NoError(t, err) + assert.NotNil(t, scripts) + extractExists, err := afero.Exists(fsys, scripts.ExtractPath) + assert.NoError(t, err) + assert.True(t, extractExists) + }) + + t.Run("skips copying unmodified scripts", func(t *testing.T) { + fsys := afero.NewMemMapFs() + home, err := os.UserHomeDir() + require.NoError(t, err) + scriptDir := filepath.Join(home, ".supabase", "denos") + require.NoError(t, fsys.MkdirAll(scriptDir, 0755)) + + scripts1, err := CopyDenoScripts(context.Background(), fsys) + require.NoError(t, err) + stat1, err := fsys.Stat(scripts1.ExtractPath) + require.NoError(t, err) + modTime1 := stat1.ModTime() + + // Second copy + scripts2, err := CopyDenoScripts(context.Background(), fsys) + require.NoError(t, err) + stat2, err := fsys.Stat(scripts2.ExtractPath) + require.NoError(t, err) + modTime2 := stat2.ModTime() + + // Verify file wasn't rewritten + assert.Equal(t, modTime1, modTime2) + }) +} diff --git a/internal/utils/tenant/database_test.go b/internal/utils/tenant/database_test.go new file mode 100644 index 0000000000..c6680f2b72 --- /dev/null +++ b/internal/utils/tenant/database_test.go @@ -0,0 +1,88 @@ +package tenant + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +func TestGetDatabaseVersion(t *testing.T) { + t.Run("retrieves database version successfully", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: projectRef, + Database: api.V1DatabaseResponse{ + Version: "14.1.0.99", + }, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.NoError(t, err) + assert.Equal(t, "14.1.0.99", version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles project not found", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: "different-project", + Database: api.V1DatabaseResponse{ + Version: "14.1.0.99", + }, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.ErrorIs(t, err, errDatabaseVersion) + assert.Empty(t, version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("handles empty database version", func(t *testing.T) { + token := apitest.RandomAccessToken(t) + t.Setenv("SUPABASE_ACCESS_TOKEN", string(token)) + + defer gock.OffAll() + projectRef := apitest.RandomProjectRef() + gock.New(utils.DefaultApiHost). + Get("/v1/projects"). + Reply(http.StatusOK). + JSON([]api.V1ProjectWithDatabaseResponse{ + { + Id: projectRef, + Database: api.V1DatabaseResponse{}, + }, + }) + + version, err := GetDatabaseVersion(context.Background(), projectRef) + + assert.ErrorIs(t, err, errDatabaseVersion) + assert.Empty(t, version) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} From 5e15acd99e5e2f88bb767ddfa4cfa606bdea231e Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 03:54:04 +0100 Subject: [PATCH 06/12] chore: add utils misc tests --- internal/utils/misc_test.go | 111 ++++++++++++++++++++++++++ internal/utils/output_test.go | 141 ++++++++++++++++++++++++++++++++++ internal/utils/slice_test.go | 54 ++++++++++++- 3 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 internal/utils/output_test.go diff --git a/internal/utils/misc_test.go b/internal/utils/misc_test.go index 6472c2145f..b12d68c789 100644 --- a/internal/utils/misc_test.go +++ b/internal/utils/misc_test.go @@ -75,3 +75,114 @@ func TestProjectRoot(t *testing.T) { assert.Equal(t, cwd, path) }) } + +func TestShortContainerImageName(t *testing.T) { + t.Run("extracts short name from image", func(t *testing.T) { + input := "registry.supabase.com/postgres:15.1.0.99" + expected := "postgres" + + result := ShortContainerImageName(input) + + assert.Equal(t, expected, result) + }) +} + +func TestIsBranchNameReserved(t *testing.T) { + t.Run("identifies reserved names", func(t *testing.T) { + reserved := []string{"_current_branch", "main"} + for _, name := range reserved { + assert.True(t, IsBranchNameReserved(name), "Expected %s to be reserved", name) + } + }) + + t.Run("allows custom names", func(t *testing.T) { + allowed := []string{"my-feature", "test-branch-123"} + for _, name := range allowed { + assert.False(t, IsBranchNameReserved(name), "Expected %s to be allowed", name) + } + }) +} + +func TestValidateFunctionSlug(t *testing.T) { + t.Run("validates correct slugs", func(t *testing.T) { + valid := []string{ + "my-function", + "MyFunction", + "function_1", + "a123", + } + for _, slug := range valid { + err := ValidateFunctionSlug(slug) + assert.NoError(t, err, "Expected %s to be valid", slug) + } + }) + + t.Run("rejects invalid slugs", func(t *testing.T) { + invalid := []string{ + "1function", + "my function", + "function!", + "", + "-function", + "_function", + } + for _, slug := range invalid { + err := ValidateFunctionSlug(slug) + assert.ErrorIs(t, err, ErrInvalidSlug, "Expected %s to be invalid", slug) + } + }) +} + +func TestAssertProjectRefIsValid(t *testing.T) { + t.Run("validates correct refs", func(t *testing.T) { + validRef := "abcdefghijklmnopqrst" + err := AssertProjectRefIsValid(validRef) + assert.NoError(t, err) + }) + + t.Run("rejects invalid refs", func(t *testing.T) { + invalid := []string{ + "tooshort", + "toolongabcdefghijklmnopqrst", + "UPPERCASE", + "special-chars", + "123", + "", + } + for _, ref := range invalid { + err := AssertProjectRefIsValid(ref) + assert.ErrorIs(t, err, ErrInvalidRef, "Expected %s to be invalid", ref) + } + }) +} + +func TestWriteFile(t *testing.T) { + t.Run("writes file with directories", func(t *testing.T) { + fsys := afero.NewMemMapFs() + path := filepath.Join("deep", "nested", "dir", "file.txt") + content := []byte("test content") + + err := WriteFile(path, content, fsys) + + assert.NoError(t, err) + + written, err := afero.ReadFile(fsys, path) + assert.NoError(t, err) + assert.Equal(t, content, written) + }) + + t.Run("overwrites existing file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + path := "test.txt" + original := []byte("original") + updated := []byte("updated") + require.NoError(t, afero.WriteFile(fsys, path, original, 0644)) + + err := WriteFile(path, updated, fsys) + + assert.NoError(t, err) + written, err := afero.ReadFile(fsys, path) + assert.NoError(t, err) + assert.Equal(t, updated, written) + }) +} diff --git a/internal/utils/output_test.go b/internal/utils/output_test.go new file mode 100644 index 0000000000..088136ebac --- /dev/null +++ b/internal/utils/output_test.go @@ -0,0 +1,141 @@ +package utils + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeOutput(t *testing.T) { + t.Run("encodes env format", func(t *testing.T) { + input := map[string]string{ + "DATABASE_URL": "postgres://user:pass@host:5432/db", + "API_KEY": "secret-key", + } + var buf bytes.Buffer + err := EncodeOutput(OutputEnv, &buf, input) + assert.NoError(t, err) + expected := "API_KEY=\"secret-key\"\nDATABASE_URL=\"postgres://user:pass@host:5432/db\"\n" + assert.Equal(t, expected, buf.String()) + }) + + t.Run("fails env format with invalid type", func(t *testing.T) { + input := map[string]int{"FOO": 123} + var buf bytes.Buffer + err := EncodeOutput(OutputEnv, &buf, input) + assert.ErrorContains(t, err, "value is not a map[string]string") + }) + + t.Run("encodes json format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputJson, &buf, input) + assert.NoError(t, err) + expected := `{ + "config": { + "enabled": true, + "port": 5432 + }, + "name": "test" +} +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("encodes yaml format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputYaml, &buf, input) + assert.NoError(t, err) + expected := `config: + enabled: true + port: 5432 +name: test +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("encodes toml format", func(t *testing.T) { + input := map[string]interface{}{ + "name": "test", + "config": map[string]interface{}{ + "port": 5432, + "enabled": true, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputToml, &buf, input) + assert.NoError(t, err) + expected := `name = "test" + +[config] + enabled = true + port = 5432 +` + assert.Equal(t, expected, buf.String()) + }) + + t.Run("fails with unsupported format", func(t *testing.T) { + var buf bytes.Buffer + err := EncodeOutput("invalid", &buf, nil) + assert.ErrorContains(t, err, `Unsupported output encoding "invalid"`) + }) + + t.Run("handles complex nested structures", func(t *testing.T) { + input := map[string]interface{}{ + "database": map[string]interface{}{ + "connections": []map[string]interface{}{ + { + "host": "localhost", + "port": 5432, + }, + { + "host": "remote", + "port": 6543, + }, + }, + "settings": map[string]interface{}{ + "max_connections": 100, + "ssl_enabled": true, + }, + }, + } + var buf bytes.Buffer + err := EncodeOutput(OutputJson, &buf, input) + require.NoError(t, err) + expected := `{ + "database": { + "connections": [ + { + "host": "localhost", + "port": 5432 + }, + { + "host": "remote", + "port": 6543 + } + ], + "settings": { + "max_connections": 100, + "ssl_enabled": true + } + } +} +` + assert.Equal(t, expected, buf.String()) + }) +} diff --git a/internal/utils/slice_test.go b/internal/utils/slice_test.go index 81642c73ff..47bdea171f 100644 --- a/internal/utils/slice_test.go +++ b/internal/utils/slice_test.go @@ -7,9 +7,59 @@ import ( ) func TestSliceEqual(t *testing.T) { - assert.False(t, SliceEqual([]string{"a"}, []string{"b"})) + t.Run("different slices", func(t *testing.T) { + assert.False(t, SliceEqual([]string{"a"}, []string{"b"})) + }) + + t.Run("different lengths", func(t *testing.T) { + assert.False(t, SliceEqual([]string{"a"}, []string{"a", "b"})) + assert.False(t, SliceEqual([]string{"a", "b"}, []string{"a"})) + }) + + t.Run("equal slices", func(t *testing.T) { + assert.True(t, SliceEqual([]string{"a", "b"}, []string{"a", "b"})) + assert.True(t, SliceEqual([]int{1, 2, 3}, []int{1, 2, 3})) + }) + + t.Run("empty slices", func(t *testing.T) { + assert.True(t, SliceEqual([]string{}, []string{})) + }) } func TestSliceContains(t *testing.T) { - assert.False(t, SliceContains([]string{"a"}, "b")) + t.Run("not contains element", func(t *testing.T) { + assert.False(t, SliceContains([]string{"a"}, "b")) + }) + + t.Run("contains element", func(t *testing.T) { + assert.True(t, SliceContains([]string{"a", "b", "c"}, "b")) + assert.True(t, SliceContains([]int{1, 2, 3}, 2)) + }) + + t.Run("empty slice", func(t *testing.T) { + assert.False(t, SliceContains([]string{}, "a")) + }) +} + +func TestRemoveDuplicates(t *testing.T) { + t.Run("string slice", func(t *testing.T) { + input := []string{"a", "b", "a", "c", "b", "d"} + expected := []string{"a", "b", "c", "d"} + assert.Equal(t, expected, RemoveDuplicates(input)) + }) + + t.Run("int slice", func(t *testing.T) { + input := []int{1, 2, 2, 3, 1, 4, 3, 5} + expected := []int{1, 2, 3, 4, 5} + assert.Equal(t, expected, RemoveDuplicates(input)) + }) + + t.Run("empty slice", func(t *testing.T) { + assert.Empty(t, RemoveDuplicates([]string{})) + }) + + t.Run("no duplicates", func(t *testing.T) { + input := []int{1, 2, 3, 4, 5} + assert.Equal(t, input, RemoveDuplicates(input)) + }) } From 5ace21babb46842556ef8e4913f77e868937658e Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 04:20:29 +0100 Subject: [PATCH 07/12] chore: rm some files from coverage --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7cf2b30bc..d7b9e09abf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: t1m0thyj/unlock-keyring@v1 - run: | go run gotest.tools/gotestsum -- -race -v -count=1 -coverprofile=coverage.out \ - `go list ./... | grep -Ev 'cmd|docs|examples|pkg/api|tools'` + `go list ./... | grep -Ev 'cmd|docs|examples|pkg/api|tools|test|testing|mock'` - uses: coverallsapp/github-action@v2 with: From 1aef18d9fe9cead8f88bddd3ab1bce9ad387c76f Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 04:26:31 +0100 Subject: [PATCH 08/12] chore: lint fixes --- internal/utils/container_output_test.go | 7 +++---- internal/utils/credentials/store_test.go | 3 ++- internal/utils/flags/db_url_test.go | 22 +++++++++++++--------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/utils/container_output_test.go b/internal/utils/container_output_test.go index 14595d83a4..05a731f6b4 100644 --- a/internal/utils/container_output_test.go +++ b/internal/utils/container_output_test.go @@ -114,7 +114,8 @@ func TestProcessPullOutput(t *testing.T) { enc := json.NewEncoder(w) go func() { for _, msg := range messages { - enc.Encode(msg) + err := enc.Encode(msg) + assert.Nil(t, err) } w.Close() }() @@ -161,6 +162,4 @@ func (m *MockProgram) Send(msg tea.Msg) { } } -func (m *MockProgram) Quit() { - return -} +func (m *MockProgram) Quit() {} diff --git a/internal/utils/credentials/store_test.go b/internal/utils/credentials/store_test.go index f13dc99081..82893c42cc 100644 --- a/internal/utils/credentials/store_test.go +++ b/internal/utils/credentials/store_test.go @@ -86,7 +86,8 @@ func setupWSLEnvironment(t *testing.T) func() { return func() { os.Remove(tmpFile.Name()) os.Remove(oldProcPath) - os.Rename(oldProcPath+".bak", oldProcPath) + err := os.Rename(oldProcPath+".bak", oldProcPath) + assert.Nil(t, err) } } diff --git a/internal/utils/flags/db_url_test.go b/internal/utils/flags/db_url_test.go index 0742c8f462..ba9676b5fc 100644 --- a/internal/utils/flags/db_url_test.go +++ b/internal/utils/flags/db_url_test.go @@ -17,11 +17,12 @@ func TestParseDatabaseConfig(t *testing.T) { t.Run("parses direct connection from db-url flag", func(t *testing.T) { flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) flagSet.String("db-url", "postgres://postgres:password@localhost:5432/postgres", "") - flagSet.Set("db-url", "postgres://admin:secret@db.example.com:6432/app") + err := flagSet.Set("db-url", "postgres://admin:secret@db.example.com:6432/app") + assert.Nil(t, err) fsys := afero.NewMemMapFs() - err := ParseDatabaseConfig(flagSet, fsys) + err = ParseDatabaseConfig(flagSet, fsys) assert.NoError(t, err) assert.Equal(t, "db.example.com", DbConfig.Host) @@ -32,10 +33,10 @@ func TestParseDatabaseConfig(t *testing.T) { }) t.Run("parses local connection", func(t *testing.T) { - flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) flagSet.Bool("local", false, "") - flagSet.Set("local", "true") + err := flagSet.Set("local", "true") + assert.Nil(t, err) fsys := afero.NewMemMapFs() @@ -43,7 +44,7 @@ func TestParseDatabaseConfig(t *testing.T) { utils.Config.Db.Port = 54322 utils.Config.Db.Password = "local-password" - err := ParseDatabaseConfig(flagSet, fsys) + err = ParseDatabaseConfig(flagSet, fsys) assert.NoError(t, err) assert.Equal(t, "localhost", DbConfig.Host) @@ -56,12 +57,13 @@ func TestParseDatabaseConfig(t *testing.T) { t.Run("parses linked connection", func(t *testing.T) { flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) flagSet.Bool("linked", false, "") - flagSet.Set("linked", "true") + err := flagSet.Set("linked", "true") + assert.Nil(t, err) fsys := afero.NewMemMapFs() project := apitest.RandomProjectRef() - err := afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) + err = afero.WriteFile(fsys, utils.ProjectRefPath, []byte(project), 0644) require.NoError(t, err) err = ParseDatabaseConfig(flagSet, fsys) @@ -78,7 +80,8 @@ func TestPromptPassword(t *testing.T) { defer r.Close() go func() { defer w.Close() - w.Write([]byte("test-password")) + _, err := w.Write([]byte("test-password")) + assert.Nil(t, err) }() password := PromptPassword(r) @@ -92,7 +95,8 @@ func TestPromptPassword(t *testing.T) { defer r.Close() go func() { defer w.Close() - w.Write([]byte("")) + _, err := w.Write([]byte("")) + assert.Nil(t, err) }() password := PromptPassword(r) From e342f733d7e335c4ce3492d0dd54e5dca8bc28d6 Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Mon, 5 May 2025 12:33:03 +0800 Subject: [PATCH 09/12] Update .github/workflows/ci.yml --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7b9e09abf..30ad7b4f58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: t1m0thyj/unlock-keyring@v1 - run: | go run gotest.tools/gotestsum -- -race -v -count=1 -coverprofile=coverage.out \ - `go list ./... | grep -Ev 'cmd|docs|examples|pkg/api|tools|test|testing|mock'` + `go list ./... | grep -Ev 'cmd|docs|examples|internal/testing|pkg/api|pkg/pgtest|tools'` - uses: coverallsapp/github-action@v2 with: From 3d88f5311c27912509d049503c430e794ad7eccf Mon Sep 17 00:00:00 2001 From: Qiao Han Date: Mon, 5 May 2025 14:25:43 +0800 Subject: [PATCH 10/12] chore: update unit tests --- internal/utils/credentials/store_test.go | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/internal/utils/credentials/store_test.go b/internal/utils/credentials/store_test.go index 82893c42cc..5376e380f7 100644 --- a/internal/utils/credentials/store_test.go +++ b/internal/utils/credentials/store_test.go @@ -11,7 +11,7 @@ import ( func TestKeyringStore(t *testing.T) { t.Run("stores and retrieves password", func(t *testing.T) { - defer MockInit()() + keyring.MockInit() project := "test-project" password := "test-password" @@ -24,7 +24,7 @@ func TestKeyringStore(t *testing.T) { }) t.Run("returns error for non-existent project", func(t *testing.T) { - defer MockInit()() + keyring.MockInit() project := "non-existent" retrieved, err := StoreProvider.Get(project) @@ -33,7 +33,7 @@ func TestKeyringStore(t *testing.T) { }) t.Run("deletes specific project password", func(t *testing.T) { - defer MockInit()() + keyring.MockInit() project := "test-project" password := "test-password" @@ -47,7 +47,7 @@ func TestKeyringStore(t *testing.T) { }) t.Run("deletes all project passwords", func(t *testing.T) { - defer MockInit()() + keyring.MockInit() projects := []string{"project1", "project2"} for _, project := range projects { @@ -131,40 +131,32 @@ func TestWSLSupport(t *testing.T) { func TestKeyringErrors(t *testing.T) { t.Run("handles Get error", func(t *testing.T) { - oldStore := StoreProvider - defer func() { StoreProvider = oldStore }() mockErr := errors.New("mock error") - StoreProvider = &mockProvider{mockError: mockErr} + keyring.MockInitWithError(mockErr) _, err := StoreProvider.Get("test") assert.ErrorIs(t, err, mockErr) }) t.Run("handles Set error", func(t *testing.T) { - oldStore := StoreProvider - defer func() { StoreProvider = oldStore }() mockErr := errors.New("mock error") - StoreProvider = &mockProvider{mockError: mockErr} + keyring.MockInitWithError(mockErr) err := StoreProvider.Set("test", "pass") assert.ErrorIs(t, err, mockErr) }) t.Run("handles Delete error", func(t *testing.T) { - oldStore := StoreProvider - defer func() { StoreProvider = oldStore }() mockErr := errors.New("mock error") - StoreProvider = &mockProvider{mockError: mockErr} + keyring.MockInitWithError(mockErr) err := StoreProvider.Delete("test") assert.ErrorIs(t, err, mockErr) }) t.Run("handles DeleteAll error", func(t *testing.T) { - oldStore := StoreProvider - defer func() { StoreProvider = oldStore }() mockErr := errors.New("mock error") - StoreProvider = &mockProvider{mockError: mockErr} + keyring.MockInitWithError(mockErr) err := StoreProvider.DeleteAll() assert.ErrorIs(t, err, mockErr) From bbaedf0afe10a87b3823f10230e6a864d063c689 Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 10:52:56 +0100 Subject: [PATCH 11/12] fix race in test --- internal/utils/container_output_test.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/utils/container_output_test.go b/internal/utils/container_output_test.go index 05a731f6b4..cf3e529bb3 100644 --- a/internal/utils/container_output_test.go +++ b/internal/utils/container_output_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "io" + "sync" "testing" tea "github.com/charmbracelet/bubbletea" @@ -146,20 +147,25 @@ func TestProcessPullOutput(t *testing.T) { type MockProgram struct { handler func(tea.Msg) + mu sync.Mutex // Add mutex for thread safety } func NewMockProgram(handler func(tea.Msg)) *MockProgram { - return &MockProgram{handler: handler} -} - -func (m *MockProgram) Start() error { - return nil + return &MockProgram{ + handler: handler, + } } func (m *MockProgram) Send(msg tea.Msg) { if m.handler != nil { + m.mu.Lock() m.handler(msg) + m.mu.Unlock() } } +func (m *MockProgram) Start() error { + return nil +} + func (m *MockProgram) Quit() {} From 0ecccbcf914ce9f8912385ef6b9e847fa24cc3df Mon Sep 17 00:00:00 2001 From: egor Date: Mon, 5 May 2025 11:31:55 +0100 Subject: [PATCH 12/12] chore: rm wsl tests --- internal/utils/credentials/store_test.go | 65 ------------------------ 1 file changed, 65 deletions(-) diff --git a/internal/utils/credentials/store_test.go b/internal/utils/credentials/store_test.go index 5376e380f7..4bb62f6581 100644 --- a/internal/utils/credentials/store_test.go +++ b/internal/utils/credentials/store_test.go @@ -1,7 +1,6 @@ package credentials import ( - "os" "testing" "github.com/go-errors/errors" @@ -65,70 +64,6 @@ func TestKeyringStore(t *testing.T) { }) } -func setupWSLEnvironment(t *testing.T) func() { - tmpFile, err := os.CreateTemp("", "osrelease") - assert.NoError(t, err) - - _, err = tmpFile.WriteString("Linux version 5.10.16.3-microsoft-standard-WSL2") - assert.NoError(t, err) - - oldProcPath := "/proc/sys/kernel/osrelease" - if err := os.Rename(oldProcPath, oldProcPath+".bak"); err == nil { - // Only setup symlink if we can backup original - err = os.Symlink(tmpFile.Name(), oldProcPath) - if err != nil { - t.Skip("Cannot create symlink for testing WSL detection") - } - } else { - t.Skip("Cannot backup original osrelease file") - } - - return func() { - os.Remove(tmpFile.Name()) - os.Remove(oldProcPath) - err := os.Rename(oldProcPath+".bak", oldProcPath) - assert.Nil(t, err) - } -} - -func TestWSLSupport(t *testing.T) { - t.Run("Get returns not supported in WSL", func(t *testing.T) { - cleanup := setupWSLEnvironment(t) - defer cleanup() - - store := &KeyringStore{} - _, err := store.Get("test") - assert.ErrorIs(t, err, ErrNotSupported) - }) - - t.Run("Set returns not supported in WSL", func(t *testing.T) { - cleanup := setupWSLEnvironment(t) - defer cleanup() - - store := &KeyringStore{} - err := store.Set("test", "pass") - assert.ErrorIs(t, err, ErrNotSupported) - }) - - t.Run("Delete returns not supported in WSL", func(t *testing.T) { - cleanup := setupWSLEnvironment(t) - defer cleanup() - - store := &KeyringStore{} - err := store.Delete("test") - assert.ErrorIs(t, err, ErrNotSupported) - }) - - t.Run("DeleteAll returns not supported in WSL", func(t *testing.T) { - cleanup := setupWSLEnvironment(t) - defer cleanup() - - store := &KeyringStore{} - err := store.DeleteAll() - assert.ErrorIs(t, err, ErrNotSupported) - }) -} - func TestKeyringErrors(t *testing.T) { t.Run("handles Get error", func(t *testing.T) { mockErr := errors.New("mock error")