diff --git a/cli/cmd/main.go b/cli/cmd/main.go index c4c9a04..f21a720 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -4,8 +4,13 @@ import ( "context" "fmt" "os" + "strings" + "time" "github.com/charmbracelet/log" + "github.com/google/uuid" + "github.com/stormlightlabs/skypanel/cli/internal/export" + "github.com/stormlightlabs/skypanel/cli/internal/imports" "github.com/stormlightlabs/skypanel/cli/internal/registry" "github.com/stormlightlabs/skypanel/cli/internal/store" "github.com/stormlightlabs/skypanel/cli/internal/ui" @@ -32,18 +37,34 @@ func main() { { Name: "login", Usage: "Authenticate with Bluesky", + Description: `Authenticate with Bluesky using one of two methods: + + 1. Direct credentials via flags: + skycli login --handle @user.bsky.social --password your-app-password + + 2. Credentials from an env file: + skycli login --file /path/to/.env + + The env file should contain: + BLUESKY_HANDLE=your.handle.bsky.social + BLUESKY_PASSWORD=your-app-password + + File paths can be relative or absolute.`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "handle", - Aliases: []string{"u"}, - Usage: "Your Bluesky handle (e.g., @user.bsky.social)", - Required: true, + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to env file containing BLUESKY_HANDLE and BLUESKY_PASSWORD", }, &cli.StringFlag{ - Name: "password", - Aliases: []string{"p"}, - Usage: "Your app password", - Required: true, + Name: "handle", + Aliases: []string{"u"}, + Usage: "Your Bluesky handle (e.g., @user.bsky.social)", + }, + &cli.StringFlag{ + Name: "password", + Aliases: []string{"p"}, + Usage: "Your app password", }, }, Action: loginAction, @@ -53,6 +74,45 @@ func main() { Usage: "Show current session status", Action: statusAction, }, + { + Name: "list", + Usage: "List all feeds", + Action: listAction, + }, + { + Name: "view", + Usage: "View posts from a feed", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "size", + Aliases: []string{"s"}, + Usage: "Number of posts to display", + Value: 25, + }, + }, + Action: viewAction, + }, + { + Name: "export", + Usage: "Export posts from a feed to file", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "format", + Aliases: []string{"f"}, + Usage: "Export format: json, csv, or txt", + Value: "json", + }, + &cli.IntFlag{ + Name: "size", + Aliases: []string{"s"}, + Usage: "Number of posts to export", + Value: 25, + }, + }, + Action: exportAction, + }, }, } @@ -65,8 +125,32 @@ func loginAction(ctx context.Context, cmd *cli.Command) error { logger := ui.GetLogger() reg := registry.Get() - handle := cmd.String("handle") - password := cmd.String("password") + var handle, password string + filePath := cmd.String("file") + + if filePath != "" { + env, err := imports.ParseEnvFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse env file: %w", err) + } + + handle = env["BLUESKY_HANDLE"] + password = env["BLUESKY_PASSWORD"] + + if handle == "" { + return fmt.Errorf("BLUESKY_HANDLE not found in env file") + } + if password == "" { + return fmt.Errorf("BLUESKY_PASSWORD not found in env file") + } + } else { + handle = cmd.String("handle") + password = cmd.String("password") + + if handle == "" || password == "" { + return fmt.Errorf("either --file or both --handle and --password are required") + } + } logger.Info("Authenticating with Bluesky", "handle", handle) @@ -129,3 +213,186 @@ func statusAction(ctx context.Context, cmd *cli.Command) error { return nil } + +func listAction(ctx context.Context, cmd *cli.Command) error { + logger := ui.GetLogger() + reg := registry.Get() + + feedRepo, err := reg.GetFeedRepo() + if err != nil { + return fmt.Errorf("failed to get feed repository: %w", err) + } + + feeds, err := feedRepo.List(ctx) + if err != nil { + logger.Error("Failed to list feeds", "error", err) + return err + } + + if len(feeds) == 0 { + ui.Infoln("No feeds found.") + return nil + } + + ui.Titleln("Feeds") + fmt.Println() + + for _, model := range feeds { + if feed, ok := model.(*store.FeedModel); ok { + ui.Subtitleln("ID: %s", feed.ID()) + ui.Infoln(" Name: %s", feed.Name) + ui.Infoln(" Source: %s", feed.Source) + ui.Infoln(" Local: %t", feed.IsLocal) + ui.Infoln(" Created: %s", feed.CreatedAt().Format(time.RFC3339)) + fmt.Println() + } + } + + ui.Successln("Total: %d feed(s)", len(feeds)) + return nil +} + +func viewAction(ctx context.Context, cmd *cli.Command) error { + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("feed ID or URI required") + } + + feedIdentifier := cmd.Args().First() + size := cmd.Int("size") + + feedRepo, err := reg.GetFeedRepo() + if err != nil { + return fmt.Errorf("failed to get feed repository: %w", err) + } + + postRepo, err := reg.GetPostRepo() + if err != nil { + return fmt.Errorf("failed to get post repository: %w", err) + } + + var feedID string + + if _, err := uuid.Parse(feedIdentifier); err == nil { + feedID = feedIdentifier + } else { + feeds, err := feedRepo.List(ctx) + if err != nil { + logger.Error("Failed to list feeds", "error", err) + return err + } + + found := false + for _, model := range feeds { + if feed, ok := model.(*store.FeedModel); ok { + if feed.Source == feedIdentifier { + feedID = feed.ID() + found = true + break + } + } + } + + if !found { + return fmt.Errorf("feed not found with identifier: %s", feedIdentifier) + } + } + + posts, err := postRepo.QueryByFeedID(ctx, feedID, size, 0) + if err != nil { + logger.Error("Failed to query posts", "error", err) + return err + } + + if len(posts) == 0 { + ui.Infoln("No posts found for this feed.") + return nil + } + + totalCount, err := postRepo.CountByFeedID(ctx, feedID) + if err != nil { + logger.Warn("Failed to get total count", "error", err) + } + + ui.Titleln("Posts for Feed: %s", feedID) + fmt.Println() + + for i, post := range posts { + ui.Subtitleln("[%d] %s", i+1, post.URI) + ui.Infoln(" Author: %s", post.AuthorDID) + text := post.Text + if len(text) > 100 { + text = text[:100] + "..." + } + ui.Infoln(" Text: %s", text) + ui.Infoln(" Indexed: %s", post.IndexedAt.Format(time.RFC3339)) + fmt.Println() + } + + ui.Successln("Showing %d of %d post(s)", len(posts), totalCount) + return nil +} + +func exportAction(ctx context.Context, cmd *cli.Command) error { + logger := ui.GetLogger() + reg := registry.Get() + + if cmd.Args().Len() == 0 { + return fmt.Errorf("feed ID required") + } + + feedID := cmd.Args().First() + format := strings.ToLower(cmd.String("format")) + size := cmd.Int("size") + + if format != "json" && format != "csv" && format != "txt" { + return fmt.Errorf("invalid format: %s (must be json, csv, or txt)", format) + } + + feedRepo, err := reg.GetFeedRepo() + if err != nil { + return fmt.Errorf("failed to get feed repository: %w", err) + } + + postRepo, err := reg.GetPostRepo() + if err != nil { + return fmt.Errorf("failed to get post repository: %w", err) + } + + _, err = feedRepo.Get(ctx, feedID) + if err != nil { + return fmt.Errorf("feed not found: %w", err) + } + + posts, err := postRepo.QueryByFeedID(ctx, feedID, size, 0) + if err != nil { + logger.Error("Failed to query posts", "error", err) + return err + } + + if len(posts) == 0 { + ui.Warningln("No posts found for this feed.") + return nil + } + + filename := fmt.Sprintf("feed_%s_%s.%s", feedID, time.Now().Format("2006-01-02"), format) + + switch format { + case "json": + err = export.ToJSON(filename, posts) + case "csv": + err = export.ToCSV(filename, posts) + case "txt": + err = export.ToTXT(filename, posts) + } + + if err != nil { + logger.Error("Failed to export", "error", err) + return err + } + + ui.Successln("Exported %d post(s) to %s", len(posts), filename) + return nil +} diff --git a/cli/internal/export/export.go b/cli/internal/export/export.go new file mode 100644 index 0000000..2fdcfda --- /dev/null +++ b/cli/internal/export/export.go @@ -0,0 +1,117 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/stormlightlabs/skypanel/cli/internal/store" +) + +// ExportPost represents a post structure for export operations +type ExportPost struct { + ID string `json:"id"` + URI string `json:"uri"` + AuthorDID string `json:"author_did"` + Text string `json:"text"` + FeedID string `json:"feed_id"` + IndexedAt time.Time `json:"indexed_at"` + CreatedAt time.Time `json:"created_at"` +} + +// ToJSON exports posts to JSON format with pretty printing +func ToJSON(filename string, posts []*store.PostModel) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + exportPosts := convertPosts(posts) + if err := encoder.Encode(exportPosts); err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + + return nil +} + +// ToCSV exports posts to CSV format with headers +func ToCSV(filename string, posts []*store.PostModel) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + // Write header + if err := writer.Write([]string{"ID", "URI", "AuthorDID", "Text", "FeedID", "IndexedAt", "CreatedAt"}); err != nil { + return fmt.Errorf("failed to write CSV header: %w", err) + } + + // Write rows + for _, post := range posts { + record := []string{ + post.ID(), + post.URI, + post.AuthorDID, + post.Text, + post.FeedID, + post.IndexedAt.Format(time.RFC3339), + post.CreatedAt().Format(time.RFC3339), + } + if err := writer.Write(record); err != nil { + return fmt.Errorf("failed to write CSV record: %w", err) + } + } + + return nil +} + +// ToTXT exports posts to plain text format with readable formatting +func ToTXT(filename string, posts []*store.PostModel) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + for i, post := range posts { + fmt.Fprintf(file, "Post #%d\n", i+1) + fmt.Fprintf(file, "ID: %s\n", post.ID()) + fmt.Fprintf(file, "URI: %s\n", post.URI) + fmt.Fprintf(file, "Author DID: %s\n", post.AuthorDID) + fmt.Fprintf(file, "Feed ID: %s\n", post.FeedID) + fmt.Fprintf(file, "Indexed At: %s\n", post.IndexedAt.Format(time.RFC3339)) + fmt.Fprintf(file, "Created At: %s\n", post.CreatedAt().Format(time.RFC3339)) + fmt.Fprintf(file, "\nText:\n%s\n", post.Text) + fmt.Fprintf(file, "\n%s\n\n", strings.Repeat("-", 80)) + } + + return nil +} + +// convertPosts transforms PostModel slice to ExportPost slice +func convertPosts(posts []*store.PostModel) []ExportPost { + exportPosts := make([]ExportPost, len(posts)) + for i, post := range posts { + exportPosts[i] = ExportPost{ + ID: post.ID(), + URI: post.URI, + AuthorDID: post.AuthorDID, + Text: post.Text, + FeedID: post.FeedID, + IndexedAt: post.IndexedAt, + CreatedAt: post.CreatedAt(), + } + } + return exportPosts +} diff --git a/cli/internal/export/export_test.go b/cli/internal/export/export_test.go new file mode 100644 index 0000000..246e751 --- /dev/null +++ b/cli/internal/export/export_test.go @@ -0,0 +1,464 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stormlightlabs/skypanel/cli/internal/store" +) + +// createTestPosts generates sample posts for testing +func createTestPosts() []*store.PostModel { + now := time.Now() + posts := []*store.PostModel{ + { + URI: "at://did:plc:test1/app.bsky.feed.post/1", + AuthorDID: "did:plc:author1", + Text: "First test post", + FeedID: "feed-1", + IndexedAt: now.Add(-2 * time.Hour), + }, + { + URI: "at://did:plc:test2/app.bsky.feed.post/2", + AuthorDID: "did:plc:author2", + Text: "Second test post", + FeedID: "feed-1", + IndexedAt: now.Add(-1 * time.Hour), + }, + { + URI: "at://did:plc:test3/app.bsky.feed.post/3", + AuthorDID: "did:plc:author3", + Text: "Third test post with special chars: \"quotes\", commas, and\nnewlines", + FeedID: "feed-2", + IndexedAt: now, + }, + } + + // Set IDs and timestamps + for i, post := range posts { + post.SetID(string(rune('a' + i))) + post.SetCreatedAt(now.Add(time.Duration(-i) * time.Hour)) + post.SetUpdatedAt(now) + } + + return posts +} + +// TestToJSON_Success verifies JSON export with valid data +func TestToJSON_Success(t *testing.T) { + posts := createTestPosts() + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.json") + + err := ToJSON(filename, posts) + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Fatal("exported file does not exist") + } + + // Read and parse JSON + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + + var exportedPosts []ExportPost + if err := json.Unmarshal(data, &exportedPosts); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if len(exportedPosts) != 3 { + t.Errorf("expected 3 posts, got %d", len(exportedPosts)) + } + + // Verify first post content + if exportedPosts[0].URI != "at://did:plc:test1/app.bsky.feed.post/1" { + t.Errorf("unexpected URI: %s", exportedPosts[0].URI) + } + if exportedPosts[0].Text != "First test post" { + t.Errorf("unexpected text: %s", exportedPosts[0].Text) + } +} + +// TestToJSON_EmptyPosts verifies JSON export with empty slice +func TestToJSON_EmptyPosts(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.json") + + err := ToJSON(filename, []*store.PostModel{}) + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + + var exportedPosts []ExportPost + if err := json.Unmarshal(data, &exportedPosts); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if len(exportedPosts) != 0 { + t.Errorf("expected 0 posts, got %d", len(exportedPosts)) + } +} + +// TestToJSON_InvalidPath verifies error handling for invalid file paths +func TestToJSON_InvalidPath(t *testing.T) { + posts := createTestPosts() + + err := ToJSON("/invalid/path/that/does/not/exist/test.json", posts) + if err == nil { + t.Error("expected error for invalid path, got nil") + } +} + +// TestToCSV_Success verifies CSV export with valid data +func TestToCSV_Success(t *testing.T) { + posts := createTestPosts() + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.csv") + + err := ToCSV(filename, posts) + if err != nil { + t.Fatalf("ToCSV failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Fatal("exported file does not exist") + } + + // Read and parse CSV + file, err := os.Open(filename) + if err != nil { + t.Fatalf("failed to open exported file: %v", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV: %v", err) + } + + // Verify header + 3 data rows + if len(records) != 4 { + t.Errorf("expected 4 rows (header + 3 data), got %d", len(records)) + } + + // Verify header + expectedHeader := []string{"ID", "URI", "AuthorDID", "Text", "FeedID", "IndexedAt", "CreatedAt"} + for i, col := range expectedHeader { + if records[0][i] != col { + t.Errorf("header column %d: expected %s, got %s", i, col, records[0][i]) + } + } + + // Verify first data row + if records[1][1] != "at://did:plc:test1/app.bsky.feed.post/1" { + t.Errorf("unexpected URI in row 1: %s", records[1][1]) + } + if records[1][3] != "First test post" { + t.Errorf("unexpected text in row 1: %s", records[1][3]) + } +} + +// TestToCSV_SpecialCharacters verifies CSV escaping of special characters +func TestToCSV_SpecialCharacters(t *testing.T) { + posts := createTestPosts() + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "special.csv") + + err := ToCSV(filename, posts) + if err != nil { + t.Fatalf("ToCSV failed: %v", err) + } + + file, err := os.Open(filename) + if err != nil { + t.Fatalf("failed to open exported file: %v", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV: %v", err) + } + + // Third post has special characters + specialText := records[3][3] + if !strings.Contains(specialText, "quotes") { + t.Error("special characters not properly preserved in CSV") + } + if !strings.Contains(specialText, "newlines") { + t.Error("newlines not properly preserved in CSV") + } +} + +// TestToCSV_EmptyPosts verifies CSV export with empty slice +func TestToCSV_EmptyPosts(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.csv") + + err := ToCSV(filename, []*store.PostModel{}) + if err != nil { + t.Fatalf("ToCSV failed: %v", err) + } + + file, err := os.Open(filename) + if err != nil { + t.Fatalf("failed to open exported file: %v", err) + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV: %v", err) + } + + // Should only have header row + if len(records) != 1 { + t.Errorf("expected 1 row (header only), got %d", len(records)) + } +} + +// TestToCSV_InvalidPath verifies error handling for invalid file paths +func TestToCSV_InvalidPath(t *testing.T) { + posts := createTestPosts() + + err := ToCSV("/invalid/path/that/does/not/exist/test.csv", posts) + if err == nil { + t.Error("expected error for invalid path, got nil") + } +} + +// TestToTXT_Success verifies TXT export with valid data +func TestToTXT_Success(t *testing.T) { + posts := createTestPosts() + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "test.txt") + + err := ToTXT(filename, posts) + if err != nil { + t.Fatalf("ToTXT failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(filename); os.IsNotExist(err) { + t.Fatal("exported file does not exist") + } + + // Read file content + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + + content := string(data) + + // Verify content contains expected elements + if !strings.Contains(content, "Post #1") { + t.Error("missing post number") + } + if !strings.Contains(content, "at://did:plc:test1/app.bsky.feed.post/1") { + t.Error("missing URI") + } + if !strings.Contains(content, "First test post") { + t.Error("missing post text") + } + if !strings.Contains(content, "did:plc:author1") { + t.Error("missing author DID") + } + if !strings.Contains(content, strings.Repeat("-", 80)) { + t.Error("missing separator") + } +} + +// TestToTXT_EmptyPosts verifies TXT export with empty slice +func TestToTXT_EmptyPosts(t *testing.T) { + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "empty.txt") + + err := ToTXT(filename, []*store.PostModel{}) + if err != nil { + t.Fatalf("ToTXT failed: %v", err) + } + + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + + if len(data) != 0 { + t.Errorf("expected empty file, got %d bytes", len(data)) + } +} + +// TestToTXT_InvalidPath verifies error handling for invalid file paths +func TestToTXT_InvalidPath(t *testing.T) { + posts := createTestPosts() + + err := ToTXT("/invalid/path/that/does/not/exist/test.txt", posts) + if err == nil { + t.Error("expected error for invalid path, got nil") + } +} + +// TestToTXT_MultiplePostsFormatting verifies proper formatting of multiple posts +func TestToTXT_MultiplePostsFormatting(t *testing.T) { + posts := createTestPosts() + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "multiple.txt") + + err := ToTXT(filename, posts) + if err != nil { + t.Fatalf("ToTXT failed: %v", err) + } + + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read exported file: %v", err) + } + + content := string(data) + + // Verify all posts are present + for i := 1; i <= 3; i++ { + postNum := "Post #" + string(rune('0'+i)) + if !strings.Contains(content, postNum) { + t.Errorf("missing %s", postNum) + } + } + + // Count separators (should have 3) + separatorCount := strings.Count(content, strings.Repeat("-", 80)) + if separatorCount != 3 { + t.Errorf("expected 3 separators, got %d", separatorCount) + } +} + +// TestConvertPosts verifies post model conversion +func TestConvertPosts(t *testing.T) { + posts := createTestPosts() + + exportPosts := convertPosts(posts) + + if len(exportPosts) != len(posts) { + t.Errorf("expected %d posts, got %d", len(posts), len(exportPosts)) + } + + for i := range posts { + if exportPosts[i].ID != posts[i].ID() { + t.Errorf("post %d: ID mismatch", i) + } + if exportPosts[i].URI != posts[i].URI { + t.Errorf("post %d: URI mismatch", i) + } + if exportPosts[i].Text != posts[i].Text { + t.Errorf("post %d: Text mismatch", i) + } + if exportPosts[i].AuthorDID != posts[i].AuthorDID { + t.Errorf("post %d: AuthorDID mismatch", i) + } + if exportPosts[i].FeedID != posts[i].FeedID { + t.Errorf("post %d: FeedID mismatch", i) + } + } +} + +// TestExportPost_JSONTags verifies JSON struct tags +func TestExportPost_JSONTags(t *testing.T) { + now := time.Now() + post := ExportPost{ + ID: "test-id", + URI: "at://test/uri", + AuthorDID: "did:plc:test", + Text: "test text", + FeedID: "feed-id", + IndexedAt: now, + CreatedAt: now, + } + + data, err := json.Marshal(post) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + content := string(data) + + // Verify JSON uses snake_case + expectedFields := []string{ + "\"id\":", + "\"uri\":", + "\"author_did\":", + "\"text\":", + "\"feed_id\":", + "\"indexed_at\":", + "\"created_at\":", + } + + for _, field := range expectedFields { + if !strings.Contains(content, field) { + t.Errorf("missing expected JSON field: %s", field) + } + } +} + +// TestToJSON_SinglePost verifies export with single post +func TestToJSON_SinglePost(t *testing.T) { + now := time.Now() + post := &store.PostModel{ + URI: "at://test/single", + AuthorDID: "did:plc:single", + Text: "Single post", + FeedID: "feed-1", + IndexedAt: now, + } + post.SetID("single-id") + post.SetCreatedAt(now) + + tmpDir := t.TempDir() + filename := filepath.Join(tmpDir, "single.json") + + err := ToJSON(filename, []*store.PostModel{post}) + if err != nil { + t.Fatalf("ToJSON failed: %v", err) + } + + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var exportedPosts []ExportPost + if err := json.Unmarshal(data, &exportedPosts); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if len(exportedPosts) != 1 { + t.Errorf("expected 1 post, got %d", len(exportedPosts)) + } + + if exportedPosts[0].Text != "Single post" { + t.Errorf("unexpected text: %s", exportedPosts[0].Text) + } +} diff --git a/cli/internal/imports/imports.go b/cli/internal/imports/imports.go new file mode 100644 index 0000000..444d9fa --- /dev/null +++ b/cli/internal/imports/imports.go @@ -0,0 +1,50 @@ +package imports + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ParseEnvFile reads (relative and absolute file paths) an env file and returns a map of key-value pairs. +// The file format is simple KEY=value pairs, one per line; empty lines and lines starting with # are ignored. +func ParseEnvFile(path string) (map[string]string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to resolve file path: %w", err) + } + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + env := make(map[string]string) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + env[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return env, nil +} diff --git a/cli/internal/imports/imports_test.go b/cli/internal/imports/imports_test.go new file mode 100644 index 0000000..83e84b3 --- /dev/null +++ b/cli/internal/imports/imports_test.go @@ -0,0 +1,233 @@ +package imports + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseEnvFile(t *testing.T) { + t.Run("parses basic key-value pairs", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := `BLUESKY_HANDLE=test.bsky.social +BLUESKY_PASSWORD=secret123` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + + if env["BLUESKY_PASSWORD"] != "secret123" { + t.Errorf("expected BLUESKY_PASSWORD=secret123, got %s", env["BLUESKY_PASSWORD"]) + } + }) + + t.Run("ignores comments and empty lines", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := `# This is a comment +BLUESKY_HANDLE=test.bsky.social + +# Another comment +BLUESKY_PASSWORD=secret123 + +` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if len(env) != 2 { + t.Errorf("expected 2 entries, got %d", len(env)) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + + if env["BLUESKY_PASSWORD"] != "secret123" { + t.Errorf("expected BLUESKY_PASSWORD=secret123, got %s", env["BLUESKY_PASSWORD"]) + } + }) + + t.Run("handles whitespace around keys and values", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := ` BLUESKY_HANDLE = test.bsky.social +BLUESKY_PASSWORD= secret123` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + + if env["BLUESKY_PASSWORD"] != "secret123" { + t.Errorf("expected BLUESKY_PASSWORD=secret123, got %s", env["BLUESKY_PASSWORD"]) + } + }) + + t.Run("handles values containing equals signs", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := `API_URL=https://api.example.com?key=value&other=thing +TOKEN=abc=def=ghi` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if env["API_URL"] != "https://api.example.com?key=value&other=thing" { + t.Errorf("expected API_URL=https://api.example.com?key=value&other=thing, got %s", env["API_URL"]) + } + + if env["TOKEN"] != "abc=def=ghi" { + t.Errorf("expected TOKEN=abc=def=ghi, got %s", env["TOKEN"]) + } + }) + + t.Run("ignores malformed lines", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := `BLUESKY_HANDLE=test.bsky.social +INVALID_LINE_NO_EQUALS +BLUESKY_PASSWORD=secret123 +ANOTHER_INVALID +` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if len(env) != 2 { + t.Errorf("expected 2 entries, got %d", len(env)) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + + if env["BLUESKY_PASSWORD"] != "secret123" { + t.Errorf("expected BLUESKY_PASSWORD=secret123, got %s", env["BLUESKY_PASSWORD"]) + } + }) + + t.Run("returns error for non-existent file", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, "nonexistent.env") + + _, err := ParseEnvFile(envPath) + if err == nil { + t.Error("expected error for non-existent file, got nil") + } + }) + + t.Run("handles relative paths", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + content := `BLUESKY_HANDLE=test.bsky.social +BLUESKY_PASSWORD=secret123` + + if err := os.WriteFile(".env", []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(".env") + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + }) + + t.Run("handles absolute paths", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + content := `BLUESKY_HANDLE=test.bsky.social +BLUESKY_PASSWORD=secret123` + + if err := os.WriteFile(envPath, []byte(content), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + absPath, err := filepath.Abs(envPath) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + + env, err := ParseEnvFile(absPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if env["BLUESKY_HANDLE"] != "test.bsky.social" { + t.Errorf("expected BLUESKY_HANDLE=test.bsky.social, got %s", env["BLUESKY_HANDLE"]) + } + }) + + t.Run("handles empty file", func(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + if err := os.WriteFile(envPath, []byte(""), 0644); err != nil { + t.Fatalf("failed to create test file: %v", err) + } + + env, err := ParseEnvFile(envPath) + if err != nil { + t.Fatalf("ParseEnvFile failed: %v", err) + } + + if len(env) != 0 { + t.Errorf("expected empty map, got %d entries", len(env)) + } + }) +} diff --git a/cli/internal/store/feed_repo_test.go b/cli/internal/store/feed_repo_test.go new file mode 100644 index 0000000..0dea571 --- /dev/null +++ b/cli/internal/store/feed_repo_test.go @@ -0,0 +1,368 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stormlightlabs/skypanel/cli/internal/utils" +) + +// TestFeedRepository_Init verifies repository initialization +func TestFeedRepository_Init(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + + err := repo.Init(context.Background()) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&count) + if err != nil { + t.Errorf("feeds table not created: %v", err) + } +} + +// TestFeedRepository_Save creates a new feed +func TestFeedRepository_Save(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{ + Name: "Test Feed", + Source: "timeline", + Params: map[string]string{"key": "value"}, + IsLocal: true, + } + + err := repo.Save(context.Background(), feed) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + if feed.ID() == "" { + t.Error("expected ID to be set after Save") + } + if feed.CreatedAt().IsZero() { + t.Error("expected CreatedAt to be set after Save") + } + if feed.UpdatedAt().IsZero() { + t.Error("expected UpdatedAt to be set after Save") + } +} + +// TestFeedRepository_Get retrieves a feed by ID +func TestFeedRepository_Get(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{ + Name: "Test Feed", + Source: "following", + Params: map[string]string{"limit": "50"}, + IsLocal: false, + } + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Save failed: %v", err) + } + + retrieved, err := repo.Get(context.Background(), feed.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + retrievedFeed, ok := retrieved.(*FeedModel) + if !ok { + t.Fatal("expected *FeedModel") + } + + if retrievedFeed.Name != "Test Feed" { + t.Errorf("expected Name 'Test Feed', got %s", retrievedFeed.Name) + } + if retrievedFeed.Source != "following" { + t.Errorf("expected Source 'following', got %s", retrievedFeed.Source) + } + if retrievedFeed.IsLocal != false { + t.Error("expected IsLocal false") + } + if retrievedFeed.Params["limit"] != "50" { + t.Errorf("expected Params['limit'] '50', got %s", retrievedFeed.Params["limit"]) + } +} + +// TestFeedRepository_Get_NotFound verifies error on missing feed +func TestFeedRepository_Get_NotFound(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + _, err := repo.Get(context.Background(), "nonexistent-id") + if err == nil { + t.Error("expected error for nonexistent feed") + } +} + +// TestFeedRepository_List retrieves all feeds +func TestFeedRepository_List(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed1 := &FeedModel{Name: "Feed 1", Source: "timeline", IsLocal: true} + feed2 := &FeedModel{Name: "Feed 2", Source: "following", IsLocal: false} + + if err := repo.Save(context.Background(), feed1); err != nil { + t.Fatalf("Save feed1 failed: %v", err) + } + time.Sleep(10 * time.Millisecond) // Ensure different timestamps + if err := repo.Save(context.Background(), feed2); err != nil { + t.Fatalf("Save feed2 failed: %v", err) + } + + feeds, err := repo.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(feeds) != 2 { + t.Errorf("expected 2 feeds, got %d", len(feeds)) + } + + // List should order by created_at DESC, so feed2 should be first + if f, ok := feeds[0].(*FeedModel); ok { + if f.Name != "Feed 2" { + t.Errorf("expected first feed to be 'Feed 2', got %s", f.Name) + } + } +} + +// TestFeedRepository_Update modifies an existing feed +func TestFeedRepository_Update(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{ + Name: "Original Name", + Source: "timeline", + IsLocal: true, + } + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Save failed: %v", err) + } + + originalID := feed.ID() + originalCreatedAt := feed.CreatedAt() + + feed.Name = "Updated Name" + feed.Source = "following" + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Update failed: %v", err) + } + + if feed.ID() != originalID { + t.Error("ID should not change on update") + } + if feed.CreatedAt() != originalCreatedAt { + t.Error("CreatedAt should not change on update") + } + + retrieved, err := repo.Get(context.Background(), feed.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + updatedFeed := retrieved.(*FeedModel) + if updatedFeed.Name != "Updated Name" { + t.Errorf("expected Name 'Updated Name', got %s", updatedFeed.Name) + } + if updatedFeed.Source != "following" { + t.Errorf("expected Source 'following', got %s", updatedFeed.Source) + } +} + +// TestFeedRepository_Delete removes a feed +func TestFeedRepository_Delete(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{Name: "To Delete", Source: "timeline", IsLocal: true} + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Save failed: %v", err) + } + + err := repo.Delete(context.Background(), feed.ID()) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err = repo.Get(context.Background(), feed.ID()) + if err == nil { + t.Error("expected error when getting deleted feed") + } +} + +// TestFeedRepository_Delete_NotFound verifies error on deleting nonexistent feed +func TestFeedRepository_Delete_NotFound(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + err := repo.Delete(context.Background(), "nonexistent-id") + if err == nil { + t.Error("expected error when deleting nonexistent feed") + } +} + +// TestFeedRepository_InvalidModelType verifies type checking on Save +func TestFeedRepository_InvalidModelType(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + invalidModel := &PostModel{URI: "test", AuthorDID: "did:test", Text: "hello"} + + err := repo.Save(context.Background(), invalidModel) + if err == nil { + t.Error("expected error when saving invalid model type") + } +} + +// TestFeedRepository_ParamsJSONMarshaling verifies Params field marshaling +func TestFeedRepository_ParamsJSONMarshaling(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{ + Name: "Complex Feed", + Source: "custom", + Params: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + IsLocal: true, + } + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Save failed: %v", err) + } + + retrieved, err := repo.Get(context.Background(), feed.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + retrievedFeed := retrieved.(*FeedModel) + if len(retrievedFeed.Params) != 3 { + t.Errorf("expected 3 params, got %d", len(retrievedFeed.Params)) + } + if retrievedFeed.Params["key1"] != "value1" { + t.Errorf("expected Params['key1'] 'value1', got %s", retrievedFeed.Params["key1"]) + } + if retrievedFeed.Params["key2"] != "value2" { + t.Errorf("expected Params['key2'] 'value2', got %s", retrievedFeed.Params["key2"]) + } + if retrievedFeed.Params["key3"] != "value3" { + t.Errorf("expected Params['key3'] 'value3', got %s", retrievedFeed.Params["key3"]) + } +} + +// TestFeedRepository_EmptyParams verifies handling of empty Params +func TestFeedRepository_EmptyParams(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + feed := &FeedModel{ + Name: "No Params Feed", + Source: "simple", + Params: map[string]string{}, + IsLocal: false, + } + + if err := repo.Save(context.Background(), feed); err != nil { + t.Fatalf("Save failed: %v", err) + } + + retrieved, err := repo.Get(context.Background(), feed.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + retrievedFeed := retrieved.(*FeedModel) + if retrievedFeed.Params == nil { + t.Error("expected non-nil Params map") + } + if len(retrievedFeed.Params) != 0 { + t.Errorf("expected 0 params, got %d", len(retrievedFeed.Params)) + } +} + +// TestFeedRepository_Close verifies repository cleanup +func TestFeedRepository_Close(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &FeedRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + err := repo.Close() + if err != nil { + t.Errorf("Close failed: %v", err) + } +} diff --git a/cli/internal/store/post_repo_test.go b/cli/internal/store/post_repo_test.go new file mode 100644 index 0000000..2bf3c48 --- /dev/null +++ b/cli/internal/store/post_repo_test.go @@ -0,0 +1,539 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stormlightlabs/skypanel/cli/internal/utils" +) + +// TestPostRepository_Init verifies repository initialization +func TestPostRepository_Init(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + + err := repo.Init(context.Background()) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count) + if err != nil { + t.Errorf("posts table not created: %v", err) + } +} + +// TestPostRepository_Save creates a new post +func TestPostRepository_Save(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + post := &PostModel{ + URI: "at://did:plc:test/app.bsky.feed.post/123", + AuthorDID: "did:plc:test", + Text: "Hello, world!", + FeedID: "feed-123", + IndexedAt: time.Now(), + } + + err := repo.Save(context.Background(), post) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + if post.ID() == "" { + t.Error("expected ID to be set after Save") + } + if post.CreatedAt().IsZero() { + t.Error("expected CreatedAt to be set after Save") + } + if post.UpdatedAt().IsZero() { + t.Error("expected UpdatedAt to be set after Save") + } +} + +// TestPostRepository_Get retrieves a post by ID +func TestPostRepository_Get(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + indexedTime := time.Now() + post := &PostModel{ + URI: "at://did:plc:test/app.bsky.feed.post/456", + AuthorDID: "did:plc:author", + Text: "Test post content", + FeedID: "feed-456", + IndexedAt: indexedTime, + } + + if err := repo.Save(context.Background(), post); err != nil { + t.Fatalf("Save failed: %v", err) + } + + retrieved, err := repo.Get(context.Background(), post.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + retrievedPost, ok := retrieved.(*PostModel) + if !ok { + t.Fatal("expected *PostModel") + } + + if retrievedPost.URI != "at://did:plc:test/app.bsky.feed.post/456" { + t.Errorf("expected URI 'at://did:plc:test/app.bsky.feed.post/456', got %s", retrievedPost.URI) + } + if retrievedPost.AuthorDID != "did:plc:author" { + t.Errorf("expected AuthorDID 'did:plc:author', got %s", retrievedPost.AuthorDID) + } + if retrievedPost.Text != "Test post content" { + t.Errorf("expected Text 'Test post content', got %s", retrievedPost.Text) + } + if retrievedPost.FeedID != "feed-456" { + t.Errorf("expected FeedID 'feed-456', got %s", retrievedPost.FeedID) + } +} + +// TestPostRepository_Get_NotFound verifies error on missing post +func TestPostRepository_Get_NotFound(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + _, err := repo.Get(context.Background(), "nonexistent-id") + if err == nil { + t.Error("expected error for nonexistent post") + } +} + +// TestPostRepository_List retrieves all posts ordered by indexed_at DESC +func TestPostRepository_List(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + now := time.Now() + post1 := &PostModel{ + URI: "at://test/post1", + AuthorDID: "did:plc:1", + Text: "Post 1", + FeedID: "feed-1", + IndexedAt: now.Add(-2 * time.Hour), + } + post2 := &PostModel{ + URI: "at://test/post2", + AuthorDID: "did:plc:2", + Text: "Post 2", + FeedID: "feed-1", + IndexedAt: now, + } + + if err := repo.Save(context.Background(), post1); err != nil { + t.Fatalf("Save post1 failed: %v", err) + } + if err := repo.Save(context.Background(), post2); err != nil { + t.Fatalf("Save post2 failed: %v", err) + } + + posts, err := repo.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(posts) != 2 { + t.Errorf("expected 2 posts, got %d", len(posts)) + } + + // List should order by indexed_at DESC, so post2 should be first + if p, ok := posts[0].(*PostModel); ok { + if p.Text != "Post 2" { + t.Errorf("expected first post to be 'Post 2', got %s", p.Text) + } + } +} + +// TestPostRepository_Update modifies an existing post +func TestPostRepository_Update(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + post := &PostModel{ + URI: "at://test/updatepost", + AuthorDID: "did:plc:author", + Text: "Original text", + FeedID: "feed-1", + IndexedAt: time.Now(), + } + + if err := repo.Save(context.Background(), post); err != nil { + t.Fatalf("Save failed: %v", err) + } + + originalCreatedAt := post.CreatedAt() + + post.Text = "Updated text" + + if err := repo.Save(context.Background(), post); err != nil { + t.Fatalf("Update failed: %v", err) + } + + if post.CreatedAt() != originalCreatedAt { + t.Error("CreatedAt should not change on update") + } + + retrieved, err := repo.Get(context.Background(), post.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + updatedPost := retrieved.(*PostModel) + if updatedPost.Text != "Updated text" { + t.Errorf("expected Text 'Updated text', got %s", updatedPost.Text) + } +} + +// TestPostRepository_Delete removes a post +func TestPostRepository_Delete(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + post := &PostModel{ + URI: "at://test/deletepost", + AuthorDID: "did:plc:author", + Text: "To be deleted", + FeedID: "feed-1", + IndexedAt: time.Now(), + } + + if err := repo.Save(context.Background(), post); err != nil { + t.Fatalf("Save failed: %v", err) + } + + err := repo.Delete(context.Background(), post.ID()) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err = repo.Get(context.Background(), post.ID()) + if err == nil { + t.Error("expected error when getting deleted post") + } +} + +// TestPostRepository_Delete_NotFound verifies error on deleting nonexistent post +func TestPostRepository_Delete_NotFound(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + err := repo.Delete(context.Background(), "nonexistent-id") + if err == nil { + t.Error("expected error when deleting nonexistent post") + } +} + +// TestPostRepository_InvalidModelType verifies type checking on Save +func TestPostRepository_InvalidModelType(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + invalidModel := &FeedModel{Name: "test", Source: "timeline", IsLocal: true} + + err := repo.Save(context.Background(), invalidModel) + if err == nil { + t.Error("expected error when saving invalid model type") + } +} + +// TestPostRepository_BatchSave inserts multiple posts efficiently +func TestPostRepository_BatchSave(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + now := time.Now() + posts := []*PostModel{ + {URI: "at://test/batch1", AuthorDID: "did:plc:1", Text: "Batch 1", FeedID: "feed-1", IndexedAt: now}, + {URI: "at://test/batch2", AuthorDID: "did:plc:2", Text: "Batch 2", FeedID: "feed-1", IndexedAt: now}, + {URI: "at://test/batch3", AuthorDID: "did:plc:3", Text: "Batch 3", FeedID: "feed-2", IndexedAt: now}, + } + + err := repo.BatchSave(context.Background(), posts) + if err != nil { + t.Fatalf("BatchSave failed: %v", err) + } + + for _, post := range posts { + if post.ID() == "" { + t.Error("expected ID to be set after BatchSave") + } + } + + allPosts, err := repo.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(allPosts) != 3 { + t.Errorf("expected 3 posts, got %d", len(allPosts)) + } +} + +// TestPostRepository_BatchSave_Empty verifies handling of empty slice +func TestPostRepository_BatchSave_Empty(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + err := repo.BatchSave(context.Background(), []*PostModel{}) + if err != nil { + t.Errorf("BatchSave with empty slice should not error: %v", err) + } +} + +// TestPostRepository_URIConflict verifies ON CONFLICT behavior for duplicate URIs +func TestPostRepository_URIConflict(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + post1 := &PostModel{ + URI: "at://test/conflict", + AuthorDID: "did:plc:author", + Text: "Original text", + FeedID: "feed-1", + IndexedAt: time.Now(), + } + + if err := repo.Save(context.Background(), post1); err != nil { + t.Fatalf("Save post1 failed: %v", err) + } + + post2 := &PostModel{ + URI: "at://test/conflict", + AuthorDID: "did:plc:author", + Text: "Updated text", + FeedID: "feed-2", + IndexedAt: time.Now(), + } + + if err := repo.Save(context.Background(), post2); err != nil { + t.Fatalf("Save post2 failed: %v", err) + } + + retrieved, err := repo.Get(context.Background(), post1.ID()) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + retrievedPost := retrieved.(*PostModel) + if retrievedPost.Text != "Updated text" { + t.Errorf("expected Text 'Updated text', got %s", retrievedPost.Text) + } + if retrievedPost.FeedID != "feed-2" { + t.Errorf("expected FeedID 'feed-2', got %s", retrievedPost.FeedID) + } +} + +// TestPostRepository_QueryByFeedID retrieves posts for a specific feed with pagination +func TestPostRepository_QueryByFeedID(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + now := time.Now() + posts := []*PostModel{ + {URI: "at://test/f1p1", AuthorDID: "did:plc:1", Text: "Feed 1 Post 1", FeedID: "feed-1", IndexedAt: now.Add(-3 * time.Hour)}, + {URI: "at://test/f1p2", AuthorDID: "did:plc:2", Text: "Feed 1 Post 2", FeedID: "feed-1", IndexedAt: now.Add(-2 * time.Hour)}, + {URI: "at://test/f1p3", AuthorDID: "did:plc:3", Text: "Feed 1 Post 3", FeedID: "feed-1", IndexedAt: now.Add(-1 * time.Hour)}, + {URI: "at://test/f2p1", AuthorDID: "did:plc:4", Text: "Feed 2 Post 1", FeedID: "feed-2", IndexedAt: now}, + } + + if err := repo.BatchSave(context.Background(), posts); err != nil { + t.Fatalf("BatchSave failed: %v", err) + } + + feed1Posts, err := repo.QueryByFeedID(context.Background(), "feed-1", 10, 0) + if err != nil { + t.Fatalf("QueryByFeedID failed: %v", err) + } + + if len(feed1Posts) != 3 { + t.Errorf("expected 3 posts for feed-1, got %d", len(feed1Posts)) + } + + // Posts should be ordered by indexed_at DESC + if feed1Posts[0].Text != "Feed 1 Post 3" { + t.Errorf("expected first post to be 'Feed 1 Post 3', got %s", feed1Posts[0].Text) + } +} + +// TestPostRepository_QueryByFeedID_Pagination verifies pagination behavior +func TestPostRepository_QueryByFeedID_Pagination(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + now := time.Now() + posts := []*PostModel{ + {URI: "at://test/p1", AuthorDID: "did:plc:1", Text: "Post 1", FeedID: "feed-1", IndexedAt: now.Add(-4 * time.Hour)}, + {URI: "at://test/p2", AuthorDID: "did:plc:2", Text: "Post 2", FeedID: "feed-1", IndexedAt: now.Add(-3 * time.Hour)}, + {URI: "at://test/p3", AuthorDID: "did:plc:3", Text: "Post 3", FeedID: "feed-1", IndexedAt: now.Add(-2 * time.Hour)}, + {URI: "at://test/p4", AuthorDID: "did:plc:4", Text: "Post 4", FeedID: "feed-1", IndexedAt: now.Add(-1 * time.Hour)}, + {URI: "at://test/p5", AuthorDID: "did:plc:5", Text: "Post 5", FeedID: "feed-1", IndexedAt: now}, + } + + if err := repo.BatchSave(context.Background(), posts); err != nil { + t.Fatalf("BatchSave failed: %v", err) + } + + page1, err := repo.QueryByFeedID(context.Background(), "feed-1", 2, 0) + if err != nil { + t.Fatalf("QueryByFeedID page 1 failed: %v", err) + } + + if len(page1) != 2 { + t.Errorf("expected 2 posts in page 1, got %d", len(page1)) + } + if page1[0].Text != "Post 5" { + t.Errorf("expected first post 'Post 5', got %s", page1[0].Text) + } + + page2, err := repo.QueryByFeedID(context.Background(), "feed-1", 2, 2) + if err != nil { + t.Fatalf("QueryByFeedID page 2 failed: %v", err) + } + + if len(page2) != 2 { + t.Errorf("expected 2 posts in page 2, got %d", len(page2)) + } + if page2[0].Text != "Post 3" { + t.Errorf("expected first post in page 2 'Post 3', got %s", page2[0].Text) + } +} + +// TestPostRepository_CountByFeedID counts posts for a feed +func TestPostRepository_CountByFeedID(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + now := time.Now() + posts := []*PostModel{ + {URI: "at://test/c1", AuthorDID: "did:plc:1", Text: "Count 1", FeedID: "feed-1", IndexedAt: now}, + {URI: "at://test/c2", AuthorDID: "did:plc:2", Text: "Count 2", FeedID: "feed-1", IndexedAt: now}, + {URI: "at://test/c3", AuthorDID: "did:plc:3", Text: "Count 3", FeedID: "feed-2", IndexedAt: now}, + } + + if err := repo.BatchSave(context.Background(), posts); err != nil { + t.Fatalf("BatchSave failed: %v", err) + } + + count1, err := repo.CountByFeedID(context.Background(), "feed-1") + if err != nil { + t.Fatalf("CountByFeedID feed-1 failed: %v", err) + } + + if count1 != 2 { + t.Errorf("expected count 2 for feed-1, got %d", count1) + } + + count2, err := repo.CountByFeedID(context.Background(), "feed-2") + if err != nil { + t.Fatalf("CountByFeedID feed-2 failed: %v", err) + } + + if count2 != 1 { + t.Errorf("expected count 1 for feed-2, got %d", count2) + } + + count3, err := repo.CountByFeedID(context.Background(), "nonexistent") + if err != nil { + t.Fatalf("CountByFeedID nonexistent failed: %v", err) + } + + if count3 != 0 { + t.Errorf("expected count 0 for nonexistent feed, got %d", count3) + } +} + +// TestPostRepository_Close verifies repository cleanup +func TestPostRepository_Close(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + repo := &PostRepository{db: db} + if err := repo.Init(context.Background()); err != nil { + t.Fatalf("Init failed: %v", err) + } + + err := repo.Close() + if err != nil { + t.Errorf("Close failed: %v", err) + } +} diff --git a/cli/internal/store/session_repo.go b/cli/internal/store/session_repo.go index 7940028..844ec69 100644 --- a/cli/internal/store/session_repo.go +++ b/cli/internal/store/session_repo.go @@ -84,7 +84,6 @@ func (r *SessionRepository) Save(ctx context.Context, model Model) error { return errors.New("invalid model type: expected *SessionModel") } - // Parse combined token format (accessToken|refreshToken) var accessToken, refreshToken string parts := splitToken(session.Token) if len(parts) == 2 {