Something went wrong. Try again.
bluesky viewer in the terminal
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812package export
import ( "encoding/csv" "encoding/json" "os" "path/filepath" "strings" "testing" "time"
"github.com/stormlightlabs/skypanel/cli/internal/store")
// createTestPosts generates sample posts for testingfunc 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, }, }
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 datafunc 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) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
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)) }
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 slicefunc 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 pathsfunc 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 datafunc 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) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
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) }
if len(records) != 4 { t.Errorf("expected 4 rows (header + 3 data), got %d", len(records)) }
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]) } }
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 charactersfunc 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) }
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 slicefunc 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) }
if len(records) != 1 { t.Errorf("expected 1 row (header only), got %d", len(records)) }}
// TestToCSV_InvalidPath verifies error handling for invalid file pathsfunc 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 datafunc 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) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
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 slicefunc 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 pathsfunc 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 postsfunc 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)
for i := 1; i <= 3; i++ { postNum := "Post #" + string(rune('0'+i)) if !strings.Contains(content, postNum) { t.Errorf("missing %s", postNum) } }
separatorCount := strings.Count(content, strings.Repeat("-", 80)) if separatorCount != 3 { t.Errorf("expected 3 separators, got %d", separatorCount) }}
// TestConvertPosts verifies post model conversionfunc 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 tagsfunc 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)
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 postfunc 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) }}
// createTestProfile generates a sample profile for testingfunc createTestProfile() *store.ActorProfile { return &store.ActorProfile{ Did: "did:plc:test123", Handle: "testuser.bsky.social", DisplayName: "Test User", Description: "This is a test profile description", FollowersCount: 100, FollowsCount: 50, PostsCount: 25, CreatedAt: "2024-01-01T00:00:00Z", }}
// TestProfileToJSON_Success verifies profile JSON export with valid datafunc TestProfileToJSON_Success(t *testing.T) { profile := createTestProfile()
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "profile.json")
err := ProfileToJSON(filename, profile) if err != nil { t.Fatalf("ProfileToJSON failed: %v", err) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
var exportedProfile store.ActorProfile if err := json.Unmarshal(data, &exportedProfile); err != nil { t.Fatalf("failed to parse JSON: %v", err) }
if exportedProfile.Did != profile.Did { t.Errorf("expected DID %s, got %s", profile.Did, exportedProfile.Did) } if exportedProfile.Handle != profile.Handle { t.Errorf("expected handle %s, got %s", profile.Handle, exportedProfile.Handle) } if exportedProfile.DisplayName != profile.DisplayName { t.Errorf("expected display name %s, got %s", profile.DisplayName, exportedProfile.DisplayName) } if exportedProfile.FollowersCount != profile.FollowersCount { t.Errorf("expected followers %d, got %d", profile.FollowersCount, exportedProfile.FollowersCount) }}
// TestProfileToJSON_InvalidPath verifies error handling for invalid file pathsfunc TestProfileToJSON_InvalidPath(t *testing.T) { profile := createTestProfile()
err := ProfileToJSON("/invalid/path/that/does/not/exist/profile.json", profile) if err == nil { t.Error("expected error for invalid path, got nil") }}
// TestProfileToTXT_Success verifies profile TXT export with valid datafunc TestProfileToTXT_Success(t *testing.T) { profile := createTestProfile()
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "profile.txt")
err := ProfileToTXT(filename, profile) if err != nil { t.Fatalf("ProfileToTXT failed: %v", err) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
if !strings.Contains(content, "@"+profile.Handle) { t.Error("missing handle") } if !strings.Contains(content, profile.DisplayName) { t.Error("missing display name") } if !strings.Contains(content, profile.Did) { t.Error("missing DID") } if !strings.Contains(content, profile.Description) { t.Error("missing description") } if !strings.Contains(content, "Followers: 100") { t.Error("missing followers count") } if !strings.Contains(content, "Following: 50") { t.Error("missing following count") } if !strings.Contains(content, "Posts: 25") { t.Error("missing posts count") } if !strings.Contains(content, strings.Repeat("=", 80)) { t.Error("missing separator") }}
// TestProfileToTXT_MinimalProfile verifies TXT export with minimal profile datafunc TestProfileToTXT_MinimalProfile(t *testing.T) { profile := &store.ActorProfile{ Did: "did:plc:minimal", Handle: "minimal.bsky.social", }
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "minimal.txt")
err := ProfileToTXT(filename, profile) if err != nil { t.Fatalf("ProfileToTXT failed: %v", err) }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
if !strings.Contains(content, "did:plc:minimal") { t.Error("missing DID") } if !strings.Contains(content, "@minimal.bsky.social") { t.Error("missing handle") }
if strings.Contains(content, "Display Name:") && profile.DisplayName == "" { t.Error("should not show empty display name label") }}
// TestProfileToTXT_InvalidPath verifies error handling for invalid file pathsfunc TestProfileToTXT_InvalidPath(t *testing.T) { profile := createTestProfile()
err := ProfileToTXT("/invalid/path/that/does/not/exist/profile.txt", profile) if err == nil { t.Error("expected error for invalid path, got nil") }}
// createTestFeedViewPost generates a sample FeedViewPost for testingfunc createTestFeedViewPost() *store.FeedViewPost { return &store.FeedViewPost{ Post: &store.PostView{ Uri: "at://did:plc:test123/app.bsky.feed.post/abc123", Cid: "bafyreic3test", Author: &store.ActorProfile{ Did: "did:plc:author123", Handle: "testauthor.bsky.social", DisplayName: "Test Author", }, Record: map[string]any{ "text": "This is a test post with some content", "createdAt": "2024-01-01T12:00:00Z", }, LikeCount: 42, RepostCount: 10, ReplyCount: 5, QuoteCount: 2, IndexedAt: "2024-01-01T12:00:00Z", }, }}
// TestFeedViewPostToJSON_Success verifies FeedViewPost JSON export with valid datafunc TestFeedViewPostToJSON_Success(t *testing.T) { post := createTestFeedViewPost()
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "post.json")
err := FeedViewPostToJSON(filename, post) if err != nil { t.Fatalf("FeedViewPostToJSON failed: %v", err) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
var exportedPost store.FeedViewPost if err := json.Unmarshal(data, &exportedPost); err != nil { t.Fatalf("failed to parse JSON: %v", err) }
if exportedPost.Post.Uri != post.Post.Uri { t.Errorf("expected URI %s, got %s", post.Post.Uri, exportedPost.Post.Uri) } if exportedPost.Post.Author.Handle != post.Post.Author.Handle { t.Errorf("expected handle %s, got %s", post.Post.Author.Handle, exportedPost.Post.Author.Handle) } if exportedPost.Post.LikeCount != post.Post.LikeCount { t.Errorf("expected like count %d, got %d", post.Post.LikeCount, exportedPost.Post.LikeCount) }}
// TestFeedViewPostToJSON_InvalidPath verifies error handling for invalid file pathsfunc TestFeedViewPostToJSON_InvalidPath(t *testing.T) { post := createTestFeedViewPost()
err := FeedViewPostToJSON("/invalid/path/that/does/not/exist/post.json", post) if err == nil { t.Error("expected error for invalid path, got nil") }}
// TestFeedViewPostToTXT_Success verifies FeedViewPost TXT export with valid datafunc TestFeedViewPostToTXT_Success(t *testing.T) { post := createTestFeedViewPost()
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "post.txt")
err := FeedViewPostToTXT(filename, post) if err != nil { t.Fatalf("FeedViewPostToTXT failed: %v", err) }
if _, err := os.Stat(filename); os.IsNotExist(err) { t.Fatal("exported file does not exist") }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
if !strings.Contains(content, "@"+post.Post.Author.Handle) { t.Error("missing author handle") } if !strings.Contains(content, post.Post.Author.DisplayName) { t.Error("missing author display name") } if !strings.Contains(content, post.Post.Uri) { t.Error("missing post URI") } if !strings.Contains(content, post.Post.Cid) { t.Error("missing post CID") } if !strings.Contains(content, "This is a test post") { t.Error("missing post text") } if !strings.Contains(content, "Likes: 42") { t.Error("missing like count") } if !strings.Contains(content, "Reposts: 10") { t.Error("missing repost count") } if !strings.Contains(content, "Replies: 5") { t.Error("missing reply count") } if !strings.Contains(content, "Quotes: 2") { t.Error("missing quote count") } if !strings.Contains(content, strings.Repeat("=", 80)) { t.Error("missing separator") }}
// TestFeedViewPostToTXT_WithRepost verifies TXT export with repost reasonfunc TestFeedViewPostToTXT_WithRepost(t *testing.T) { post := createTestFeedViewPost() post.Reason = &store.ReasonView{ Type: "app.bsky.feed.defs#reasonRepost", By: &store.ActorProfile{ Did: "did:plc:reposter", Handle: "reposter.bsky.social", DisplayName: "Reposter", }, IndexedAt: "2024-01-01T12:00:00Z", }
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "repost.txt")
err := FeedViewPostToTXT(filename, post) if err != nil { t.Fatalf("FeedViewPostToTXT failed: %v", err) }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
if !strings.Contains(content, "Reposted by: @reposter.bsky.social") { t.Error("missing repost information") }}
// TestFeedViewPostToTXT_MinimalPost verifies TXT export with minimal post datafunc TestFeedViewPostToTXT_MinimalPost(t *testing.T) { post := &store.FeedViewPost{ Post: &store.PostView{ Uri: "at://did:plc:minimal/app.bsky.feed.post/xyz", Cid: "bafyreicminimal", Author: &store.ActorProfile{ Did: "did:plc:minimal", Handle: "minimal.bsky.social", }, Record: map[string]any{}, IndexedAt: "2024-01-01T00:00:00Z", }, }
tmpDir := t.TempDir() filename := filepath.Join(tmpDir, "minimal.txt")
err := FeedViewPostToTXT(filename, post) if err != nil { t.Fatalf("FeedViewPostToTXT failed: %v", err) }
data, err := os.ReadFile(filename) if err != nil { t.Fatalf("failed to read exported file: %v", err) }
content := string(data)
if !strings.Contains(content, "@minimal.bsky.social") { t.Error("missing handle") } if !strings.Contains(content, "at://did:plc:minimal") { t.Error("missing URI") }}
// TestFeedViewPostToTXT_InvalidPath verifies error handling for invalid file pathsfunc TestFeedViewPostToTXT_InvalidPath(t *testing.T) { post := createTestFeedViewPost()
err := FeedViewPostToTXT("/invalid/path/that/does/not/exist/post.txt", post) if err == nil { t.Error("expected error for invalid path, got nil") }}