From f210c74fc9020cd71e1891146d1644d43d91337c Mon Sep 17 00:00:00 2001 From: Alan Hamlett Date: Wed, 24 Jul 2024 09:17:06 +0200 Subject: [PATCH] Rate limiting by default --- .golangci.yml | 1 + USAGE.md | 1 + cmd/configwrite/configwrite_test.go | 6 +- cmd/heartbeat/heartbeat.go | 61 +++++ cmd/heartbeat/heartbeat_test.go | 115 +++++++- cmd/offlinesync/offlinesync.go | 38 ++- cmd/offlinesync/offlinesync_test.go | 282 +++++++++++++++++++ cmd/params/params.go | 33 ++- cmd/params/params_test.go | 402 +++++++++++++++------------- cmd/root.go | 7 + cmd/run.go | 4 +- pkg/offline/offline.go | 3 + 12 files changed, 762 insertions(+), 191 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index a8ae4e1d..341fd9a0 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -67,3 +67,4 @@ issues: - gosec include: - EXC0002 + fix: true diff --git a/USAGE.md b/USAGE.md index eb438dd5..30dfe773 100644 --- a/USAGE.md +++ b/USAGE.md @@ -91,6 +91,7 @@ some/submodule/name = new project name | api_key | Your wakatime api key. | _string_ | | | api_key_vault_cmd | A command to get your api key, perhaps from some sort of secure vault. Actually a space-separated list of an executable and its arguments. Executables in PATH can be referred to by their basenames. Shell syntax not supported. | _string_ | | | api_url | The WakaTime API base url. | _string_ | | +| heartbeat_rate_limit_seconds | Rate limit sending heartbeats to the API once per duration. Set to 0 to disable rate limiting. | _int_ | `120` | | hide_file_names | Obfuscate filenames. Will not send file names to api. | _bool_;_list_ | `false` | | hide_project_names | Obfuscate project names. When a project folder is detected instead of using the folder name as the project, a `.wakatime-project file` is created with a random project name. | _bool_;_list_ | `false` | | hide_branch_names | Obfuscate branch names. Will not send revision control branch names to api. | _bool_;_list_ | `false` | diff --git a/cmd/configwrite/configwrite_test.go b/cmd/configwrite/configwrite_test.go index f244b920..d96e0b7a 100644 --- a/cmd/configwrite/configwrite_test.go +++ b/cmd/configwrite/configwrite_test.go @@ -132,7 +132,7 @@ func TestWriteErr(t *testing.T) { func TestWriteSaveErr(t *testing.T) { v := viper.New() - w := &writerMock{ + w := &mockWriter{ WriteFn: func(section string, keyValue map[string]string) error { assert.Equal(t, "settings", section) assert.Equal(t, map[string]string{"debug": "false"}, keyValue) @@ -148,10 +148,10 @@ func TestWriteSaveErr(t *testing.T) { assert.Error(t, err) } -type writerMock struct { +type mockWriter struct { WriteFn func(section string, keyValue map[string]string) error } -func (m *writerMock) Write(section string, keyValue map[string]string) error { +func (m *mockWriter) Write(section string, keyValue map[string]string) error { return m.WriteFn(section, keyValue) } diff --git a/cmd/heartbeat/heartbeat.go b/cmd/heartbeat/heartbeat.go index ef8847b7..8799a8e2 100644 --- a/cmd/heartbeat/heartbeat.go +++ b/cmd/heartbeat/heartbeat.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "time" apicmd "github.com/wakatime/wakatime-cli/cmd/api" offlinecmd "github.com/wakatime/wakatime-cli/cmd/offline" @@ -16,6 +17,7 @@ import ( "github.com/wakatime/wakatime-cli/pkg/filestats" "github.com/wakatime/wakatime-cli/pkg/filter" "github.com/wakatime/wakatime-cli/pkg/heartbeat" + "github.com/wakatime/wakatime-cli/pkg/ini" "github.com/wakatime/wakatime-cli/pkg/language" _ "github.com/wakatime/wakatime-cli/pkg/lexer" // force to load all lexers "github.com/wakatime/wakatime-cli/pkg/log" @@ -76,6 +78,19 @@ func SendHeartbeats(v *viper.Viper, queueFilepath string) error { setLogFields(params) log.Debugf("params: %s", params) + if RateLimited(RateLimitParams{ + Disabled: params.Offline.Disabled, + LastSentAt: params.Offline.LastSentAt, + Timeout: params.Offline.RateLimit, + }) { + if err = offlinecmd.SaveHeartbeats(v, nil, queueFilepath); err == nil { + return nil + } + + // log offline db error then try to send heartbeats to API so they're not lost + log.Errorf("failed to save rate limited heartbeats: %s", err) + } + heartbeats := buildHeartbeats(params) var chOfflineSave = make(chan bool) @@ -143,6 +158,10 @@ func SendHeartbeats(v *viper.Viper, queueFilepath string) error { } } + if err := ResetRateLimit(v); err != nil { + log.Errorf("failed to reset rate limit: %s", err) + } + return nil } @@ -170,6 +189,48 @@ func LoadParams(v *viper.Viper) (paramscmd.Params, error) { }, nil } +// RateLimitParams contains params for the RateLimited function. +type RateLimitParams struct { + Disabled bool + LastSentAt time.Time + Timeout time.Duration +} + +// RateLimited determines if we should send heartbeats to the API or save to the offline db. +func RateLimited(params RateLimitParams) bool { + if params.Disabled { + return false + } + + if params.Timeout == 0 { + return false + } + + if params.LastSentAt.IsZero() { + return false + } + + return time.Since(params.LastSentAt) < params.Timeout +} + +// ResetRateLimit updates the internal.heartbeats_last_sent_at timestamp. +func ResetRateLimit(v *viper.Viper) error { + w, err := ini.NewWriter(v, ini.InternalFilePath) + if err != nil { + return fmt.Errorf("failed to parse config file: %s", err) + } + + keyValue := map[string]string{ + "heartbeats_last_sent_at": time.Now().Format(ini.DateFormat), + } + + if err := w.Write("internal", keyValue); err != nil { + return fmt.Errorf("failed to write to internal config file: %s", err) + } + + return nil +} + func buildHeartbeats(params paramscmd.Params) []heartbeat.Heartbeat { heartbeats := []heartbeat.Heartbeat{} diff --git a/cmd/heartbeat/heartbeat_test.go b/cmd/heartbeat/heartbeat_test.go index 21261ee6..9e34a203 100644 --- a/cmd/heartbeat/heartbeat_test.go +++ b/cmd/heartbeat/heartbeat_test.go @@ -47,6 +47,8 @@ func TestSendHeartbeats(t *testing.T) { subfolders := project.CountSlashesInProjectFolder(projectFolder) router.HandleFunc("/users/current/heartbeats.bulk", func(w http.ResponseWriter, req *http.Request) { + numCalls++ + // check request assert.Equal(t, http.MethodPost, req.Method) assert.Equal(t, []string{"application/json"}, req.Header["Accept"]) @@ -85,7 +87,47 @@ func TestSendHeartbeats(t *testing.T) { _, err = io.Copy(w, f) require.NoError(t, err) + }) + + v := viper.New() + v.SetDefault("sync-offline-activity", 1000) + v.Set("api-url", testServerURL) + v.Set("category", "debugging") + v.Set("cursorpos", 42) + v.Set("entity", "testdata/main.go") + v.Set("entity-type", "file") + v.Set("key", "00000000-0000-4000-8000-000000000000") + v.Set("language", "Go") + v.Set("alternate-language", "Golang") + v.Set("hide-branch-names", true) + v.Set("project", "wakatime-cli") + v.Set("lineno", 13) + v.Set("local-file", "testdata/localfile.go") + v.Set("plugin", plugin) + v.Set("time", 1585598059.1) + v.Set("timeout", 5) + v.Set("write", true) + + offlineQueueFile, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + + err = cmdheartbeat.SendHeartbeats(v, offlineQueueFile.Name()) + require.NoError(t, err) + + assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) +} + +func TestSendHeartbeats_RateLimited(t *testing.T) { + testServerURL, router, tearDown := setupTestServer() + defer tearDown() + + var ( + plugin = "plugin/0.0.1" + numCalls int + ) + router.HandleFunc("/users/current/heartbeats.bulk", func(_ http.ResponseWriter, _ *http.Request) { + // Should not be called numCalls++ }) @@ -107,6 +149,8 @@ func TestSendHeartbeats(t *testing.T) { v.Set("time", 1585598059.1) v.Set("timeout", 5) v.Set("write", true) + v.Set("heartbeat-rate-limit-seconds", 500) + v.Set("internal.heartbeats_last_sent_at", time.Now().Add(-time.Minute).Format(time.RFC3339)) offlineQueueFile, err := os.CreateTemp(t.TempDir(), "") require.NoError(t, err) @@ -114,7 +158,7 @@ func TestSendHeartbeats(t *testing.T) { err = cmdheartbeat.SendHeartbeats(v, offlineQueueFile.Name()) require.NoError(t, err) - assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) + assert.Zero(t, numCalls) } func TestSendHeartbeats_WithFiltering_Exclude(t *testing.T) { @@ -1052,6 +1096,75 @@ func TestSendHeartbeats_ObfuscateProjectNotBranch(t *testing.T) { assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) } +func TestRateLimited(t *testing.T) { + p := cmdheartbeat.RateLimitParams{ + Timeout: time.Duration(offline.RateLimitDefaultSeconds) * time.Second, + LastSentAt: time.Now(), + } + + assert.True(t, cmdheartbeat.RateLimited(p)) +} + +func TestRateLimited_NotLimited(t *testing.T) { + p := cmdheartbeat.RateLimitParams{ + LastSentAt: time.Now().Add(time.Duration(-offline.RateLimitDefaultSeconds*2) * time.Second), + Timeout: time.Duration(offline.RateLimitDefaultSeconds) * time.Second, + } + + assert.False(t, cmdheartbeat.RateLimited(p)) +} + +func TestRateLimited_Disabled(t *testing.T) { + p := cmdheartbeat.RateLimitParams{ + Disabled: true, + } + + assert.False(t, cmdheartbeat.RateLimited(p)) +} + +func TestRateLimited_TimeoutZero(t *testing.T) { + p := cmdheartbeat.RateLimitParams{ + LastSentAt: time.Time{}, + } + + assert.False(t, cmdheartbeat.RateLimited(p)) +} + +func TestRateLimited_LastSentAtZero(t *testing.T) { + p := cmdheartbeat.RateLimitParams{ + Timeout: 0, + } + + assert.False(t, cmdheartbeat.RateLimited(p)) +} + +func TestResetRateLimit(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "wakatime") + require.NoError(t, err) + + defer tmpFile.Close() + + v := viper.New() + v.Set("config", tmpFile.Name()) + v.Set("internal-config", tmpFile.Name()) + + writer, err := ini.NewWriter(v, func(vp *viper.Viper) (string, error) { + assert.Equal(t, v, vp) + return tmpFile.Name(), nil + }) + require.NoError(t, err) + + err = cmdheartbeat.ResetRateLimit(v) + require.NoError(t, err) + + err = writer.File.Reload() + require.NoError(t, err) + + lastSentAt := writer.File.Section("internal").Key("heartbeats_last_sent_at").MustTimeFormat(ini.DateFormat) + + assert.WithinDuration(t, time.Now(), lastSentAt, 1*time.Second) +} + func setupTestServer() (string, *http.ServeMux, func()) { router := http.NewServeMux() srv := httptest.NewServer(router) diff --git a/cmd/offlinesync/offlinesync.go b/cmd/offlinesync/offlinesync.go index 48574e8a..878834e5 100644 --- a/cmd/offlinesync/offlinesync.go +++ b/cmd/offlinesync/offlinesync.go @@ -5,6 +5,7 @@ import ( "os" cmdapi "github.com/wakatime/wakatime-cli/cmd/api" + cmdheartbeat "github.com/wakatime/wakatime-cli/cmd/heartbeat" "github.com/wakatime/wakatime-cli/cmd/params" "github.com/wakatime/wakatime-cli/pkg/apikey" "github.com/wakatime/wakatime-cli/pkg/exitcode" @@ -16,8 +17,33 @@ import ( "github.com/spf13/viper" ) -// Run executes the sync-offline-activity command. -func Run(v *viper.Viper) (int, error) { +// RunWithoutRateLimiting executes the sync-offline-activity command without rate limiting. +func RunWithoutRateLimiting(v *viper.Viper) (int, error) { + return run(v) +} + +// RunWithRateLimiting executes sync-offline-activity command with rate limiting enabled. +func RunWithRateLimiting(v *viper.Viper) (int, error) { + paramOffline := params.LoadOfflineParams(v) + + if cmdheartbeat.RateLimited(cmdheartbeat.RateLimitParams{ + Disabled: paramOffline.Disabled, + LastSentAt: paramOffline.LastSentAt, + Timeout: paramOffline.RateLimit, + }) { + log.Debugln("skip syncing offline activity to respect rate limit") + return exitcode.Success, nil + } + + return run(v) +} + +func run(v *viper.Viper) (int, error) { + paramOffline := params.LoadOfflineParams(v) + if paramOffline.Disabled { + return exitcode.Success, nil + } + queueFilepath, err := offline.QueueFilepath() if err != nil { return exitcode.ErrGeneric, fmt.Errorf( @@ -97,8 +123,6 @@ func syncOfflineActivityLegacy(v *viper.Viper, queueFilepath string) error { // SyncOfflineActivity syncs offline activity by sending heartbeats // from the offline queue to the WakaTime API. func SyncOfflineActivity(v *viper.Viper, queueFilepath string) error { - paramOffline := params.LoadOfflineParams(v) - paramAPI, err := params.LoadAPIParams(v) if err != nil { return fmt.Errorf("failed to load API parameters: %w", err) @@ -109,6 +133,8 @@ func SyncOfflineActivity(v *viper.Viper, queueFilepath string) error { return fmt.Errorf("failed to initialize api client: %w", err) } + paramOffline := params.LoadOfflineParams(v) + if paramOffline.QueueFile != "" { queueFilepath = paramOffline.QueueFile } @@ -126,5 +152,9 @@ func SyncOfflineActivity(v *viper.Viper, queueFilepath string) error { return err } + if err := cmdheartbeat.ResetRateLimit(v); err != nil { + log.Errorf("failed to reset rate limit: %s", err) + } + return nil } diff --git a/cmd/offlinesync/offlinesync_test.go b/cmd/offlinesync/offlinesync_test.go index cb7abead..50754c0f 100644 --- a/cmd/offlinesync/offlinesync_test.go +++ b/cmd/offlinesync/offlinesync_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/wakatime/wakatime-cli/cmd/offlinesync" + "github.com/wakatime/wakatime-cli/pkg/exitcode" "github.com/wakatime/wakatime-cli/pkg/heartbeat" "github.com/spf13/viper" @@ -20,6 +21,198 @@ import ( bolt "go.etcd.io/bbolt" ) +func TestRunWithRateLimiting(t *testing.T) { + testServerURL, router, tearDown := setupTestServer() + defer tearDown() + + var ( + plugin = "plugin/0.0.1" + numCalls int + ) + + router.HandleFunc("/users/current/heartbeats.bulk", func(w http.ResponseWriter, req *http.Request) { + numCalls++ + + // check request + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, []string{"application/json"}, req.Header["Accept"]) + assert.Equal(t, []string{"application/json"}, req.Header["Content-Type"]) + assert.Equal(t, []string{"Basic MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAw"}, req.Header["Authorization"]) + assert.True(t, strings.HasSuffix(req.Header["User-Agent"][0], plugin), fmt.Sprintf( + "%q should have suffix %q", + req.Header["User-Agent"][0], + plugin, + )) + + expectedBody, err := os.ReadFile("testdata/api_heartbeats_request_template.json") + require.NoError(t, err) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedBody), string(body)) + + // send response + w.WriteHeader(http.StatusCreated) + + f, err := os.Open("testdata/api_heartbeats_response.json") + require.NoError(t, err) + defer f.Close() + + _, err = io.Copy(w, f) + require.NoError(t, err) + }) + + // setup offline queue + f, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + + db, err := bolt.Open(f.Name(), 0600, nil) + require.NoError(t, err) + + dataGo, err := os.ReadFile("testdata/heartbeat_go.json") + require.NoError(t, err) + + dataPy, err := os.ReadFile("testdata/heartbeat_py.json") + require.NoError(t, err) + + dataJs, err := os.ReadFile("testdata/heartbeat_js.json") + require.NoError(t, err) + + insertHeartbeatRecords(t, db, "heartbeats", []heartbeatRecord{ + { + ID: "1592868367.219124-file-coding-wakatime-cli-heartbeat-/tmp/main.go-true", + Heartbeat: string(dataGo), + }, + { + ID: "1592868386.079084-file-debugging-wakatime-summary-/tmp/main.py-false", + Heartbeat: string(dataPy), + }, + { + ID: "1592868394.084354-file-building-wakatime-todaygoal-/tmp/main.js-false", + Heartbeat: string(dataJs), + }, + }) + + err = db.Close() + require.NoError(t, err) + + v := viper.New() + v.Set("api-url", testServerURL) + v.Set("key", "00000000-0000-4000-8000-000000000000") + v.Set("offline-queue-file", f.Name()) + v.Set("sync-offline-activity", 100) + v.Set("plugin", plugin) + + code, err := offlinesync.RunWithRateLimiting(v) + require.NoError(t, err) + + assert.Equal(t, exitcode.Success, code) + assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) +} + +func TestRunWithoutRateLimiting(t *testing.T) { + testServerURL, router, tearDown := setupTestServer() + defer tearDown() + + var ( + plugin = "plugin/0.0.1" + numCalls int + ) + + router.HandleFunc("/users/current/heartbeats.bulk", func(w http.ResponseWriter, req *http.Request) { + numCalls++ + + // check request + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, []string{"application/json"}, req.Header["Accept"]) + assert.Equal(t, []string{"application/json"}, req.Header["Content-Type"]) + assert.Equal(t, []string{"Basic MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAw"}, req.Header["Authorization"]) + assert.True(t, strings.HasSuffix(req.Header["User-Agent"][0], plugin), fmt.Sprintf( + "%q should have suffix %q", + req.Header["User-Agent"][0], + plugin, + )) + + expectedBody, err := os.ReadFile("testdata/api_heartbeats_request_template.json") + require.NoError(t, err) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedBody), string(body)) + + // send response + w.WriteHeader(http.StatusCreated) + + f, err := os.Open("testdata/api_heartbeats_response.json") + require.NoError(t, err) + defer f.Close() + + _, err = io.Copy(w, f) + require.NoError(t, err) + }) + + // setup offline queue + f, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + + db, err := bolt.Open(f.Name(), 0600, nil) + require.NoError(t, err) + + dataGo, err := os.ReadFile("testdata/heartbeat_go.json") + require.NoError(t, err) + + dataPy, err := os.ReadFile("testdata/heartbeat_py.json") + require.NoError(t, err) + + dataJs, err := os.ReadFile("testdata/heartbeat_js.json") + require.NoError(t, err) + + insertHeartbeatRecords(t, db, "heartbeats", []heartbeatRecord{ + { + ID: "1592868367.219124-file-coding-wakatime-cli-heartbeat-/tmp/main.go-true", + Heartbeat: string(dataGo), + }, + { + ID: "1592868386.079084-file-debugging-wakatime-summary-/tmp/main.py-false", + Heartbeat: string(dataPy), + }, + { + ID: "1592868394.084354-file-building-wakatime-todaygoal-/tmp/main.js-false", + Heartbeat: string(dataJs), + }, + }) + + err = db.Close() + require.NoError(t, err) + + v := viper.New() + v.Set("api-url", testServerURL) + v.Set("key", "00000000-0000-4000-8000-000000000000") + v.Set("offline-queue-file", f.Name()) + v.Set("sync-offline-activity", 100) + v.Set("plugin", plugin) + + code, err := offlinesync.RunWithoutRateLimiting(v) + require.NoError(t, err) + + assert.Equal(t, exitcode.Success, code) + assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) +} + +func TestRunWithRateLimiting_RateLimited(t *testing.T) { + v := viper.New() + v.Set("key", "00000000-0000-4000-8000-000000000000") + v.Set("heartbeat-rate-limit-seconds", 500) + v.Set("internal.heartbeats_last_sent_at", time.Now().Add(-time.Minute).Format(time.RFC3339)) + + code, err := offlinesync.RunWithRateLimiting(v) + require.NoError(t, err) + + assert.Equal(t, exitcode.Success, code) +} + func TestSyncOfflineActivity(t *testing.T) { testServerURL, router, tearDown := setupTestServer() defer tearDown() @@ -108,6 +301,95 @@ func TestSyncOfflineActivity(t *testing.T) { assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) } +func TestSyncOfflineActivity_QueueFileFromConfig(t *testing.T) { + testServerURL, router, tearDown := setupTestServer() + defer tearDown() + + var ( + plugin = "plugin/0.0.1" + numCalls int + ) + + router.HandleFunc("/users/current/heartbeats.bulk", func(w http.ResponseWriter, req *http.Request) { + numCalls++ + + // check request + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, []string{"application/json"}, req.Header["Accept"]) + assert.Equal(t, []string{"application/json"}, req.Header["Content-Type"]) + assert.Equal(t, []string{"Basic MDAwMDAwMDAtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAw"}, req.Header["Authorization"]) + assert.True(t, strings.HasSuffix(req.Header["User-Agent"][0], plugin), fmt.Sprintf( + "%q should have suffix %q", + req.Header["User-Agent"][0], + plugin, + )) + + expectedBody, err := os.ReadFile("testdata/api_heartbeats_request_template.json") + require.NoError(t, err) + + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + assert.JSONEq(t, string(expectedBody), string(body)) + + // send response + w.WriteHeader(http.StatusCreated) + + f, err := os.Open("testdata/api_heartbeats_response.json") + require.NoError(t, err) + defer f.Close() + + _, err = io.Copy(w, f) + require.NoError(t, err) + }) + + // setup offline queue + f, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + + db, err := bolt.Open(f.Name(), 0600, nil) + require.NoError(t, err) + + dataGo, err := os.ReadFile("testdata/heartbeat_go.json") + require.NoError(t, err) + + dataPy, err := os.ReadFile("testdata/heartbeat_py.json") + require.NoError(t, err) + + dataJs, err := os.ReadFile("testdata/heartbeat_js.json") + require.NoError(t, err) + + insertHeartbeatRecords(t, db, "heartbeats", []heartbeatRecord{ + { + ID: "1592868367.219124-file-coding-wakatime-cli-heartbeat-/tmp/main.go-true", + Heartbeat: string(dataGo), + }, + { + ID: "1592868386.079084-file-debugging-wakatime-summary-/tmp/main.py-false", + Heartbeat: string(dataPy), + }, + { + ID: "1592868394.084354-file-building-wakatime-todaygoal-/tmp/main.js-false", + Heartbeat: string(dataJs), + }, + }) + + err = db.Close() + require.NoError(t, err) + + v := viper.New() + v.Set("api-url", testServerURL) + v.Set("key", "00000000-0000-4000-8000-000000000000") + v.Set("offline-queue-file", f.Name()) + v.Set("sync-offline-activity", 100) + v.Set("plugin", plugin) + + err = offlinesync.SyncOfflineActivity(v, "/another/file") + require.NoError(t, err) + + assert.Eventually(t, func() bool { return numCalls == 1 }, time.Second, 50*time.Millisecond) +} + func TestSyncOfflineActivity_MultipleApiKey(t *testing.T) { testServerURL, router, tearDown := setupTestServer() defer tearDown() diff --git a/cmd/params/params.go b/cmd/params/params.go index eea875f8..f43a3c30 100644 --- a/cmd/params/params.go +++ b/cmd/params/params.go @@ -131,9 +131,11 @@ type ( // Offline contains offline related parameters. Offline struct { Disabled bool + LastSentAt time.Time PrintMax int QueueFile string QueueFileLegacy string + RateLimit time.Duration SyncMax int } @@ -647,6 +649,13 @@ func LoadOfflineParams(v *viper.Viper) Offline { disabled = !b } + rateLimit, _ := vipertools.FirstNonEmptyInt(v, "heartbeat-rate-limit-seconds", "settings.heartbeat_rate_limit_seconds") + if rateLimit < 0 { + log.Warnf("argument --heartbeat-rate-limit-seconds must be zero or a positive integer number, got %d", rateLimit) + + rateLimit = 0 + } + syncMax := v.GetInt("sync-offline-activity") if syncMax < 0 { log.Warnf("argument --sync-offline-activity must be zero or a positive integer number, got %d", syncMax) @@ -654,11 +663,25 @@ func LoadOfflineParams(v *viper.Viper) Offline { syncMax = 0 } + var lastSentAt time.Time + + lastSentAtStr := vipertools.GetString(v, "internal.heartbeats_last_sent_at") + if lastSentAtStr != "" { + parsed, err := time.Parse(ini.DateFormat, lastSentAtStr) + if err != nil { + log.Warnf("failed to parse heartbeats_last_sent_at: %s", err) + } else { + lastSentAt = parsed + } + } + return Offline{ Disabled: disabled, + LastSentAt: lastSentAt, PrintMax: v.GetInt("print-offline-heartbeats"), QueueFile: vipertools.GetString(v, "offline-queue-file"), QueueFileLegacy: vipertools.GetString(v, "offline-queue-file-legacy"), + RateLimit: time.Duration(rateLimit) * time.Second, SyncMax: syncMax, } } @@ -1038,12 +1061,20 @@ func (p Heartbeat) String() string { // String implements fmt.Stringer interface. func (p Offline) String() string { + var lastSentAt string + if !p.LastSentAt.IsZero() { + lastSentAt = p.LastSentAt.Format(ini.DateFormat) + } + return fmt.Sprintf( - "disabled: %t, print max: %d, queue file: '%s', queue file legacy: '%s', num sync max: %d", + "disabled: %t, last sent at: '%s', print max: %d, queue file: '%s', queue file legacy: '%s',"+ + " num rate limit: %d, num sync max: %d", p.Disabled, + lastSentAt, p.PrintMax, p.QueueFile, p.QueueFileLegacy, + p.RateLimit, p.SyncMax, ) } diff --git a/cmd/params/params_test.go b/cmd/params/params_test.go index a7286bc4..cda37cee 100644 --- a/cmd/params/params_test.go +++ b/cmd/params/params_test.go @@ -28,7 +28,7 @@ import ( "gopkg.in/ini.v1" ) -func TestLoadParams_AlternateProject(t *testing.T) { +func TestLoadHeartbeatParams_AlternateProject(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("alternate-project", "web") @@ -39,7 +39,7 @@ func TestLoadParams_AlternateProject(t *testing.T) { assert.Equal(t, "web", params.Project.Alternate) } -func TestLoadParams_AlternateProject_Unset(t *testing.T) { +func TestLoadHeartbeatParams_AlternateProject_Unset(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -49,7 +49,7 @@ func TestLoadParams_AlternateProject_Unset(t *testing.T) { assert.Empty(t, params.Project.Alternate) } -func TestLoadParams_Category(t *testing.T) { +func TestLoadHeartbeatParams_Category(t *testing.T) { tests := map[string]heartbeat.Category{ "advising": heartbeat.AdvisingCategory, "browsing": heartbeat.BrowsingCategory, @@ -85,7 +85,7 @@ func TestLoadParams_Category(t *testing.T) { } } -func TestLoadParams_Category_Default(t *testing.T) { +func TestLoadHeartbeatParams_Category_Default(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -95,7 +95,7 @@ func TestLoadParams_Category_Default(t *testing.T) { assert.Equal(t, heartbeat.CodingCategory, params.Category) } -func TestLoadParams_Category_Invalid(t *testing.T) { +func TestLoadHeartbeatParams_Category_Invalid(t *testing.T) { v := viper.New() v.SetDefault("sync-offline-activity", 1000) v.Set("category", "invalid") @@ -106,7 +106,7 @@ func TestLoadParams_Category_Invalid(t *testing.T) { assert.Equal(t, "failed to parse category: invalid category \"invalid\"", err.Error()) } -func TestLoadParams_CursorPosition(t *testing.T) { +func TestLoadHeartbeatParams_CursorPosition(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("cursorpos", 42) @@ -117,7 +117,7 @@ func TestLoadParams_CursorPosition(t *testing.T) { assert.Equal(t, 42, *params.CursorPosition) } -func TestLoadParams_CursorPosition_Zero(t *testing.T) { +func TestLoadHeartbeatParams_CursorPosition_Zero(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("cursorpos", 0) @@ -128,7 +128,7 @@ func TestLoadParams_CursorPosition_Zero(t *testing.T) { assert.Zero(t, *params.CursorPosition) } -func TestLoadParams_CursorPosition_Unset(t *testing.T) { +func TestLoadHeartbeatParams_CursorPosition_Unset(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -139,7 +139,7 @@ func TestLoadParams_CursorPosition_Unset(t *testing.T) { assert.Nil(t, params.CursorPosition) } -func TestLoadParams_Entity_EntityFlagTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_Entity_EntityFlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("file", "ignored") @@ -150,7 +150,7 @@ func TestLoadParams_Entity_EntityFlagTakesPrecedence(t *testing.T) { assert.Equal(t, "/path/to/file", params.Entity) } -func TestLoadParams_Entity_FileFlag(t *testing.T) { +func TestLoadHeartbeatParams_Entity_FileFlag(t *testing.T) { v := viper.New() v.Set("file", "~/path/to/file") @@ -163,7 +163,7 @@ func TestLoadParams_Entity_FileFlag(t *testing.T) { assert.Equal(t, filepath.Join(home, "/path/to/file"), params.Entity) } -func TestLoadParams_Entity_Unset(t *testing.T) { +func TestLoadHeartbeatParams_Entity_Unset(t *testing.T) { v := viper.New() _, err := paramscmd.LoadHeartbeatParams(v) @@ -172,7 +172,7 @@ func TestLoadParams_Entity_Unset(t *testing.T) { assert.Equal(t, "failed to retrieve entity", err.Error()) } -func TestLoadParams_EntityType(t *testing.T) { +func TestLoadHeartbeatParams_EntityType(t *testing.T) { tests := map[string]heartbeat.EntityType{ "file": heartbeat.FileType, "domain": heartbeat.DomainType, @@ -193,7 +193,7 @@ func TestLoadParams_EntityType(t *testing.T) { } } -func TestLoadParams_EntityType_Default(t *testing.T) { +func TestLoadHeartbeatParams_EntityType_Default(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -203,7 +203,7 @@ func TestLoadParams_EntityType_Default(t *testing.T) { assert.Equal(t, heartbeat.FileType, params.EntityType) } -func TestLoadParams_EntityType_Invalid(t *testing.T) { +func TestLoadHeartbeatParams_EntityType_Invalid(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("entity-type", "invalid") @@ -217,7 +217,7 @@ func TestLoadParams_EntityType_Invalid(t *testing.T) { err.Error()) } -func TestLoadParams_ExtraHeartbeats(t *testing.T) { +func TestLoadHeartbeatParams_ExtraHeartbeats(t *testing.T) { r, w, err := os.Pipe() require.NoError(t, err) @@ -289,7 +289,7 @@ func TestLoadParams_ExtraHeartbeats(t *testing.T) { }, params.ExtraHeartbeats) } -func TestLoadParams_ExtraHeartbeats_WithStringValues(t *testing.T) { +func TestLoadHeartbeatParams_ExtraHeartbeats_WithStringValues(t *testing.T) { r, w, err := os.Pipe() require.NoError(t, err) @@ -356,7 +356,7 @@ func TestLoadParams_ExtraHeartbeats_WithStringValues(t *testing.T) { }, params.ExtraHeartbeats) } -func TestLoadParams_ExtraHeartbeats_WithEOF(t *testing.T) { +func TestLoadHeartbeatParams_ExtraHeartbeats_WithEOF(t *testing.T) { r, w, err := os.Pipe() require.NoError(t, err) @@ -429,7 +429,7 @@ func TestLoadParams_ExtraHeartbeats_WithEOF(t *testing.T) { }, params.ExtraHeartbeats) } -func TestLoadParams_ExtraHeartbeats_NoData(t *testing.T) { +func TestLoadHeartbeatParams_ExtraHeartbeats_NoData(t *testing.T) { r, w, err := os.Pipe() require.NoError(t, err) @@ -502,7 +502,7 @@ func TestLoadHeartbeat_GuessLanguage_Default(t *testing.T) { assert.False(t, params.GuessLanguage) } -func TestLoadParams_IsUnsavedEntity(t *testing.T) { +func TestLoadHeartbeatParams_IsUnsavedEntity(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("is-unsaved-entity", true) @@ -513,7 +513,7 @@ func TestLoadParams_IsUnsavedEntity(t *testing.T) { assert.True(t, params.IsUnsavedEntity) } -func TestLoadParams_IsWrite(t *testing.T) { +func TestLoadHeartbeatParams_IsWrite(t *testing.T) { tests := map[string]bool{ "is write": true, "is no write": false, @@ -533,7 +533,7 @@ func TestLoadParams_IsWrite(t *testing.T) { } } -func TestLoadParams_IsWrite_Unset(t *testing.T) { +func TestLoadHeartbeatParams_IsWrite_Unset(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -543,7 +543,7 @@ func TestLoadParams_IsWrite_Unset(t *testing.T) { assert.Nil(t, params.IsWrite) } -func TestLoadParams_Language(t *testing.T) { +func TestLoadHeartbeatParams_Language(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("language", "Go") @@ -554,7 +554,7 @@ func TestLoadParams_Language(t *testing.T) { assert.Equal(t, heartbeat.LanguageGo.String(), *params.Language) } -func TestLoadParams_LanguageAlternate(t *testing.T) { +func TestLoadHeartbeatParams_LanguageAlternate(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("alternate-language", "Go") @@ -566,7 +566,7 @@ func TestLoadParams_LanguageAlternate(t *testing.T) { assert.Nil(t, params.Language) } -func TestLoadParams_LineNumber(t *testing.T) { +func TestLoadHeartbeatParams_LineNumber(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("lineno", 42) @@ -577,7 +577,7 @@ func TestLoadParams_LineNumber(t *testing.T) { assert.Equal(t, 42, *params.LineNumber) } -func TestLoadParams_LineNumber_Zero(t *testing.T) { +func TestLoadHeartbeatParams_LineNumber_Zero(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("lineno", 0) @@ -588,7 +588,7 @@ func TestLoadParams_LineNumber_Zero(t *testing.T) { assert.Zero(t, *params.LineNumber) } -func TestLoadParams_LineNumber_Unset(t *testing.T) { +func TestLoadHeartbeatParams_LineNumber_Unset(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -598,7 +598,7 @@ func TestLoadParams_LineNumber_Unset(t *testing.T) { assert.Nil(t, params.LineNumber) } -func TestLoadParams_LocalFile(t *testing.T) { +func TestLoadHeartbeatParams_LocalFile(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("local-file", "/path/to/file") @@ -609,7 +609,7 @@ func TestLoadParams_LocalFile(t *testing.T) { assert.Equal(t, "/path/to/file", params.LocalFile) } -func TestLoadParams_Project(t *testing.T) { +func TestLoadHeartbeatParams_Project(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("project", "billing") @@ -620,7 +620,7 @@ func TestLoadParams_Project(t *testing.T) { assert.Equal(t, "billing", params.Project.Override) } -func TestLoadParams_Project_Unset(t *testing.T) { +func TestLoadHeartbeatParams_Project_Unset(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -630,7 +630,7 @@ func TestLoadParams_Project_Unset(t *testing.T) { assert.Empty(t, params.Project.Override) } -func TestLoadParams_ProjectMap(t *testing.T) { +func TestLoadHeartbeatParams_ProjectMap(t *testing.T) { tests := map[string]struct { Entity string Regex regex.Regex @@ -675,7 +675,7 @@ func TestLoadParams_ProjectMap(t *testing.T) { } } -func TestLoadParams_ProjectApiKey(t *testing.T) { +func TestLoadAPIParams_ProjectApiKey(t *testing.T) { tests := map[string]struct { Entity string Regex regex.Regex @@ -733,7 +733,7 @@ func TestLoadParams_ProjectApiKey(t *testing.T) { } } -func TestLoadParams_ProjectApiKey_ParseConfig(t *testing.T) { +func TestLoadAPIParams_ProjectApiKey_ParseConfig(t *testing.T) { v := viper.New() v.Set("config", "testdata/.wakatime.cfg") v.Set("entity", "testdata/heartbeat_go.json") @@ -757,7 +757,15 @@ func TestLoadParams_ProjectApiKey_ParseConfig(t *testing.T) { assert.Equal(t, expected, params.KeyPatterns) } -func TestLoadParams_Time(t *testing.T) { +func TestLoadAPIParams_APIKeyPrefixSupported(t *testing.T) { + v := viper.New() + v.Set("key", "waka_00000000-0000-4000-8000-000000000000") + + _, err := paramscmd.LoadAPIParams(v) + require.NoError(t, err) +} + +func TestLoadHeartbeatParams_Time(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("time", 1590609206.1) @@ -768,7 +776,7 @@ func TestLoadParams_Time(t *testing.T) { assert.Equal(t, 1590609206.1, params.Time) } -func TestLoadParams_Time_Default(t *testing.T) { +func TestLoadHeartbeatParams_Time_Default(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") @@ -780,7 +788,7 @@ func TestLoadParams_Time_Default(t *testing.T) { assert.GreaterOrEqual(t, params.Time, now-60) } -func TestLoadParams_Filter_Exclude(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Exclude(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("exclude", []string{".*", "wakatime.*"}) @@ -799,7 +807,7 @@ func TestLoadParams_Filter_Exclude(t *testing.T) { assert.Equal(t, "(?i)wakatime.?", params.Filter.Exclude[5].String()) } -func TestLoadParams_Filter_Exclude_Multiline(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Exclude_Multiline(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.ignore", "\t.?\n\twakatime.? \t\n") @@ -812,7 +820,7 @@ func TestLoadParams_Filter_Exclude_Multiline(t *testing.T) { assert.Equal(t, "(?i)wakatime.?", params.Filter.Exclude[1].String()) } -func TestLoadParams_Filter_Exclude_IgnoresInvalidRegex(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Exclude_IgnoresInvalidRegex(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("exclude", []string{".*", "["}) @@ -824,7 +832,7 @@ func TestLoadParams_Filter_Exclude_IgnoresInvalidRegex(t *testing.T) { assert.Equal(t, "(?i).*", params.Filter.Exclude[0].String()) } -func TestLoadParams_Filter_Exclude_PerlRegexPatterns(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Exclude_PerlRegexPatterns(t *testing.T) { tests := map[string]string{ "negative lookahead": `^/var/(?!www/).*`, "positive lookahead": `^/var/(?=www/).*`, @@ -845,7 +853,7 @@ func TestLoadParams_Filter_Exclude_PerlRegexPatterns(t *testing.T) { } } -func TestLoadParams_Filter_ExcludeUnknownProject(t *testing.T) { +func TestLoadHeartbeatParams_Filter_ExcludeUnknownProject(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("exclude-unknown-project", true) @@ -856,7 +864,7 @@ func TestLoadParams_Filter_ExcludeUnknownProject(t *testing.T) { assert.True(t, params.Filter.ExcludeUnknownProject) } -func TestLoadParams_Filter_ExcludeUnknownProject_FromConfig(t *testing.T) { +func TestLoadHeartbeatParams_Filter_ExcludeUnknownProject_FromConfig(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("exclude-unknown-project", false) @@ -868,7 +876,7 @@ func TestLoadParams_Filter_ExcludeUnknownProject_FromConfig(t *testing.T) { assert.True(t, params.Filter.ExcludeUnknownProject) } -func TestLoadParams_Filter_Include(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Include(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("include", []string{".*", "wakatime.*"}) @@ -884,7 +892,7 @@ func TestLoadParams_Filter_Include(t *testing.T) { assert.Equal(t, "(?i)wakatime.+", params.Filter.Include[3].String()) } -func TestLoadParams_Filter_Include_IgnoresInvalidRegex(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Include_IgnoresInvalidRegex(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("include", []string{".*", "["}) @@ -896,7 +904,7 @@ func TestLoadParams_Filter_Include_IgnoresInvalidRegex(t *testing.T) { assert.Equal(t, "(?i).*", params.Filter.Include[0].String()) } -func TestLoadParams_Filter_Include_PerlRegexPatterns(t *testing.T) { +func TestLoadHeartbeatParams_Filter_Include_PerlRegexPatterns(t *testing.T) { tests := map[string]string{ "negative lookahead": `^/var/(?!www/).*`, "positive lookahead": `^/var/(?=www/).*`, @@ -917,7 +925,7 @@ func TestLoadParams_Filter_Include_PerlRegexPatterns(t *testing.T) { } } -func TestLoadParams_Filter_IncludeOnlyWithProjectFile(t *testing.T) { +func TestLoadHeartbeatParams_Filter_IncludeOnlyWithProjectFile(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("include-only-with-project-file", true) @@ -928,7 +936,7 @@ func TestLoadParams_Filter_IncludeOnlyWithProjectFile(t *testing.T) { assert.True(t, params.Filter.IncludeOnlyWithProjectFile) } -func TestLoadParams_Filter_IncludeOnlyWithProjectFile_FromConfig(t *testing.T) { +func TestLoadHeartbeatParams_Filter_IncludeOnlyWithProjectFile_FromConfig(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("include-only-with-project-file", false) @@ -940,7 +948,7 @@ func TestLoadParams_Filter_IncludeOnlyWithProjectFile_FromConfig(t *testing.T) { assert.True(t, params.Filter.IncludeOnlyWithProjectFile) } -func TestLoadParams_SanitizeParams_HideBranchNames_True(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_True(t *testing.T) { tests := map[string]string{ "lowercase": "true", "uppercase": "TRUE", @@ -963,7 +971,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_True(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideBranchNames_False(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_False(t *testing.T) { tests := map[string]string{ "lowercase": "false", "uppercase": "FALSE", @@ -986,7 +994,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_False(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideBranchNames_List(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_List(t *testing.T) { tests := map[string]struct { ViperValue string Expected []regex.Regex @@ -1022,7 +1030,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_List(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideBranchNames_FlagTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-branch-names", true) @@ -1038,7 +1046,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_FlagTakesPrecedence(t *testin }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideBranchNames_ConfigTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_branch_names", "true") @@ -1053,7 +1061,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_ConfigTakesPrecedence(t *test }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideBranchNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_branchnames", "true") @@ -1067,7 +1075,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_ConfigDeprecatedOneTakesPrece }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideBranchNames_ConfigDeprecatedTwo(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_ConfigDeprecatedTwo(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hidebranchnames", "true") @@ -1080,7 +1088,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_ConfigDeprecatedTwo(t *testin }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideBranchNames_InvalidRegex(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideBranchNames_InvalidRegex(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-branch-names", ".*secret.*\n[0-9+") @@ -1096,7 +1104,7 @@ func TestLoadParams_SanitizeParams_HideBranchNames_InvalidRegex(t *testing.T) { )) } -func TestLoadParams_SanitizeParams_HideProjectNames_True(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_True(t *testing.T) { tests := map[string]string{ "lowercase": "true", "uppercase": "TRUE", @@ -1119,7 +1127,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_True(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideProjectNames_False(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_False(t *testing.T) { tests := map[string]string{ "lowercase": "false", "uppercase": "FALSE", @@ -1142,7 +1150,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_False(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideProjecthNames_List(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjecthNames_List(t *testing.T) { tests := map[string]struct { ViperValue string Expected []regex.Regex @@ -1178,7 +1186,7 @@ func TestLoadParams_SanitizeParams_HideProjecthNames_List(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideProjectNames_FlagTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-project-names", "true") @@ -1194,7 +1202,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_FlagTakesPrecedence(t *testi }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideProjectNames_ConfigTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_project_names", "true") @@ -1209,7 +1217,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_ConfigTakesPrecedence(t *tes }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideProjectNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_projectnames", "true") @@ -1223,7 +1231,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_ConfigDeprecatedOneTakesPrec }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideProjectNames_ConfigDeprecatedTwo(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_ConfigDeprecatedTwo(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hideprojectnames", "true") @@ -1236,7 +1244,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_ConfigDeprecatedTwo(t *testi }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideProjectNames_InvalidRegex(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectNames_InvalidRegex(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-project-names", ".*secret.*\n[0-9+") @@ -1252,7 +1260,7 @@ func TestLoadParams_SanitizeParams_HideProjectNames_InvalidRegex(t *testing.T) { )) } -func TestLoadParams_SanitizeParams_HideFileNames_True(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_True(t *testing.T) { tests := map[string]string{ "lowercase": "true", "uppercase": "TRUE", @@ -1275,7 +1283,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_True(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideFileNames_False(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_False(t *testing.T) { tests := map[string]string{ "lowercase": "false", "uppercase": "FALSE", @@ -1298,7 +1306,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_False(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideFileNames_List(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_List(t *testing.T) { tests := map[string]struct { ViperValue string Expected []regex.Regex @@ -1334,7 +1342,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_List(t *testing.T) { } } -func TestLoadParams_SanitizeParams_HideFileNames_FlagTakesPrecedence(t *testing.T) { +func TestLoadheartbeatParams_SanitizeParams_HideFileNames_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-file-names", "true") @@ -1352,7 +1360,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_FlagTakesPrecedence(t *testing. }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_FlagDeprecatedOneTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_FlagDeprecatedOneTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-filenames", "true") @@ -1369,7 +1377,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_FlagDeprecatedOneTakesPrecedenc }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_FlagDeprecatedTwoTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_FlagDeprecatedTwoTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hidefilenames", "true") @@ -1385,7 +1393,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_FlagDeprecatedTwoTakesPrecedenc }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_ConfigTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_file_names", "true") @@ -1400,7 +1408,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_ConfigTakesPrecedence(t *testin }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_ConfigDeprecatedOneTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_filenames", "true") @@ -1414,7 +1422,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_ConfigDeprecatedOneTakesPrecede }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_ConfigDeprecatedTwo(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_ConfigDeprecatedTwo(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hidefilenames", "true") @@ -1427,7 +1435,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_ConfigDeprecatedTwo(t *testing. }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideFileNames_InvalidRegex(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideFileNames_InvalidRegex(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-file-names", ".*secret.*\n[0-9+") @@ -1443,7 +1451,7 @@ func TestLoadParams_SanitizeParams_HideFileNames_InvalidRegex(t *testing.T) { )) } -func TestLoadParams_SanitizeParams_HideProjectFolder(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectFolder(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("hide-project-folder", true) @@ -1456,7 +1464,7 @@ func TestLoadParams_SanitizeParams_HideProjectFolder(t *testing.T) { }, params.Sanitize) } -func TestLoadParams_SanitizeParams_HideProjectFolder_ConfigTakesPrecedence(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_HideProjectFolder_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("settings.hide_project_folder", true) @@ -1469,7 +1477,7 @@ func TestLoadParams_SanitizeParams_HideProjectFolder_ConfigTakesPrecedence(t *te }, params.Sanitize) } -func TestLoadParams_SanitizeParams_OverrideProjectPath(t *testing.T) { +func TestLoadHeartbeatParams_SanitizeParams_OverrideProjectPath(t *testing.T) { v := viper.New() v.Set("entity", "/path/to/file") v.Set("project-folder", "/custom-path") @@ -1482,7 +1490,7 @@ func TestLoadParams_SanitizeParams_OverrideProjectPath(t *testing.T) { }, params.Sanitize) } -func TestLoadParams_SubmodulesDisabled_True(t *testing.T) { +func TestLoadHeartbeatParams_SubmodulesDisabled_True(t *testing.T) { tests := map[string]string{ "lowercase": "true", "uppercase": "TRUE", @@ -1503,7 +1511,7 @@ func TestLoadParams_SubmodulesDisabled_True(t *testing.T) { } } -func TestLoadParams_SubmodulesDisabled_False(t *testing.T) { +func TestLoadHeartbeatParams_SubmodulesDisabled_False(t *testing.T) { tests := map[string]string{ "lowercase": "false", "uppercase": "FALSE", @@ -1524,7 +1532,7 @@ func TestLoadParams_SubmodulesDisabled_False(t *testing.T) { } } -func TestLoadParams_SubmodulesDisabled_List(t *testing.T) { +func TestLoadHeartbeatsParams_SubmodulesDisabled_List(t *testing.T) { tests := map[string]struct { ViperValue string Expected []regex.Regex @@ -1559,7 +1567,7 @@ func TestLoadParams_SubmodulesDisabled_List(t *testing.T) { } } -func TestLoadParams_SubmoduleProjectMap(t *testing.T) { +func TestLoadHeartbeatsParams_SubmoduleProjectMap(t *testing.T) { tests := map[string]struct { Entity string Regex regex.Regex @@ -1604,7 +1612,7 @@ func TestLoadParams_SubmoduleProjectMap(t *testing.T) { } } -func TestLoadParams_Plugin(t *testing.T) { +func TestLoadAPIParams_Plugin(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("plugin", "plugin/10.0.0") @@ -1615,7 +1623,7 @@ func TestLoadParams_Plugin(t *testing.T) { assert.Equal(t, "plugin/10.0.0", params.Plugin) } -func TestLoadParams_Plugin_Unset(t *testing.T) { +func TestLoadAPIParams_Plugin_Unset(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -1625,7 +1633,7 @@ func TestLoadParams_Plugin_Unset(t *testing.T) { assert.Empty(t, params.Plugin) } -func TestLoadParams_Timeout_FlagTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_Timeout_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("timeout", 5) @@ -1637,7 +1645,7 @@ func TestLoadParams_Timeout_FlagTakesPrecedence(t *testing.T) { assert.Equal(t, 5*time.Second, params.Timeout) } -func TestLoadParams_Timeout_FromConfig(t *testing.T) { +func TestLoadAPIParams_Timeout_FromConfig(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.timeout", 10) @@ -1648,7 +1656,7 @@ func TestLoadParams_Timeout_FromConfig(t *testing.T) { assert.Equal(t, 10*time.Second, params.Timeout) } -func TestLoad_OfflineDisabled_ConfigTakesPrecedence(t *testing.T) { +func TestLoadOfflineParams_Disabled_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("disable-offline", false) v.Set("disableoffline", false) @@ -1659,7 +1667,7 @@ func TestLoad_OfflineDisabled_ConfigTakesPrecedence(t *testing.T) { assert.True(t, params.Disabled) } -func TestLoad_OfflineDisabled_FlagDeprecatedTakesPrecedence(t *testing.T) { +func TestLoadOfflineParams_Disabled_FlagDeprecatedTakesPrecedence(t *testing.T) { v := viper.New() v.Set("disable-offline", false) v.Set("disableoffline", true) @@ -1669,7 +1677,7 @@ func TestLoad_OfflineDisabled_FlagDeprecatedTakesPrecedence(t *testing.T) { assert.True(t, params.Disabled) } -func TestLoad_OfflineDisabled_FromFlag(t *testing.T) { +func TestLoadOfflineParams_Disabled_FromFlag(t *testing.T) { v := viper.New() v.Set("disable-offline", true) @@ -1678,7 +1686,83 @@ func TestLoad_OfflineDisabled_FromFlag(t *testing.T) { assert.True(t, params.Disabled) } -func TestLoad_OfflineQueueFile(t *testing.T) { +func TestLoadOfflineParams_RateLimit_FlagTakesPrecedence(t *testing.T) { + v := viper.New() + v.Set("heartbeat-rate-limit-seconds", 5) + v.Set("settings.heartbeat_rate_limit_seconds", 10) + + params := paramscmd.LoadOfflineParams(v) + + assert.Equal(t, time.Duration(5)*time.Second, params.RateLimit) +} + +func TestLoadOfflineParams_RateLimit_FromConfig(t *testing.T) { + v := viper.New() + v.Set("settings.heartbeat_rate_limit_seconds", 10) + + params := paramscmd.LoadOfflineParams(v) + + assert.Equal(t, time.Duration(10)*time.Second, params.RateLimit) +} + +func TestLoadOfflineParams_RateLimit_Zero(t *testing.T) { + v := viper.New() + v.Set("heartbeat-rate-limit-seconds", "0") + + params := paramscmd.LoadOfflineParams(v) + + assert.Zero(t, params.RateLimit) +} + +func TestLoadOfflineParams_RateLimit_Default(t *testing.T) { + v := viper.New() + v.SetDefault("heartbeat-rate-limit-seconds", 20) + + params := paramscmd.LoadOfflineParams(v) + + assert.Equal(t, time.Duration(20)*time.Second, params.RateLimit) +} + +func TestLoadOfflineParams_RateLimit_NegativeNumber(t *testing.T) { + v := viper.New() + v.Set("heartbeat-rate-limit-seconds", -1) + + params := paramscmd.LoadOfflineParams(v) + + assert.Zero(t, params.RateLimit) +} + +func TestLoadOfflineParams_RateLimit_NonIntegerValue(t *testing.T) { + v := viper.New() + v.Set("heartbeat-rate-limit-seconds", "invalid") + + params := paramscmd.LoadOfflineParams(v) + + assert.Zero(t, params.RateLimit) +} + +func TestLoadOfflineParams_LastSentAt(t *testing.T) { + v := viper.New() + v.Set("internal.heartbeats_last_sent_at", "2021-08-30T18:50:42-03:00") + + params := paramscmd.LoadOfflineParams(v) + + lastSentAt, err := time.Parse(inipkg.DateFormat, "2021-08-30T18:50:42-03:00") + require.NoError(t, err) + + assert.Equal(t, lastSentAt, params.LastSentAt) +} + +func TestLoadOfflineParams_LastSentAt_Err(t *testing.T) { + v := viper.New() + v.Set("internal.heartbeats_last_sent_at", "2021-08-30") + + params := paramscmd.LoadOfflineParams(v) + + assert.Zero(t, params.LastSentAt) +} + +func TestLoadOfflineParams_QueueFile(t *testing.T) { v := viper.New() v.Set("offline-queue-file", "/path/to/file") @@ -1687,7 +1771,7 @@ func TestLoad_OfflineQueueFile(t *testing.T) { assert.Equal(t, "/path/to/file", params.QueueFile) } -func TestLoad_OfflineQueueFileLegacy(t *testing.T) { +func TestLoadOfflineParams_QueueFileLegacy(t *testing.T) { v := viper.New() v.Set("offline-queue-file-legacy", "/path/to/file") @@ -1696,7 +1780,7 @@ func TestLoad_OfflineQueueFileLegacy(t *testing.T) { assert.Equal(t, "/path/to/file", params.QueueFileLegacy) } -func TestLoad_OfflineSyncMax(t *testing.T) { +func TestLoadOfflineParams_SyncMax(t *testing.T) { v := viper.New() v.Set("sync-offline-activity", 42) @@ -1705,7 +1789,7 @@ func TestLoad_OfflineSyncMax(t *testing.T) { assert.Equal(t, 42, params.SyncMax) } -func TestLoad_OfflineSyncMax_Zero(t *testing.T) { +func TestLoadOfflineParams_SyncMax_Zero(t *testing.T) { v := viper.New() v.Set("sync-offline-activity", "0") @@ -1714,7 +1798,7 @@ func TestLoad_OfflineSyncMax_Zero(t *testing.T) { assert.Zero(t, params.SyncMax) } -func TestLoad_OfflineSyncMax_Default(t *testing.T) { +func TestLoadOfflineParams_SyncMax_Default(t *testing.T) { v := viper.New() v.SetDefault("sync-offline-activity", 1000) @@ -1723,25 +1807,25 @@ func TestLoad_OfflineSyncMax_Default(t *testing.T) { assert.Equal(t, 1000, params.SyncMax) } -func TestLoad_OfflineSyncMax_NegativeNumber(t *testing.T) { +func TestLoadOfflineParams_SyncMax_NegativeNumber(t *testing.T) { v := viper.New() v.Set("sync-offline-activity", -1) params := paramscmd.LoadOfflineParams(v) - assert.Equal(t, 0, params.SyncMax) + assert.Zero(t, params.SyncMax) } -func TestLoad_OfflineSyncMax_NonIntegerValue(t *testing.T) { +func TestLoadOfflineParams_SyncMax_NonIntegerValue(t *testing.T) { v := viper.New() v.Set("sync-offline-activity", "invalid") params := paramscmd.LoadOfflineParams(v) - assert.Equal(t, 0, params.SyncMax) + assert.Zero(t, params.SyncMax) } -func TestLoad_API_APIKey(t *testing.T) { +func TestLoadAPIParams_APIKey(t *testing.T) { tests := map[string]struct { ViperAPIKey string ViperAPIKeyConfig string @@ -1793,7 +1877,7 @@ func TestLoad_API_APIKey(t *testing.T) { } } -func TestLoad_API_APIKeyUnset(t *testing.T) { +func TestLoadAPIParams_APIKeyUnset(t *testing.T) { v := viper.New() v.Set("key", "") @@ -1807,7 +1891,7 @@ func TestLoad_API_APIKeyUnset(t *testing.T) { assert.EqualError(t, errauth, "api key not found or empty") } -func TestLoad_API_APIKeyInvalid(t *testing.T) { +func TestLoadAPIParams_APIKeyInvalid(t *testing.T) { tests := map[string]string{ "invalid format 1": "not-uuid", "invalid format 2": "00000000-0000-0000-8000-000000000000", @@ -1830,7 +1914,7 @@ func TestLoad_API_APIKeyInvalid(t *testing.T) { } } -func TestLoadParams_ApiKey_SettingTakePrecedence(t *testing.T) { +func TestLoadAPIParams_ApiKey_SettingTakePrecedence(t *testing.T) { v := viper.New() v.Set("config", "testdata/.wakatime.cfg") v.Set("entity", "testdata/heartbeat_go.json") @@ -1847,7 +1931,7 @@ func TestLoadParams_ApiKey_SettingTakePrecedence(t *testing.T) { assert.Equal(t, "00000000-0000-4000-8000-000000000000", params.Key) } -func TestLoadParams_ApiKey_FromVault(t *testing.T) { +func TestLoadAPIParams_ApiKey_FromVault(t *testing.T) { v := viper.New() v.Set("config", "testdata/.wakatime-vault.cfg") v.Set("entity", "testdata/heartbeat_go.json") @@ -1884,7 +1968,7 @@ func TestLoadParams_ApiKey_FromVault_Err_Darwin(t *testing.T) { assert.EqualError(t, err, "failed to read api key from vault: exit status 1") } -func TestLoad_API_APIKeyFromEnv(t *testing.T) { +func TestLoadAPIParams_APIKeyFromEnv(t *testing.T) { v := viper.New() err := os.Setenv("WAKATIME_API_KEY", "00000000-0000-4000-8000-000000000000") @@ -1898,7 +1982,7 @@ func TestLoad_API_APIKeyFromEnv(t *testing.T) { assert.Equal(t, "00000000-0000-4000-8000-000000000000", params.Key) } -func TestLoad_API_APIKeyFromEnvInvalid(t *testing.T) { +func TestLoadAPIParams_APIKeyFromEnvInvalid(t *testing.T) { v := viper.New() err := os.Setenv("WAKATIME_API_KEY", "00000000-0000-4000-0000-000000000000") @@ -1916,7 +2000,7 @@ func TestLoad_API_APIKeyFromEnvInvalid(t *testing.T) { assert.EqualError(t, errauth, "invalid api key format") } -func TestLoad_API_APIKeyFromEnv_ConfigTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_APIKeyFromEnv_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("settings.api_key", "00000000-0000-4000-8000-000000000000") @@ -1931,7 +2015,7 @@ func TestLoad_API_APIKeyFromEnv_ConfigTakesPrecedence(t *testing.T) { assert.Equal(t, "00000000-0000-4000-8000-000000000000", params.Key) } -func TestLoad_API_APIUrl(t *testing.T) { +func TestLoadAPIParams_APIUrl(t *testing.T) { tests := map[string]struct { ViperAPIUrl string ViperAPIUrlConfig string @@ -2008,7 +2092,7 @@ func TestLoad_API_APIUrl(t *testing.T) { } } -func TestLoad_APIUrl_Default(t *testing.T) { +func TestLoadAPIParams_Url_Default(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2018,7 +2102,7 @@ func TestLoad_APIUrl_Default(t *testing.T) { assert.Equal(t, api.BaseURL, params.URL) } -func TestLoad_APIUrl_InvalidFormat(t *testing.T) { +func TestLoadAPIParams_Url_InvalidFormat(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("api-url", "http://in valid") @@ -2032,7 +2116,7 @@ func TestLoad_APIUrl_InvalidFormat(t *testing.T) { assert.EqualError(t, errauth, `invalid api url: parse "http://in valid": invalid character " " in host name`) } -func TestLoad_API_BackoffAt(t *testing.T) { +func TestLoadAPIParams_BackoffAt(t *testing.T) { v := viper.New() v.Set("hostname", "my-computer") v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2054,7 +2138,7 @@ func TestLoad_API_BackoffAt(t *testing.T) { }, params) } -func TestLoad_API_BackoffAtErr(t *testing.T) { +func TestLoadAPIParams_BackoffAtErr(t *testing.T) { v := viper.New() v.Set("hostname", "my-computer") v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2073,47 +2157,7 @@ func TestLoad_API_BackoffAtErr(t *testing.T) { }, params) } -func TestLoad_API_Plugin(t *testing.T) { - v := viper.New() - v.Set("hostname", "my-computer") - v.Set("key", "00000000-0000-4000-8000-000000000000") - v.Set("plugin", "plugin/10.0.0") - - params, err := paramscmd.LoadAPIParams(v) - require.NoError(t, err) - - assert.Equal(t, paramscmd.API{ - Key: "00000000-0000-4000-8000-000000000000", - URL: "https://api.wakatime.com/api/v1", - Plugin: "plugin/10.0.0", - Hostname: "my-computer", - }, params) -} - -func TestLoad_API_Timeout_FlagTakesPrecedence(t *testing.T) { - v := viper.New() - v.Set("key", "00000000-0000-4000-8000-000000000000") - v.Set("timeout", 5) - v.Set("settings.timeout", 10) - - params, err := paramscmd.LoadAPIParams(v) - require.NoError(t, err) - - assert.Equal(t, 5*time.Second, params.Timeout) -} - -func TestLoad_API_Timeout_FromConfig(t *testing.T) { - v := viper.New() - v.Set("key", "00000000-0000-4000-8000-000000000000") - v.Set("settings.timeout", 10) - - params, err := paramscmd.LoadAPIParams(v) - require.NoError(t, err) - - assert.Equal(t, 10*time.Second, params.Timeout) -} - -func TestLoad_API_DisableSSLVerify_FlagTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_DisableSSLVerify_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("no-ssl-verify", true) @@ -2125,7 +2169,7 @@ func TestLoad_API_DisableSSLVerify_FlagTakesPrecedence(t *testing.T) { assert.True(t, params.DisableSSLVerify) } -func TestLoad_API_DisableSSLVerify_FromConfig(t *testing.T) { +func TestLoadAPIParams_DisableSSLVerify_FromConfig(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.no_ssl_verify", true) @@ -2136,7 +2180,7 @@ func TestLoad_API_DisableSSLVerify_FromConfig(t *testing.T) { assert.True(t, params.DisableSSLVerify) } -func TestLoad_API_DisableSSLVerify_Default(t *testing.T) { +func TestLoadAPIParams_DisableSSLVerify_Default(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2146,7 +2190,7 @@ func TestLoad_API_DisableSSLVerify_Default(t *testing.T) { assert.False(t, params.DisableSSLVerify) } -func TestLoad_API_ProxyURL(t *testing.T) { +func TestLoadAPIParams_ProxyURL(t *testing.T) { tests := map[string]string{ "https": "https://john:secret@example.org:8888", "http": "http://john:secret@example.org:8888", @@ -2169,7 +2213,7 @@ func TestLoad_API_ProxyURL(t *testing.T) { } } -func TestLoad_API_ProxyURL_FlagTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_ProxyURL_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("proxy", "https://john:secret@example.org:8888") @@ -2181,7 +2225,7 @@ func TestLoad_API_ProxyURL_FlagTakesPrecedence(t *testing.T) { assert.Equal(t, "https://john:secret@example.org:8888", params.ProxyURL) } -func TestLoad_API_ProxyURL_UserDefinedTakesPrecedenceOverEnvironment(t *testing.T) { +func TestLoadAPIParams_ProxyURL_UserDefinedTakesPrecedenceOverEnvironment(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("proxy", "https://john:secret@example.org:8888") @@ -2197,7 +2241,7 @@ func TestLoad_API_ProxyURL_UserDefinedTakesPrecedenceOverEnvironment(t *testing. assert.Equal(t, "https://john:secret@example.org:8888", params.ProxyURL) } -func TestLoad_API_ProxyURL_FromConfig(t *testing.T) { +func TestLoadAPIParams_ProxyURL_FromConfig(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.proxy", "https://john:secret@example.org:8888") @@ -2208,7 +2252,7 @@ func TestLoad_API_ProxyURL_FromConfig(t *testing.T) { assert.Equal(t, "https://john:secret@example.org:8888", params.ProxyURL) } -func TestLoad_API_ProxyURL_FromEnvironment(t *testing.T) { +func TestLoadAPIParams_ProxyURL_FromEnvironment(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2223,7 +2267,7 @@ func TestLoad_API_ProxyURL_FromEnvironment(t *testing.T) { assert.Equal(t, "https://john:secret@example.org:8888", params.ProxyURL) } -func TestLoad_API_ProxyURL_NoProxyFromEnvironment(t *testing.T) { +func TestLoadAPIParams_ProxyURL_NoProxyFromEnvironment(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2238,7 +2282,7 @@ func TestLoad_API_ProxyURL_NoProxyFromEnvironment(t *testing.T) { assert.Empty(t, params.ProxyURL) } -func TestLoad_API_ProxyURL_InvalidFormat(t *testing.T) { +func TestLoadAPIParams_ProxyURL_InvalidFormat(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("proxy", "ftp://john:secret@example.org:8888") @@ -2256,7 +2300,7 @@ func TestLoad_API_ProxyURL_InvalidFormat(t *testing.T) { " 'socks5://user:pass@host:port' or 'domain\\\\user:pass.'") } -func TestLoad_API_SSLCertFilepath_FlagTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_SSLCertFilepath_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("ssl-certs-file", "~/path/to/cert.pem") @@ -2270,7 +2314,7 @@ func TestLoad_API_SSLCertFilepath_FlagTakesPrecedence(t *testing.T) { assert.Equal(t, filepath.Join(home, "/path/to/cert.pem"), params.SSLCertFilepath) } -func TestLoad_API_SSLCertFilepath_FromConfig(t *testing.T) { +func TestLoadAPIParams_SSLCertFilepath_FromConfig(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.ssl_certs_file", "/path/to/cert.pem") @@ -2281,7 +2325,7 @@ func TestLoad_API_SSLCertFilepath_FromConfig(t *testing.T) { assert.Equal(t, "/path/to/cert.pem", params.SSLCertFilepath) } -func TestLoadParams_Hostname_FlagTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_Hostname_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("hostname", "my-machine") @@ -2298,7 +2342,7 @@ func TestLoadParams_Hostname_FlagTakesPrecedence(t *testing.T) { assert.Equal(t, "my-machine", params.Hostname) } -func TestLoadParams_Hostname_FromConfig(t *testing.T) { +func TestLoadAPIParams_Hostname_FromConfig(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.hostname", "my-machine") @@ -2309,7 +2353,7 @@ func TestLoadParams_Hostname_FromConfig(t *testing.T) { assert.Equal(t, "my-machine", params.Hostname) } -func TestLoadParams_Hostname_FromConfig_ConfigTakesPrecedence(t *testing.T) { +func TestLoadAPIParams_Hostname_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") v.Set("settings.hostname", "my-machine") @@ -2325,7 +2369,7 @@ func TestLoadParams_Hostname_FromConfig_ConfigTakesPrecedence(t *testing.T) { assert.Equal(t, "my-machine", params.Hostname) } -func TestLoadParams_Hostname_FromGitpodEnv(t *testing.T) { +func TestLoadAPIParams_Hostname_FromGitpodEnv(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2340,7 +2384,7 @@ func TestLoadParams_Hostname_FromGitpodEnv(t *testing.T) { assert.Equal(t, "Gitpod", params.Hostname) } -func TestLoadParams_Hostname_DefaultFromSystem(t *testing.T) { +func TestLoadAPIParams_Hostname_DefaultFromSystem(t *testing.T) { v := viper.New() v.Set("key", "00000000-0000-4000-8000-000000000000") @@ -2353,7 +2397,7 @@ func TestLoadParams_Hostname_DefaultFromSystem(t *testing.T) { assert.Equal(t, expected, params.Hostname) } -func TestLoadParams_StatusBar_HideCategories_FlagTakesPrecedence(t *testing.T) { +func TestLoadStatusBarParams_HideCategories_FlagTakesPrecedence(t *testing.T) { v := viper.New() v.Set("today-hide-categories", true) v.Set("settings.status_bar_hide_categories", "ignored") @@ -2364,7 +2408,7 @@ func TestLoadParams_StatusBar_HideCategories_FlagTakesPrecedence(t *testing.T) { assert.True(t, params.HideCategories) } -func TestLoadParams_StatusBar_HideCategories_ConfigTakesPrecedence(t *testing.T) { +func TestLoadStatusBarParams_HideCategories_ConfigTakesPrecedence(t *testing.T) { v := viper.New() v.Set("settings.status_bar_hide_categories", true) @@ -2374,7 +2418,7 @@ func TestLoadParams_StatusBar_HideCategories_ConfigTakesPrecedence(t *testing.T) assert.True(t, params.HideCategories) } -func TestLoadParams_StatusBar_Output(t *testing.T) { +func TestLoadStatusBarParams_Output(t *testing.T) { tests := map[string]output.Output{ "text": output.TextOutput, "json": output.JSONOutput, @@ -2393,7 +2437,7 @@ func TestLoadParams_StatusBar_Output(t *testing.T) { } } -func TestLoadParams_StatusBar_Output_Default(t *testing.T) { +func TestLoadStatusBarParams_Output_Default(t *testing.T) { v := viper.New() params, err := paramscmd.LoadStatusBarParams(v) @@ -2402,7 +2446,7 @@ func TestLoadParams_StatusBar_Output_Default(t *testing.T) { assert.Equal(t, output.TextOutput, params.Output) } -func TestLoadParams_StatusBar_Output_Invalid(t *testing.T) { +func TestLoadStatusBarParams_Output_Invalid(t *testing.T) { v := viper.New() v.Set("output", "invalid") @@ -2495,18 +2539,24 @@ func TestHeartbeat_String(t *testing.T) { } func TestOffline_String(t *testing.T) { + lastSentAt, err := time.Parse(inipkg.DateFormat, "2021-08-30T18:50:42-03:00") + require.NoError(t, err) + offline := paramscmd.Offline{ Disabled: true, + LastSentAt: lastSentAt, PrintMax: 6, QueueFile: "/path/to/queue.file", QueueFileLegacy: "/path/to/legacy.file", + RateLimit: 15, SyncMax: 12, } assert.Equal( t, - "disabled: true, print max: 6, queue file: '/path/to/queue.file',"+ - " queue file legacy: '/path/to/legacy.file', num sync max: 12", + "disabled: true, last sent at: '2021-08-30T18:50:42-03:00', print max: 6,"+ + " queue file: '/path/to/queue.file', queue file legacy: '/path/to/legacy.file',"+ + " num rate limit: 15, num sync max: 12", offline.String(), ) } @@ -2530,7 +2580,7 @@ func TestProjectParams_String(t *testing.T) { ) } -func TestLoadParams_ProjectFromGitRemote(t *testing.T) { +func TestLoadHeartbeatParams_ProjectFromGitRemote(t *testing.T) { v := viper.New() v.Set("git.project_from_git_remote", true) v.Set("entity", "/path/to/file") @@ -2571,14 +2621,6 @@ func TestStatusBar_String(t *testing.T) { ) } -func TestLoadParams_APIKeyPrefixSupported(t *testing.T) { - v := viper.New() - v.Set("key", "waka_00000000-0000-4000-8000-000000000000") - - _, err := paramscmd.LoadAPIParams(v) - require.NoError(t, err) -} - func captureLogs(dest io.Writer) func() { // set verbose log.SetVerbose(true) diff --git a/cmd/root.go b/cmd/root.go index c0dc86f3..516dfd06 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -124,6 +124,13 @@ func setFlags(cmd *cobra.Command, v *viper.Viper) { "guess-language", false, "Enable detecting language from file contents.") + flags.Int( + "heartbeat-rate-limit-seconds", + offline.RateLimitDefaultSeconds, + fmt.Sprintf("Only sync heartbeats to the API once per these seconds, instead"+ + " saving to the offline db. Defaults to %d. Use zero to disable.", + offline.RateLimitDefaultSeconds), + ) flags.String("hide-branch-names", "", "Obfuscate branch names. Will not send revision control branch names to api.") flags.String("hide-file-names", "", "Obfuscate filenames. Will not send file names to api.") flags.String("hide-filenames", "", "(deprecated) Obfuscate filenames. Will not send file names to api.") diff --git a/cmd/run.go b/cmd/run.go index 25811c73..73b4f97a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -138,7 +138,7 @@ func RunE(cmd *cobra.Command, v *viper.Viper) error { if v.IsSet("sync-offline-activity") { log.Debugln("command: sync-offline-activity") - return RunCmd(v, logFileParams.Verbose, logFileParams.SendDiagsOnErrors, offlinesync.Run) + return RunCmd(v, logFileParams.Verbose, logFileParams.SendDiagsOnErrors, offlinesync.RunWithoutRateLimiting) } if v.GetBool("offline-count") { @@ -273,7 +273,7 @@ func RunCmdWithOfflineSync(v *viper.Viper, verbose bool, sendDiagsOnErrors bool, return err } - return runCmd(v, verbose, sendDiagsOnErrors, offlinesync.Run) + return runCmd(v, verbose, sendDiagsOnErrors, offlinesync.RunWithRateLimiting) } // runCmd contains the main logic of RunCmd. diff --git a/pkg/offline/offline.go b/pkg/offline/offline.go index 59de11c9..1f4ed907 100644 --- a/pkg/offline/offline.go +++ b/pkg/offline/offline.go @@ -27,6 +27,9 @@ const ( maxRequeueAttempts = 3 // PrintMaxDefault is the default maximum number of heartbeats to print. PrintMaxDefault = 10 + // RateLimitDefaultSeconds is the default seconds between sending heartbeats + // to the API. If not enough time has passed, heartbeats are saved to the offline queue. + RateLimitDefaultSeconds = 120 // SendLimit is the maximum number of heartbeats, which will be sent at once // to the WakaTime API. SendLimit = 25