diff --git a/cmd/dump.go b/cmd/dump.go index 3583e92..de6d13a 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -6,13 +6,10 @@ package cmd import ( "context" "encoding/json" - "errors" "log/slog" "os" - "github.com/karl-cardenas-coding/mywhoop/export" "github.com/karl-cardenas-coding/mywhoop/internal" - "github.com/karl-cardenas-coding/mywhoop/notifications" "github.com/spf13/cobra" ) @@ -52,6 +49,7 @@ func dump(ctx context.Context) error { } cfg := Configuration + cfg.Server.Enabled = false ok, token, err := internal.VerfyToken(cfg.Credentials.CredentialsFile) if err != nil { @@ -63,25 +61,9 @@ func dump(ctx context.Context) error { os.Exit(1) } - var notificationMethod internal.Notification - - switch cfg.Notification.Method { - case "ntfy": - ntfy := notifications.NewNtfy() - ntfy.ServerEndpoint = cfg.Notification.Ntfy.ServerEndpoint - ntfy.SubscriptionID = cfg.Notification.Ntfy.SubscriptionID - ntfy.UserName = cfg.Notification.Ntfy.UserName - ntfy.Events = cfg.Notification.Ntfy.Events - err = ntfy.SetUp() - if err != nil { - return err - } - notificationMethod = ntfy - slog.Info("Ntfy notification method configured") - default: - slog.Info("no notification method specified. Defaulting to stdout.") - std := notifications.NewStdout() - notificationMethod = std + notificationMethod, err := determineNotificationExtension(cfg) + if err != nil { + return err } if filter != "" { @@ -172,83 +154,28 @@ func dump(ctx context.Context) error { } return err } - var filePath string - switch cfg.Export.Method { - case "file": - - if dataLocation == "" { - filePath = Configuration.Export.FileExport.FilePath - } else { - filePath = dataLocation - } - - fileExp := export.NewFileExport(filePath, - Configuration.Export.FileExport.FileType, - Configuration.Export.FileExport.FileName, - Configuration.Export.FileExport.FileNamePrefix, - false, - ) - err = fileExp.Export(finalDataRaw) - if err != nil { - notifyErr := notificationMethod.Publish(client, []byte(err.Error()), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - return err - } - slog.Info("Data exported successfully", "file", fileExp.FileName) - case "s3": - - if dataLocation != "" { - cfg.Export.AWSS3.FileConfig.FilePath = dataLocation - } - - awsS3, err := export.NewAwsS3Export(cfg.Export.AWSS3.Region, - cfg.Export.AWSS3.Bucket, - cfg.Export.AWSS3.Profile, - client, - &cfg.Export.AWSS3.FileConfig, - false, - ) - if err != nil { - return errors.New("unable initialize AWS S3 export. Additional error context: " + err.Error()) - } - err = awsS3.Export(finalDataRaw) - if err != nil { - notifyErr := notificationMethod.Publish(client, []byte(err.Error()), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - return errors.New("unable to export data to AWS S3. Additional error context: " + err.Error()) - } - - default: - - if dataLocation == "" { - filePath = Configuration.Export.FileExport.FilePath - } else { - filePath = dataLocation + exporterMethod, err := determineExporterExtension(cfg, client, dataLocation) + if err != nil { + slog.Error("unable to determine export method", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(err.Error()), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) } + return err + } - slog.Info("no export method specified. Defaulting to file.") - fileExp := export.NewFileExport(filePath, - Configuration.Export.FileExport.FileType, - Configuration.Export.FileExport.FileName, - Configuration.Export.FileExport.FileNamePrefix, - false, - ) - err = fileExp.Export(finalDataRaw) - if err != nil { - notifyErr := notificationMethod.Publish(client, []byte(err.Error()), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - return err + err = exporterMethod.Export(finalDataRaw) + if err != nil { + slog.Error("unable to export data", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(err.Error()), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) } - + return err } - slog.Info("All Whoop data downloaded successfully") + + slog.Info("All Whoop data downloaded and exported successfully") if notificationMethod != nil { err = notificationMethod.Publish(client, []byte("Successfully downloaded all Whoop data."), internal.EventSuccess.String()) if err != nil { diff --git a/cmd/extensions.go b/cmd/extensions.go new file mode 100644 index 0000000..ae44533 --- /dev/null +++ b/cmd/extensions.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/karl-cardenas-coding/mywhoop/export" + "github.com/karl-cardenas-coding/mywhoop/internal" + "github.com/karl-cardenas-coding/mywhoop/notifications" +) + +// determineExtension determines the notification extension to use and returns the appropriate notification. +func determineNotificationExtension(cfg internal.ConfigurationData) (internal.Notification, error) { + + var notificationMethod internal.Notification + + switch cfg.Notification.Method { + case "ntfy": + ntfy := notifications.NewNtfy() + ntfy.ServerEndpoint = cfg.Notification.Ntfy.ServerEndpoint + ntfy.SubscriptionID = cfg.Notification.Ntfy.SubscriptionID + ntfy.UserName = cfg.Notification.Ntfy.UserName + ntfy.Events = cfg.Notification.Ntfy.Events + err := ntfy.SetUp() + if err != nil { + return notificationMethod, err + } + slog.Info("Ntfy notification method configured") + notificationMethod = ntfy + + default: + slog.Info("no notification method specified. Defaulting to stdout.") + std := notifications.NewStdout() + notificationMethod = std + } + + return notificationMethod, nil + +} + +// determineExporterExtension determines the export extension to use and returns the appropriate export. +// The paramter isServerMode is used to determine if the exporter is being used in server mode. Use this flag to set server mode defaults. +func determineExporterExtension(cfg internal.ConfigurationData, client *http.Client, dataLocation string) (internal.Export, error) { + + var ( + filePath string + exporter internal.Export + ) + + switch cfg.Export.Method { + case "file": + if dataLocation == "" { + filePath = cfg.Export.FileExport.FilePath + } + + if dataLocation != "" { + filePath = dataLocation + } + + fileExp := export.NewFileExport(filePath, + cfg.Export.FileExport.FileType, + cfg.Export.FileExport.FileName, + cfg.Export.FileExport.FileNamePrefix, + cfg.Server.Enabled, + ) + slog.Info("File export method specified") + exporter = fileExp + + case "s3": + slog.Info("AWS S3 export method specified") + if dataLocation != "" { + cfg.Export.AWSS3.FileConfig.FilePath = dataLocation + } + + awsS3, err := export.NewAwsS3Export(cfg.Export.AWSS3.Region, + cfg.Export.AWSS3.Bucket, + cfg.Export.AWSS3.Profile, + client, + &cfg.Export.AWSS3.FileConfig, + cfg.Server.Enabled, + ) + if err != nil { + return exporter, errors.New("unable initialize AWS S3 export. Additional error context: " + err.Error()) + } + exporter = awsS3 + + default: + if dataLocation == "" { + filePath = cfg.Export.FileExport.FilePath + } else { + filePath = dataLocation + } + slog.Info("no valid export method specified. Defaulting to file.") + + fileExp := export.NewFileExport(filePath, + cfg.Export.FileExport.FileType, + cfg.Export.FileExport.FileName, + cfg.Export.FileExport.FileNamePrefix, + cfg.Server.Enabled, + ) + exporter = fileExp + + } + + return exporter, nil + +} diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go new file mode 100644 index 0000000..73f7c84 --- /dev/null +++ b/cmd/extensions_test.go @@ -0,0 +1,251 @@ +// Copyright (c) karl-cardenas-coding +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "net/http" + "os" + "testing" + + "github.com/karl-cardenas-coding/mywhoop/export" + "github.com/karl-cardenas-coding/mywhoop/internal" + "github.com/karl-cardenas-coding/mywhoop/notifications" +) + +func TestDetermineExporterExtension(t *testing.T) { + + client := internal.CreateHTTPClient() + + tests := []struct { + name string + cfg internal.ConfigurationData + dataLocation string + client *http.Client + expextedError bool + expectedType interface{} + setEnvCreds bool + setAWScreds bool + }{ + { + name: "file with datalocation", + cfg: internal.ConfigurationData{}, + dataLocation: "data/", + expectedType: &export.FileExport{ + FileType: "json", + FileName: "user", + FileNamePrefix: "test_", + }, + expextedError: false, + }, + { + name: "file", + dataLocation: "", + cfg: internal.ConfigurationData{ + Export: internal.ConfigExport{ + Method: "file", + FileExport: export.FileExport{ + FilePath: "data/", + FileType: "json", + FileName: "user", + FileNamePrefix: "test_", + }, + }, + }, + expectedType: &export.FileExport{}, + expextedError: false, + }, + { + name: "aws", + expextedError: false, + dataLocation: "", + setAWScreds: true, + cfg: internal.ConfigurationData{ + Export: internal.ConfigExport{ + Method: "s3", + AWSS3: export.AWS_S3{ + Region: "us-west-2", + Bucket: "mybucket", + FileConfig: export.FileExport{}, + }, + }, + }, + }, + { + name: "aws with datalocation", + expextedError: false, + dataLocation: "whoopdata", + setAWScreds: true, + cfg: internal.ConfigurationData{ + Export: internal.ConfigExport{ + Method: "s3", + AWSS3: export.AWS_S3{ + Region: "us-west-2", + Bucket: "mybucket", + FileConfig: export.FileExport{}, + }, + }, + }, + }, + { + name: "aws with error", + expextedError: true, + setEnvCreds: false, + setAWScreds: false, + cfg: internal.ConfigurationData{ + Export: internal.ConfigExport{ + Method: "s3", + AWSS3: export.AWS_S3{ + FileConfig: export.FileExport{}, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.setEnvCreds { + setEnvCreds(false, false, test.setAWScreds) + } + + test.client = client + + exporterMethod, err := determineExporterExtension(test.cfg, test.client, dataLocation) + if (err != nil) != test.expextedError { + t.Errorf("expected error: %v, got: %v", test.expextedError, err) + } + + // check if the returned type is the expected type + if test.expectedType != nil { + if _, ok := exporterMethod.(*export.FileExport); ok { + if _, ok := test.expectedType.(*export.FileExport); !ok { + t.Errorf("%s - expected type: %T, got: %T", test.name, test.expectedType, exporterMethod) + } + } + + if _, ok := exporterMethod.(*export.AWS_S3); ok { + if _, ok := test.expectedType.(*export.AWS_S3); !ok { + t.Errorf("%s - expected type: %T, got: %T", test.name, test.expectedType, exporterMethod) + } + } + } + + }) + t.Cleanup(func() { + + os.Unsetenv("AWS_ACCESS_KEY_ID") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") + os.Unsetenv("AWS_DEFAULT_REGION") + + }) + } +} + +func TestDetermineNotificationExtension(t *testing.T) { + + tests := []struct { + name string + cfg internal.ConfigurationData + expextedError bool + setEnvCreds bool + setToken bool + setPassword bool + expectedType interface{} + }{ + { + name: "ntfy", + cfg: internal.ConfigurationData{ + Notification: internal.NotificationConfig{ + Method: "ntfy", + Ntfy: notifications.Ntfy{ + ServerEndpoint: "http://localhost:8080", + SubscriptionID: "1234", + Events: "all", + }, + }, + }, + expextedError: false, + setEnvCreds: true, + setToken: true, + expectedType: ¬ifications.Ntfy{}, + }, + { + name: "ntfy with error", + cfg: internal.ConfigurationData{ + Notification: internal.NotificationConfig{ + Method: "ntfy", + Ntfy: notifications.Ntfy{ + Events: "all", + }, + }, + }, + expextedError: true, + setEnvCreds: true, + setToken: true, + expectedType: ¬ifications.Ntfy{}, + }, + { + name: "no notification method specified", + cfg: internal.ConfigurationData{ + Notification: internal.NotificationConfig{}, + }, + expextedError: false, + expectedType: ¬ifications.Stdout{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.setEnvCreds { + setEnvCreds(test.setPassword, test.setToken, false) + } + notificationMethod, err := determineNotificationExtension(test.cfg) + if (err != nil) != test.expextedError { + t.Errorf("expected error: %v, got: %v", test.expextedError, err) + } + + // check if the returned type is the expected type + if test.expectedType != nil { + if _, ok := notificationMethod.(*notifications.Ntfy); ok { + if _, ok := test.expectedType.(*notifications.Ntfy); !ok { + t.Errorf("expected type: %T, got: %T", test.expectedType, notificationMethod) + } + } + + if _, ok := notificationMethod.(*notifications.Stdout); ok { + if _, ok := test.expectedType.(*notifications.Stdout); !ok { + t.Errorf("expected type: %T, got: %T", test.expectedType, notificationMethod) + } + } + } + + }) + t.Cleanup(func() { + if test.setEnvCreds { + os.Unsetenv("NOTIFICATION_NTFY_PASSWORD") + os.Unsetenv("NOTIFICATION_NTFY_AUTH_TOKEN") + } + }) + } + +} + +func setEnvCreds(setPassword, setToken, setAWS bool) { + + if setPassword { + os.Setenv("NOTIFICATION_NTFY_PASSWORD", "1234") + } + + if setToken { + + os.Setenv("NOTIFICATION_NTFY_AUTH_TOKEN", "abcd") + } + + if setAWS { + os.Setenv("AWS_ACCESS_KEY_ID", "1234") + os.Setenv("AWS_SECRET_ACCESS_KEY", "abcd") + os.Setenv("AWS_DEFAULT_REGION", "us-west-2") + } + +} diff --git a/cmd/login.go b/cmd/login.go index ffcaa53..21a22fe 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -222,9 +222,9 @@ func getStaticAssets(f embed.FS, filePath string) (fs.FS, error) { return fs.Sub(f, filePath) } -// closeHandler closes the application after 2 seconds +// closeHandler closes the CLI application. func closeHandler(w http.ResponseWriter, r *http.Request) { - time.Sleep(2 * time.Second) + time.Sleep(1 * time.Second) _, err := w.Write([]byte("Closing application...")) if err != nil { slog.Error("unable to write response", "error", err) diff --git a/cmd/server.go b/cmd/server.go index 8227f2a..0ffdc4f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -18,9 +18,7 @@ import ( gocron "github.com/go-co-op/gocron/v2" "github.com/google/uuid" - "github.com/karl-cardenas-coding/mywhoop/export" "github.com/karl-cardenas-coding/mywhoop/internal" - "github.com/karl-cardenas-coding/mywhoop/notifications" "github.com/spf13/cobra" "golang.org/x/oauth2" ) @@ -64,6 +62,7 @@ func server(ctx context.Context) error { client := internal.CreateHTTPClient() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + cfg.Server.Enabled = true // Evaluate the configuration options err = evaluateConfigOptions(&cfg) @@ -71,63 +70,24 @@ func server(ctx context.Context) error { slog.Error("unable to evaluate configuration options", "error", err) return err } - var exportSelected internal.Export - // Initialize the data exporters - switch cfg.Export.Method { - case "file": - fileExp := export.NewFileExport(cfg.Export.FileExport.FilePath, - cfg.Export.FileExport.FileType, - cfg.Export.FileExport.FileName, - cfg.Export.FileExport.FileNamePrefix, - true, - ) - - if cfg.Export.FileExport.FileNamePrefix == "" { - cfg.Export.FileExport.FileNamePrefix = "user" - } - - exportSelected = fileExp - case "s3": - awsS3Exp, err := export.NewAwsS3Export(cfg.Export.AWSS3.Region, cfg.Export.AWSS3.Bucket, cfg.Export.AWSS3.Profile, client, &cfg.Export.AWSS3.FileConfig, true) - if err != nil { - slog.Error("unable to initialize AWS S3 export", "error", err) - return err - } - - exportSelected = awsS3Exp - slog.Info("AWS S3 export method specified") - default: - slog.Error("unknown exporter", "exporter", cfg.Export.Method) - return errors.New("unknown exporter") + exportSelected, err := determineExporterExtension(cfg, client, "") + if err != nil { + slog.Error("unable to determine exporter extension", "error", err) + return err } - // Setup the notification method err = exportSelected.Setup() if err != nil { slog.Error("unable to setup data exporter", "error", err) return err } - var notificationMethod internal.Notification - - // Initialize the notification method - switch Configuration.Notification.Method { - case "ntfy": - ntfy := notifications.NewNtfy() - ntfy.ServerEndpoint = cfg.Notification.Ntfy.ServerEndpoint - ntfy.SubscriptionID = cfg.Notification.Ntfy.SubscriptionID - ntfy.UserName = cfg.Notification.Ntfy.UserName - ntfy.Events = cfg.Notification.Ntfy.Events - slog.Info("Ntfy notification method specified") - notificationMethod = ntfy - default: - slog.Info("No notification method specified. Defaulting to stdout.") - std := notifications.NewStdout() - notificationMethod = std - + notificationMethod, err := determineNotificationExtension(cfg) + if err != nil { + slog.Error("unable to determine notification extension", "error", err) + return err } - // Setup the notification method if notificationMethod != nil { err = notificationMethod.SetUp() if err != nil { diff --git a/export/aws_s3_export.go b/export/aws_s3_export.go index dbfcdcc..6537282 100644 --- a/export/aws_s3_export.go +++ b/export/aws_s3_export.go @@ -29,7 +29,7 @@ func NewAwsS3Export(region, bucket, profile string, client *http.Client, f *File if region == "" { - envValue := os.Getenv("AWS_REGION") + envValue := os.Getenv("AWS_DEFAULT_REGION") if envValue == "" { return nil, fmt.Errorf("AWS region is required") } diff --git a/export/aws_s3_export_test.go b/export/aws_s3_export_test.go index c19c1b1..5adea3d 100644 --- a/export/aws_s3_export_test.go +++ b/export/aws_s3_export_test.go @@ -131,14 +131,24 @@ func TestNewAwsS3Export(t *testing.T) { for _, tc := range tests { + // if tc.clearEnv { + + // t.Setenv("AWS_PROFILE", "") + // t.Setenv("AWS_DEFAULT_REGION", "") + // t.Setenv("AWS_ACCESS_KEY_ID", "") + // t.Setenv("AWS_SECRET_ACCESS_KEY", "") + // } + t.Run(tc.description, func(t *testing.T) { if tc.setProfileEnv { os.Setenv("AWS_PROFILE", "test") } + os.Unsetenv("AWS_DEFAULT_REGION") + if tc.setRegionEnv { - os.Setenv("AWS_REGION", "us-east-1") + os.Setenv("AWS_DEFAULT_REGION", "us-east-1") } result, err := NewAwsS3Export(tc.region, tc.bucket, tc.profile, tc.client, tc.f, tc.serverMode) @@ -158,8 +168,9 @@ func TestNewAwsS3Export(t *testing.T) { t.Errorf("%s: Server mode is not set correctly", tc.description) } + }) + t.Cleanup(func() { clearEnvVariables() - }) } @@ -571,7 +582,7 @@ func TestS3CleanUp(t *testing.T) { func clearEnvVariables() { os.Unsetenv("AWS_PROFILE") - os.Unsetenv("AWS_REGION") + os.Unsetenv("AWS_DEFAULT_REGION") } func createMockBucket(s3Client *s3.Client, bucket string) error { diff --git a/export/file_export.go b/export/file_export.go index e0fa3ab..775ba42 100644 --- a/export/file_export.go +++ b/export/file_export.go @@ -55,7 +55,6 @@ func (f *FileExport) Export(data []byte) error { slog.Error("unable to write to file", "error", err) return err } - return nil } diff --git a/internal/endpoints.go b/internal/endpoints.go index bcd3d5e..a58d4fc 100644 --- a/internal/endpoints.go +++ b/internal/endpoints.go @@ -198,7 +198,7 @@ func (u User) GetSleepCollection(ctx context.Context, client *http.Client, url, } } - slog.Debug("Sleep Records", slog.Any("Sleep Records", sleepRecords)) + slog.Debug("Sleep Records", slog.Any("Sleep Records Count", len(sleepRecords))) sleep.SleepCollectionRecords = sleepRecords @@ -295,7 +295,7 @@ func (u User) GetRecoveryCollection(ctx context.Context, client *http.Client, ur } } - slog.Debug("Recovery Records", slog.Any("Recovery Records", recoveryRecords)) + slog.Debug("Recovery Records", slog.Any("Recovery Records Count", len(recoveryRecords))) recovery.RecoveryRecords = recoveryRecords @@ -393,7 +393,7 @@ func (u User) GetWorkoutCollection(ctx context.Context, client *http.Client, url } } - slog.Debug("Workout Records", slog.Any("Workout Records", workoutRecords)) + slog.Debug("Workout Records", slog.Any("Workout Records Count", len(workoutRecords))) workout.Records = workoutRecords return &workout, nil @@ -487,7 +487,7 @@ func (u User) GetCycleCollection(ctx context.Context, client *http.Client, url, } } - slog.Debug("Cycle Records", slog.Any("Cycle Records", cycleRecords)) + slog.Debug("Cycle Records", slog.Any("Cycle Records Count", len(cycleRecords))) cycle.Records = cycleRecords return &cycle, nil