From dcd1eb10ea69fa33d7c1bea5cfe55d52ab3f5564 Mon Sep 17 00:00:00 2001 From: Darko Atanasovski Date: Thu, 17 Jul 2025 11:48:29 +0200 Subject: [PATCH] initial version of feeds v3 importer --- docs/stream-cli_feeds.md | 78 +++++ docs/stream-cli_feeds_import-status.md | 48 +++ docs/stream-cli_feeds_import-validate.md | 32 ++ docs/stream-cli_feeds_import.md | 46 +++ pkg/cmd/feeds/feeds.go | 422 +++++++++++++++++++++++ pkg/cmd/feeds/feeds_test.go | 324 +++++++++++++++++ pkg/cmd/feeds/root.go | 16 + pkg/cmd/root/root.go | 2 + pkg/version/version.go | 4 +- test/activities-only.json | 40 +++ test/feeds-only.json | 45 +++ test/feeds-sample.json | 132 +++++++ test/invalid-feeds.json | 26 ++ test/reactions-only.json | 17 + test/users-only.json | 20 ++ 15 files changed, 1250 insertions(+), 2 deletions(-) create mode 100644 docs/stream-cli_feeds.md create mode 100644 docs/stream-cli_feeds_import-status.md create mode 100644 docs/stream-cli_feeds_import-validate.md create mode 100644 docs/stream-cli_feeds_import.md create mode 100644 pkg/cmd/feeds/feeds.go create mode 100644 pkg/cmd/feeds/feeds_test.go create mode 100644 pkg/cmd/feeds/root.go create mode 100644 test/activities-only.json create mode 100644 test/feeds-only.json create mode 100644 test/feeds-sample.json create mode 100644 test/invalid-feeds.json create mode 100644 test/reactions-only.json create mode 100644 test/users-only.json diff --git a/docs/stream-cli_feeds.md b/docs/stream-cli_feeds.md new file mode 100644 index 0000000..3434b95 --- /dev/null +++ b/docs/stream-cli_feeds.md @@ -0,0 +1,78 @@ +# stream-cli feeds + +Allows you to interact with your Feeds applications + +## Commands + +### stream-cli feeds import-validate + +Validates a JSON file for feeds import. + +This command checks if the file is valid JSON format. + +**Usage:** + +```bash +stream-cli feeds import-validate [filename] +``` + +**Examples:** + +```bash +# Validates a JSON feeds import file +$ stream-cli feeds import-validate feeds-data.json +``` + +### stream-cli feeds import + +Imports feeds data from a JSON file. + +This command uploads the file to S3 and initiates the import process. + +**Usage:** + +```bash +stream-cli feeds import [filename] --apikey [api-key] +``` + +**Flags:** + +- `-k, --apikey string`: [required] API key for authentication +- `-m, --mode string`: [optional] Import mode. Can be upsert or insert (default "upsert") + +**Examples:** + +```bash +# Import feeds data with API key +$ stream-cli feeds import feeds-data.json --apikey your-api-key + +# Import feeds data with custom mode +$ stream-cli feeds import feeds-data.json --apikey your-api-key --mode insert +``` + +### stream-cli feeds import-status + +Checks the status of a feeds import operation. + +You can optionally watch for completion with the --watch flag. + +**Usage:** + +```bash +stream-cli feeds import-status [import-id] +``` + +**Flags:** + +- `-w, --watch`: [optional] Watch import until completion +- `-o, --output-format string`: [optional] Output format. Can be json or tree (default "json") + +**Examples:** + +```bash +# Check import status +$ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 + +# Watch import until completion +$ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 --watch +``` diff --git a/docs/stream-cli_feeds_import-status.md b/docs/stream-cli_feeds_import-status.md new file mode 100644 index 0000000..7a51dba --- /dev/null +++ b/docs/stream-cli_feeds_import-status.md @@ -0,0 +1,48 @@ +# stream-cli feeds import-status + +Checks the status of a feeds import operation. + +You can optionally watch for completion with the --watch flag. + +## Usage + +```bash +stream-cli feeds import-status [import-id] +``` + +## Arguments + +- `import-id`: The ID of the import operation to check + +## Flags + +- `-w, --watch`: [optional] Watch import until completion +- `-o, --output-format string`: [optional] Output format. Can be json or tree (default "json") + +## Examples + +```bash +# Check import status +$ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 + +# Watch import until completion +$ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 --watch +``` + +## Output Formats + +- `json`: Output in JSON format (default) +- `tree`: Output in a browsable tree format + +## Watch Mode + +When using the `--watch` flag, the command will continuously poll the import status every 5 seconds until the import is completed or failed. + +## Output + +The command will output the current status of the import task, including details such as: + +- Import ID +- Status (pending, running, completed, failed) +- Progress information +- Error details (if any) diff --git a/docs/stream-cli_feeds_import-validate.md b/docs/stream-cli_feeds_import-validate.md new file mode 100644 index 0000000..b463b68 --- /dev/null +++ b/docs/stream-cli_feeds_import-validate.md @@ -0,0 +1,32 @@ +# stream-cli feeds import-validate + +Validates a JSON file for feeds import. + +This command checks if the file is valid JSON format. + +## Usage + +```bash +stream-cli feeds import-validate [filename] +``` + +## Arguments + +- `filename`: Path to the JSON file to validate + +## Examples + +```bash +# Validates a JSON feeds import file +$ stream-cli feeds import-validate feeds-data.json +``` + +## Output + +The command will output a success message if the file is valid JSON: + +``` +✅ File 'feeds-data.json' is valid JSON +``` + +If the file is invalid or doesn't exist, an error message will be displayed. diff --git a/docs/stream-cli_feeds_import.md b/docs/stream-cli_feeds_import.md new file mode 100644 index 0000000..4cd91e1 --- /dev/null +++ b/docs/stream-cli_feeds_import.md @@ -0,0 +1,46 @@ +# stream-cli feeds import + +Imports feeds data from a JSON file. + +This command uploads the file to S3 and initiates the import process. + +## Usage + +```bash +stream-cli feeds import [filename] --apikey [api-key] +``` + +## Arguments + +- `filename`: Path to the JSON file to import + +## Flags + +- `-k, --apikey string`: [required] API key for authentication +- `-m, --mode string`: [optional] Import mode. Can be upsert or insert (default "upsert") + +## Examples + +```bash +# Import feeds data with API key +$ stream-cli feeds import feeds-data.json --apikey your-api-key + +# Import feeds data with custom mode +$ stream-cli feeds import feeds-data.json --apikey your-api-key --mode insert +``` + +## Import Modes + +- `upsert`: Updates existing feeds and creates new ones (default) +- `insert`: Only creates new feeds, fails if feed already exists + +## Output + +The command will output the import task details including the import ID: + +``` +✅ Import started successfully +Import ID: dcb6e366-93ec-4e52-af6f-b0c030ad5272 +``` + +The full import task object will also be printed in the specified output format. diff --git a/pkg/cmd/feeds/feeds.go b/pkg/cmd/feeds/feeds.go new file mode 100644 index 0000000..9f420a9 --- /dev/null +++ b/pkg/cmd/feeds/feeds.go @@ -0,0 +1,422 @@ +package feeds + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + stream "github.com/GetStream/stream-chat-go/v5" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/GetStream/stream-cli/pkg/config" + "github.com/GetStream/stream-cli/pkg/utils" +) + +// ImportData represents the structure of feeds import data +type ImportData struct { + Users []User `json:"users,omitempty"` + Feeds []Feed `json:"feeds,omitempty"` + Activities []Activity `json:"activities,omitempty"` + Follows []User `json:"follows,omitempty"` + Members []Member `json:"members,omitempty"` + Comments []User `json:"comments,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` +} + +// User represents a user in the import data +type User struct { + ID string `json:"id"` + Role string `json:"role,omitempty"` + Custom map[string]any `json:"custom,omitempty"` +} + +// Feed represents a feed in the import data +type Feed struct { + GroupID string `json:"group_id"` + ID string `json:"id"` + FID string `json:"fid"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Custom map[string]interface{} `json:"custom,omitempty"` + FilterTags []string `json:"filter_tags,omitempty"` + Visibility string `json:"visibility,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + DeletedAt *int64 `json:"deleted_at,omitempty"` +} + +// Activity represents an activity in the import data +type Activity struct { + ID string `json:"id"` + Type string `json:"type"` + Feeds []string `json:"feeds"` + Visibility string `json:"visibility,omitempty"` + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Attachments []interface{} `json:"attachments,omitempty"` + MentionedUsers []string `json:"mentioned_users,omitempty"` + Custom map[string]interface{} `json:"custom,omitempty"` + Text string `json:"text,omitempty"` + SearchData map[string]interface{} `json:"search_data,omitempty"` + FilterTags []string `json:"filter_tags,omitempty"` + InterestTags []string `json:"interest_tags,omitempty"` +} + +// Member represents a member in the import data +type Member struct { + UserID string `json:"user_id"` + Feed string `json:"feed"` + Custom map[string]any `json:"custom,omitempty"` + Role string `json:"role,omitempty"` +} + +// Reaction represents a reaction in the import data +type Reaction struct { + ActivityID string `json:"activity_id"` + CommentID string `json:"comment_id,omitempty"` + Type string `json:"type"` + UserID string `json:"user_id"` + Custom map[string]any `json:"custom,omitempty"` +} + +func NewCmds() []*cobra.Command { + return []*cobra.Command{ + importValidateCmd(), + importCmd(), + importStatusCmd(), + } +} + +func validateFeedsFile(cmd *cobra.Command, filename string) error { + reader, err := os.Open(filename) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer reader.Close() + + // Parse JSON and validate structure + var data ImportData + decoder := json.NewDecoder(reader) + if err := decoder.Decode(&data); err != nil { + return fmt.Errorf("invalid JSON format: %w", err) + } + + // Validate that at least one section is present + if len(data.Users) == 0 && len(data.Feeds) == 0 && len(data.Activities) == 0 && + len(data.Follows) == 0 && len(data.Members) == 0 && len(data.Comments) == 0 && + len(data.Reactions) == 0 { + return fmt.Errorf("file must contain at least one of: users, feeds, activities, follows, members, comments, or reactions") + } + + // Validate users + if err := validateUsers(data.Users); err != nil { + return fmt.Errorf("users validation failed: %w", err) + } + + // Validate feeds + if err := validateFeeds(data.Feeds); err != nil { + return fmt.Errorf("feeds validation failed: %w", err) + } + + // Validate activities + if err := validateActivities(data.Activities); err != nil { + return fmt.Errorf("activities validation failed: %w", err) + } + + // Validate follows + if err := validateUsers(data.Follows); err != nil { + return fmt.Errorf("follows validation failed: %w", err) + } + + // Validate members + if err := validateMembers(data.Members); err != nil { + return fmt.Errorf("members validation failed: %w", err) + } + + // Validate comments + if err := validateUsers(data.Comments); err != nil { + return fmt.Errorf("comments validation failed: %w", err) + } + + // Validate reactions + if err := validateReactions(data.Reactions); err != nil { + return fmt.Errorf("reactions validation failed: %w", err) + } + + // Only print success message if all validations pass + cmd.Printf("✅ File '%s' is valid JSON with proper feeds import structure\n", filename) + cmd.Printf("📊 Import summary:\n") + if len(data.Users) > 0 { + cmd.Printf(" - Users: %d\n", len(data.Users)) + } + if len(data.Feeds) > 0 { + cmd.Printf(" - Feeds: %d\n", len(data.Feeds)) + } + if len(data.Activities) > 0 { + cmd.Printf(" - Activities: %d\n", len(data.Activities)) + } + if len(data.Follows) > 0 { + cmd.Printf(" - Follows: %d\n", len(data.Follows)) + } + if len(data.Members) > 0 { + cmd.Printf(" - Members: %d\n", len(data.Members)) + } + if len(data.Comments) > 0 { + cmd.Printf(" - Comments: %d\n", len(data.Comments)) + } + if len(data.Reactions) > 0 { + cmd.Printf(" - Reactions: %d\n", len(data.Reactions)) + } + + return nil +} + +func validateUsers(users []User) error { + for i, user := range users { + if user.ID == "" { + return fmt.Errorf("user at index %d: id is required", i) + } + } + return nil +} + +func validateFeeds(feeds []Feed) error { + for i, feed := range feeds { + if feed.ID == "" { + return fmt.Errorf("feed at index %d: id is required", i) + } + if feed.GroupID == "" { + return fmt.Errorf("feed at index %d: group_id is required", i) + } + if feed.FID == "" { + return fmt.Errorf("feed at index %d: fid is required", i) + } + if feed.Name == "" { + return fmt.Errorf("feed at index %d: name is required", i) + } + } + return nil +} + +func validateActivities(activities []Activity) error { + for i, activity := range activities { + if activity.ID == "" { + return fmt.Errorf("activity at index %d: id is required", i) + } + if activity.Type == "" { + return fmt.Errorf("activity at index %d: type is required", i) + } + if len(activity.Feeds) == 0 { + return fmt.Errorf("activity at index %d: feeds is required and cannot be empty", i) + } + } + return nil +} + +func validateMembers(members []Member) error { + for i, member := range members { + if member.UserID == "" { + return fmt.Errorf("member at index %d: user_id is required", i) + } + if member.Feed == "" { + return fmt.Errorf("member at index %d: feed is required", i) + } + } + return nil +} + +func validateReactions(reactions []Reaction) error { + for i, reaction := range reactions { + if reaction.ActivityID == "" { + return fmt.Errorf("reaction at index %d: activity_id is required", i) + } + if reaction.Type == "" { + return fmt.Errorf("reaction at index %d: type is required", i) + } + if reaction.UserID == "" { + return fmt.Errorf("reaction at index %d: user_id is required", i) + } + } + return nil +} + +func importValidateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import-validate [filename]", + Short: "Validate feeds import file", + Long: heredoc.Doc(` + Validates a JSON file for feeds import. + This command checks if the file is valid JSON format. + `), + Example: heredoc.Doc(` + # Validates a JSON feeds import file + $ stream-cli feeds import-validate feeds-data.json + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filename := args[0] + return validateFeedsFile(cmd, filename) + }, + } + + return cmd +} + +func uploadFeedsToS3(ctx context.Context, filename, url string) error { + data, err := os.Open(filename) + if err != nil { + return err + } + defer data.Close() + + stat, err := data.Stat() + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "PUT", url, data) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.ContentLength = stat.Size() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("upload failed with status: %d", resp.StatusCode) + } + + return nil +} + +func importCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import [filename] --apikey [api-key]", + Short: "Import feeds data", + Long: heredoc.Doc(` + Imports feeds data from a JSON file. + This command uploads the file to S3 and initiates the import process. + `), + Example: heredoc.Doc(` + # Import feeds data with API key + $ stream-cli feeds import feeds-data.json --apikey your-api-key + + # Import feeds data with custom mode + $ stream-cli feeds import feeds-data.json --apikey your-api-key --mode insert + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := config.GetConfig(cmd).GetClient(cmd) + if err != nil { + return err + } + + filename := args[0] + + // Validate file first + if err := validateFeedsFile(cmd, filename); err != nil { + return err + } + + // Create import URL + createImportURLResp, err := c.CreateImportURL(cmd.Context(), filepath.Base(filename)) + if err != nil { + return err + } + + // Upload to S3 + if err := uploadFeedsToS3(cmd.Context(), filename, createImportURLResp.UploadURL); err != nil { + return err + } + + // Determine import mode + mode := stream.UpsertMode + if m, _ := cmd.Flags().GetString("mode"); stream.ImportMode(m) == stream.InsertMode { + mode = stream.InsertMode + } + + // Create import + createImportResp, err := c.CreateImport(cmd.Context(), createImportURLResp.Path, mode) + if err != nil { + return err + } + + cmd.Printf("✅ Import started successfully\n") + cmd.Printf("Import ID: %s\n", createImportResp.ImportTask.ID) + + return utils.PrintObject(cmd, createImportResp.ImportTask) + }, + } + + fl := cmd.Flags() + fl.StringP("apikey", "k", "", "[required] API key for authentication") + fl.StringP("mode", "m", "upsert", "[optional] Import mode. Can be upsert or insert") + _ = cmd.MarkFlagRequired("apikey") + + return cmd +} + +func importStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import-status [import-id]", + Short: "Check import status", + Long: heredoc.Doc(` + Checks the status of a feeds import operation. + You can optionally watch for completion with the --watch flag. + `), + Example: heredoc.Doc(` + # Check import status + $ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 + + # Watch import until completion + $ stream-cli feeds import-status dcb6e366-93ec-4e52-af6f-b0c030ad5272 --watch + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := config.GetConfig(cmd).GetClient(cmd) + if err != nil { + return err + } + + id := args[0] + watch, _ := cmd.Flags().GetBool("watch") + + for { + resp, err := c.GetImport(cmd.Context(), id) + if err != nil { + return err + } + + err = utils.PrintObject(cmd, resp.ImportTask) + if err != nil { + return err + } + + if !watch { + break + } + + // Wait before checking again + time.Sleep(5 * time.Second) + } + + return nil + }, + } + + fl := cmd.Flags() + fl.BoolP("watch", "w", false, "[optional] Watch import until completion") + fl.StringP("output-format", "o", "json", "[optional] Output format. Can be json or tree") + + return cmd +} diff --git a/pkg/cmd/feeds/feeds_test.go b/pkg/cmd/feeds/feeds_test.go new file mode 100644 index 0000000..7b429a0 --- /dev/null +++ b/pkg/cmd/feeds/feeds_test.go @@ -0,0 +1,324 @@ +package feeds + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func TestNewCmds(t *testing.T) { + cmds := NewCmds() + if len(cmds) != 3 { + t.Errorf("Expected 3 commands, got %d", len(cmds)) + } + + expectedCommands := []string{"import-validate", "import", "import-status"} + for i, expected := range expectedCommands { + if cmds[i].Name() != expected { + t.Errorf("Expected command %d to be %s, got %s", i, expected, cmds[i].Name()) + } + } +} + +func TestNewRootCmd(t *testing.T) { + cmd := NewRootCmd() + if cmd.Use != "feeds" { + t.Errorf("Expected root command to be 'feeds', got %s", cmd.Use) + } + + if len(cmd.Commands()) != 3 { + t.Errorf("Expected 3 subcommands, got %d", len(cmd.Commands())) + } +} + +func TestValidateFeedsFile(t *testing.T) { + cmd := &cobra.Command{} + + // Change to project root directory for test files + projectRoot, err := findProjectRoot() + if err != nil { + t.Fatalf("Failed to find project root: %v", err) + } + + // Test with nonexistent file + err = validateFeedsFile(cmd, "nonexistent.json") + if err == nil { + t.Error("Expected error for nonexistent file") + } + + // Test with valid complete file + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/feeds-sample.json")) + if err != nil { + t.Errorf("Expected no error for valid file, got: %v", err) + } + + // Test with users only + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/users-only.json")) + if err != nil { + t.Errorf("Expected no error for users-only file, got: %v", err) + } + + // Test with feeds only + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/feeds-only.json")) + if err != nil { + t.Errorf("Expected no error for feeds-only file, got: %v", err) + } + + // Test with activities only + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/activities-only.json")) + if err != nil { + t.Errorf("Expected no error for activities-only file, got: %v", err) + } + + // Test with reactions only + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/reactions-only.json")) + if err != nil { + t.Errorf("Expected no error for reactions-only file, got: %v", err) + } + + // Test with invalid data + err = validateFeedsFile(cmd, filepath.Join(projectRoot, "test/invalid-feeds.json")) + if err == nil { + t.Error("Expected error for invalid file") + } +} + +// findProjectRoot finds the project root directory by looking for go.mod +func findProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", fmt.Errorf("could not find go.mod in any parent directory") + } + dir = parent + } +} + +func TestValidateUsers(t *testing.T) { + // Test valid users + users := []User{ + {ID: "user:1", Role: "user"}, + {ID: "user:2", Role: "admin"}, + } + err := validateUsers(users) + if err != nil { + t.Errorf("Expected no error for valid users, got: %v", err) + } + + // Test invalid user (empty ID) + invalidUsers := []User{ + {ID: "", Role: "user"}, + } + err = validateUsers(invalidUsers) + if err == nil { + t.Error("Expected error for user with empty ID") + } +} + +func TestValidateFeeds(t *testing.T) { + // Test valid feeds + feeds := []Feed{ + {ID: "feed:1", GroupID: "group:1", FID: "fid:1", Name: "user:1"}, + {ID: "feed:2", GroupID: "group:2", FID: "fid:2", Name: "user:2"}, + } + err := validateFeeds(feeds) + if err != nil { + t.Errorf("Expected no error for valid feeds, got: %v", err) + } + + // Test invalid feed (empty ID) + invalidFeeds := []Feed{ + {ID: "", GroupID: "group:1", FID: "fid:1", Name: "user:1"}, + } + err = validateFeeds(invalidFeeds) + if err == nil { + t.Error("Expected error for feed with empty ID") + } + + // Test invalid feed (empty group_id) + invalidFeeds2 := []Feed{ + {ID: "feed:1", GroupID: "", FID: "fid:1", Name: "user:1"}, + } + err = validateFeeds(invalidFeeds2) + if err == nil { + t.Error("Expected error for feed with empty group_id") + } + + // Test invalid feed (empty fid) + invalidFeeds3 := []Feed{ + {ID: "feed:1", GroupID: "group:1", FID: "", Name: "user:1"}, + } + err = validateFeeds(invalidFeeds3) + if err == nil { + t.Error("Expected error for feed with empty fid") + } + + // Test invalid feed (empty name) + invalidFeeds4 := []Feed{ + {ID: "feed:1", GroupID: "group:1", FID: "fid:1", Name: ""}, + } + err = validateFeeds(invalidFeeds4) + if err == nil { + t.Error("Expected error for feed with empty name") + } +} + +func TestValidateActivities(t *testing.T) { + // Test valid activities + activities := []Activity{ + {ID: "activity:1", Type: "post", Feeds: []string{"feed:1"}}, + {ID: "activity:2", Type: "comment", Feeds: []string{"feed:2"}}, + } + err := validateActivities(activities) + if err != nil { + t.Errorf("Expected no error for valid activities, got: %v", err) + } + + // Test invalid activity (empty ID) + invalidActivities := []Activity{ + {ID: "", Type: "post", Feeds: []string{"feed:1"}}, + } + err = validateActivities(invalidActivities) + if err == nil { + t.Error("Expected error for activity with empty ID") + } + + // Test invalid activity (empty type) + invalidActivities2 := []Activity{ + {ID: "activity:1", Type: "", Feeds: []string{"feed:1"}}, + } + err = validateActivities(invalidActivities2) + if err == nil { + t.Error("Expected error for activity with empty type") + } + + // Test invalid activity (empty feeds) + invalidActivities3 := []Activity{ + {ID: "activity:1", Type: "post", Feeds: []string{}}, + } + err = validateActivities(invalidActivities3) + if err == nil { + t.Error("Expected error for activity with empty feeds") + } +} + +func TestValidateMembers(t *testing.T) { + // Test valid members + members := []Member{ + {UserID: "user:1", Feed: "feed:1"}, + {UserID: "user:2", Feed: "feed:2", Role: "member"}, + } + err := validateMembers(members) + if err != nil { + t.Errorf("Expected no error for valid members, got: %v", err) + } + + // Test invalid member (empty user_id) + invalidMembers := []Member{ + {UserID: "", Feed: "feed:1"}, + } + err = validateMembers(invalidMembers) + if err == nil { + t.Error("Expected error for member with empty user_id") + } + + // Test invalid member (empty feed) + invalidMembers2 := []Member{ + {UserID: "user:1", Feed: ""}, + } + err = validateMembers(invalidMembers2) + if err == nil { + t.Error("Expected error for member with empty feed") + } +} + +func TestValidateReactions(t *testing.T) { + // Test valid reactions + reactions := []Reaction{ + {ActivityID: "activity:1", Type: "like", UserID: "user:1"}, + {ActivityID: "activity:2", CommentID: "comment:1", Type: "love", UserID: "user:2"}, + } + err := validateReactions(reactions) + if err != nil { + t.Errorf("Expected no error for valid reactions, got: %v", err) + } + + // Test invalid reaction (empty activity_id) + invalidReactions := []Reaction{ + {ActivityID: "", Type: "like", UserID: "user:1"}, + } + err = validateReactions(invalidReactions) + if err == nil { + t.Error("Expected error for reaction with empty activity_id") + } + + // Test invalid reaction (empty type) + invalidReactions2 := []Reaction{ + {ActivityID: "activity:1", Type: "", UserID: "user:1"}, + } + err = validateReactions(invalidReactions2) + if err == nil { + t.Error("Expected error for reaction with empty type") + } + + // Test invalid reaction (empty user_id) + invalidReactions3 := []Reaction{ + {ActivityID: "activity:1", Type: "like", UserID: ""}, + } + err = validateReactions(invalidReactions3) + if err == nil { + t.Error("Expected error for reaction with empty user_id") + } +} + +func TestImportDataStructure(t *testing.T) { + // Test that ImportData can be marshaled and unmarshaled + data := ImportData{ + Users: []User{ + {ID: "user:1", Role: "user"}, + }, + Feeds: []Feed{ + {ID: "feed:1", GroupID: "group:1", FID: "fid:1", Name: "user:1"}, + }, + Activities: []Activity{ + {ID: "activity:1", Type: "post", Feeds: []string{"feed:1"}}, + }, + } + + // Marshal to JSON + jsonData, err := json.Marshal(data) + if err != nil { + t.Errorf("Failed to marshal ImportData: %v", err) + } + + // Unmarshal back + var unmarshaledData ImportData + err = json.Unmarshal(jsonData, &unmarshaledData) + if err != nil { + t.Errorf("Failed to unmarshal ImportData: %v", err) + } + + // Verify data is preserved + if len(unmarshaledData.Users) != 1 { + t.Errorf("Expected 1 user, got %d", len(unmarshaledData.Users)) + } + if len(unmarshaledData.Feeds) != 1 { + t.Errorf("Expected 1 feed, got %d", len(unmarshaledData.Feeds)) + } + if len(unmarshaledData.Activities) != 1 { + t.Errorf("Expected 1 activity, got %d", len(unmarshaledData.Activities)) + } +} diff --git a/pkg/cmd/feeds/root.go b/pkg/cmd/feeds/root.go new file mode 100644 index 0000000..a14dc38 --- /dev/null +++ b/pkg/cmd/feeds/root.go @@ -0,0 +1,16 @@ +package feeds + +import ( + "github.com/spf13/cobra" +) + +func NewRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "feeds", + Short: "Allows you to interact with your Feeds applications", + } + + cmd.AddCommand(NewCmds()...) + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 4585dcc..af89597 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/GetStream/stream-cli/pkg/cmd/chat" cfgCmd "github.com/GetStream/stream-cli/pkg/cmd/config" + "github.com/GetStream/stream-cli/pkg/cmd/feeds" "github.com/GetStream/stream-cli/pkg/config" "github.com/GetStream/stream-cli/pkg/version" ) @@ -39,6 +40,7 @@ func NewCmd() *cobra.Command { root.AddCommand( cfgCmd.NewRootCmd(), chat.NewRootCmd(), + feeds.NewRootCmd(), ) cobra.OnInitialize(config.GetInitConfig(root, cfgPath)) diff --git a/pkg/version/version.go b/pkg/version/version.go index c9bb241..936d572 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -6,8 +6,8 @@ import ( const ( versionMajor = 1 - versionMinor = 7 - versionPatch = 1 + versionMinor = 8 + versionPatch = 0 ) func FmtVersion() string { diff --git a/test/activities-only.json b/test/activities-only.json new file mode 100644 index 0000000..d486c44 --- /dev/null +++ b/test/activities-only.json @@ -0,0 +1,40 @@ +{ + "activities": [ + { + "id": "19a8aa2b-1e71-447c-9338-556ced2cd7a1", + "type": "post", + "feeds": [ + "user:user-w6p05o" + ], + "visibility": "public", + "created_at": 1752741103506159000, + "updated_at": 1752741108550693000, + "attachments": [], + "mentioned_users": [], + "custom": { + "foo": "bar" + }, + "text": "Hello world!", + "search_data": {}, + "filter_tags": [], + "interest_tags": [] + }, + { + "id": "29b9bb3c-2f82-558d-0449-667dfe3de8b2", + "type": "comment", + "feeds": [ + "user:user-w6p05o" + ], + "visibility": "public", + "created_at": 1752741103506159000, + "updated_at": 1752741108550693000, + "attachments": [], + "mentioned_users": [], + "custom": {}, + "text": "Great post!", + "search_data": {}, + "filter_tags": [], + "interest_tags": [] + } + ] +} \ No newline at end of file diff --git a/test/feeds-only.json b/test/feeds-only.json new file mode 100644 index 0000000..9cfab8a --- /dev/null +++ b/test/feeds-only.json @@ -0,0 +1,45 @@ +{ + "feeds": [ + { + "group_id": "group_123", + "id": "feed_456", + "fid": "fid_789", + "name": "Tech Enthusiasts", + "description": "A group for technology lovers to share insights and news.", + "custom": { + "category": "Technology", + "region": "Global", + "is_featured": true + }, + "filter_tags": [ + "tech", + "gadgets", + "news" + ], + "visibility": "public", + "created_at": 1752741108550693000, + "updated_at": 1752741108550693000, + "deleted_at": null + }, + { + "group_id": "group_456", + "id": "feed_789", + "fid": "fid_012", + "name": "Design Community", + "description": "A community for designers to share their work.", + "custom": { + "category": "Design", + "region": "Global" + }, + "filter_tags": [ + "design", + "art", + "creativity" + ], + "visibility": "private", + "created_at": 1752741108550693000, + "updated_at": 1752741108550693000, + "deleted_at": null + } + ] +} \ No newline at end of file diff --git a/test/feeds-sample.json b/test/feeds-sample.json new file mode 100644 index 0000000..714df9c --- /dev/null +++ b/test/feeds-sample.json @@ -0,0 +1,132 @@ +{ + "users": [ + { + "id": "user:123", + "role": "user", + "custom": { + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "user:456", + "role": "user", + "custom": { + "name": "Jane Smith", + "email": "jane@example.com" + } + } + ], + "feeds": [ + { + "group_id": "group_123", + "id": "feed_456", + "fid": "fid_789", + "name": "Tech Enthusiasts", + "description": "A group for technology lovers to share insights and news.", + "custom": { + "category": "Technology", + "region": "Global", + "is_featured": true + }, + "filter_tags": [ + "tech", + "gadgets", + "news" + ], + "visibility": "public", + "created_at": 1752741108550693000, + "updated_at": 1752741108550693000, + "deleted_at": null + }, + { + "group_id": "group_456", + "id": "feed_789", + "fid": "fid_012", + "name": "Design Community", + "description": "A community for designers to share their work.", + "custom": { + "category": "Design", + "region": "Global" + }, + "filter_tags": [ + "design", + "art", + "creativity" + ], + "visibility": "public", + "created_at": 1752741108550693000, + "updated_at": 1752741108550693000, + "deleted_at": null + } + ], + "activities": [ + { + "id": "19a8aa2b-1e71-447c-9338-556ced2cd7a1", + "type": "post", + "feeds": [ + "user:user-w6p05o" + ], + "visibility": "public", + "created_at": 1752741103506159000, + "updated_at": 1752741108550693000, + "attachments": [], + "mentioned_users": [], + "custom": { + "foo": "bar" + }, + "text": "Hello world!", + "search_data": {}, + "filter_tags": [], + "interest_tags": [] + }, + { + "id": "29b9bb3c-2f82-558d-0449-667dfe3de8b2", + "type": "comment", + "feeds": [ + "user:user-w6p05o" + ], + "visibility": "public", + "created_at": 1752741103506159000, + "updated_at": 1752741108550693000, + "attachments": [], + "mentioned_users": [], + "custom": {}, + "text": "Great post!", + "search_data": {}, + "filter_tags": [], + "interest_tags": [] + } + ], + "follows": [ + { + "id": "user:456", + "role": "user", + "custom": {} + } + ], + "members": [ + { + "user_id": "user:123", + "feed": "feed:456", + "custom": {}, + "role": "member" + } + ], + "comments": [ + { + "id": "user:123", + "role": "user", + "custom": {} + } + ], + "reactions": [ + { + "activity_id": "activity:1", + "comment_id": "comment:1", + "type": "like", + "user_id": "user:456", + "custom": {} + } + ] +} \ No newline at end of file diff --git a/test/invalid-feeds.json b/test/invalid-feeds.json new file mode 100644 index 0000000..e698fc6 --- /dev/null +++ b/test/invalid-feeds.json @@ -0,0 +1,26 @@ +{ + "users": [ + { + "id": "", + "role": "user", + "custom": {} + } + ], + "feeds": [ + { + "group_id": "", + "id": "feed_123", + "fid": "", + "name": "user:123", + "custom": {} + } + ], + "activities": [ + { + "id": "activity:1", + "type": "post", + "feeds": [], + "custom": {} + } + ] +} \ No newline at end of file diff --git a/test/reactions-only.json b/test/reactions-only.json new file mode 100644 index 0000000..0639aad --- /dev/null +++ b/test/reactions-only.json @@ -0,0 +1,17 @@ +{ + "reactions": [ + { + "activity_id": "activity:1", + "comment_id": "comment:1", + "type": "like", + "user_id": "user:456", + "custom": {} + }, + { + "activity_id": "activity:2", + "type": "love", + "user_id": "user:123", + "custom": {} + } + ] +} \ No newline at end of file diff --git a/test/users-only.json b/test/users-only.json new file mode 100644 index 0000000..bb13363 --- /dev/null +++ b/test/users-only.json @@ -0,0 +1,20 @@ +{ + "users": [ + { + "id": "user:123", + "role": "user", + "custom": { + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "id": "user:456", + "role": "admin", + "custom": { + "name": "Jane Smith", + "email": "jane@example.com" + } + } + ] +} \ No newline at end of file