diff --git a/cli/cmd/main.go b/cli/cmd/main.go index dcc1a97..c4c9a04 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -57,7 +57,7 @@ func main() { } if err := app.Run(ctx, os.Args); err != nil { - logger.Fatal("Command failed", "error", err) + logger.Fatalf("Command failed with error: %v", err) } } diff --git a/cli/internal/store/bluesky_test.go b/cli/internal/store/bluesky_test.go new file mode 100644 index 0000000..b51adf2 --- /dev/null +++ b/cli/internal/store/bluesky_test.go @@ -0,0 +1,584 @@ +package store + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestNewBlueskyService verifies service initialization with default and custom URLs. +func TestNewBlueskyService(t *testing.T) { + t.Run("default URL", func(t *testing.T) { + svc := NewBlueskyService("") + if svc.BaseURL() != defaultServiceURL { + t.Errorf("expected default URL %s, got %s", defaultServiceURL, svc.BaseURL()) + } + if svc.Authenticated() { + t.Error("new service should not be authenticated") + } + }) + + t.Run("custom URL", func(t *testing.T) { + customURL := "https://custom.bsky.social" + svc := NewBlueskyService(customURL) + if svc.BaseURL() != customURL { + t.Errorf("expected custom URL %s, got %s", customURL, svc.BaseURL()) + } + }) +} + +// TestBlueskyService_Name verifies the service identifier. +func TestBlueskyService_Name(t *testing.T) { + svc := NewBlueskyService("") + if svc.Name() != "Bluesky" { + t.Errorf("expected name 'Bluesky', got %s", svc.Name()) + } +} + +// TestBlueskyService_Authenticate verifies successful authentication flow. +func TestBlueskyService_Authenticate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/com.atproto.server.createSession" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + if body["identifier"] != "test.bsky.social" { + t.Errorf("unexpected identifier: %s", body["identifier"]) + } + if body["password"] != "test-password" { + t.Errorf("unexpected password: %s", body["password"]) + } + + response := CreateSessionResponse{ + Did: "did:plc:test123", + Handle: "test.bsky.social", + AccessJwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzAwMDAwMDB9.test", + RefreshJwt: "refresh-token", + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + creds := map[string]string{ + "identifier": "test.bsky.social", + "password": "test-password", + } + + err := svc.Authenticate(context.Background(), creds) + if err != nil { + t.Fatalf("authentication failed: %v", err) + } + + if !svc.Authenticated() { + t.Error("service should be authenticated") + } + if svc.GetAccessToken() == "" { + t.Error("access token should be set") + } + if svc.GetRefreshToken() == "" { + t.Error("refresh token should be set") + } +} + +// TestBlueskyService_Authenticate_InvalidCredentials verifies error handling for invalid credentials. +func TestBlueskyService_Authenticate_InvalidCredentials(t *testing.T) { + tests := []struct { + name string + credentials any + wantErr string + }{ + { + name: "wrong type", + credentials: "not-a-map", + wantErr: "credentials must be map[string]string", + }, + { + name: "missing identifier", + credentials: map[string]string{"password": "test"}, + wantErr: "identifier required", + }, + { + name: "missing password", + credentials: map[string]string{"identifier": "test"}, + wantErr: "password required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := NewBlueskyService("") + err := svc.Authenticate(context.Background(), tt.credentials) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +// TestBlueskyService_Authenticate_ServerError verifies error handling for server errors. +func TestBlueskyService_Authenticate_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"InvalidCredentials","message":"Invalid handle or password"}`)) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + creds := map[string]string{ + "identifier": "test.bsky.social", + "password": "wrong-password", + } + + err := svc.Authenticate(context.Background(), creds) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 error, got: %v", err) + } +} + +// TestBlueskyService_Request verifies generic request handling. +func TestBlueskyService_Request(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + t.Errorf("missing or invalid authorization header: %s", auth) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-access-token", "test-refresh-token") + + resp, err := svc.Request(context.Background(), "GET", "/test", nil, nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } +} + +// TestBlueskyService_Request_Unauthenticated verifies error when not authenticated. +func TestBlueskyService_Request_Unauthenticated(t *testing.T) { + svc := NewBlueskyService("") + _, err := svc.Request(context.Background(), "GET", "/test", nil, nil) + if err == nil { + t.Fatal("expected error for unauthenticated request") + } + if !strings.Contains(err.Error(), "not authenticated") { + t.Errorf("expected authentication error, got: %v", err) + } +} + +// TestBlueskyService_Request_TokenRefresh verifies automatic token refresh on 401. +func TestBlueskyService_Request_TokenRefresh(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/xrpc/com.atproto.server.refreshSession": + response := CreateSessionResponse{ + AccessJwt: "new-access-token", + RefreshJwt: "new-refresh-token", + } + json.NewEncoder(w).Encode(response) + case "/test": + callCount++ + if callCount == 1 { + w.WriteHeader(http.StatusUnauthorized) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("expired-token", "refresh-token") + + resp, err := svc.Request(context.Background(), "GET", "/test", nil, nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 after refresh, got %d", resp.StatusCode) + } + if callCount != 2 { + t.Errorf("expected 2 calls (failed + retry), got %d", callCount) + } + if svc.GetAccessToken() != "new-access-token" { + t.Error("access token should be updated after refresh") + } +} + +// TestBlueskyService_HealthCheck verifies connectivity checks. +func TestBlueskyService_HealthCheck(t *testing.T) { + t.Run("healthy", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/_health" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + if err := svc.HealthCheck(context.Background()); err != nil { + t.Errorf("health check failed: %v", err) + } + }) + + t.Run("unhealthy", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + err := svc.HealthCheck(context.Background()) + if err == nil { + t.Error("expected health check to fail") + } + }) +} + +// TestBlueskyService_GetTimeline verifies timeline fetching. +func TestBlueskyService_GetTimeline(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.feed.getTimeline") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + limit := r.URL.Query().Get("limit") + if limit != "50" { + t.Errorf("expected limit=50, got %s", limit) + } + + response := GetTimelineResponse{ + Cursor: "next-cursor", + Feed: []FeedViewPost{ + { + Post: &PostView{ + Uri: "at://test/post1", + Author: &ActorProfile{ + Did: "did:plc:test", + Handle: "test.bsky.social", + }, + }, + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + timeline, err := svc.GetTimeline(context.Background(), 50, "") + if err != nil { + t.Fatalf("GetTimeline failed: %v", err) + } + + if timeline.Cursor != "next-cursor" { + t.Errorf("expected cursor 'next-cursor', got %s", timeline.Cursor) + } + if len(timeline.Feed) != 1 { + t.Errorf("expected 1 post, got %d", len(timeline.Feed)) + } +} + +// TestBlueskyService_GetAuthorFeed verifies author feed fetching. +func TestBlueskyService_GetAuthorFeed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.feed.getAuthorFeed") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + actor := r.URL.Query().Get("actor") + if actor != "test.bsky.social" { + t.Errorf("expected actor=test.bsky.social, got %s", actor) + } + + response := GetAuthorFeedResponse{ + Feed: []FeedViewPost{ + { + Post: &PostView{ + Uri: "at://test/post1", + }, + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + feed, err := svc.GetAuthorFeed(context.Background(), "test.bsky.social", 50, "") + if err != nil { + t.Fatalf("GetAuthorFeed failed: %v", err) + } + + if len(feed.Feed) != 1 { + t.Errorf("expected 1 post, got %d", len(feed.Feed)) + } +} + +// TestBlueskyService_GetFollows verifies follows fetching. +func TestBlueskyService_GetFollows(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "app.bsky.graph.getFollows") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + cursor := r.URL.Query().Get("cursor") + if cursor != "test-cursor" { + t.Errorf("expected cursor=test-cursor, got %s", cursor) + } + + response := GetFollowsResponse{ + Subject: "did:plc:test", + Cursor: "next-cursor", + Follows: []ActorProfile{ + { + Did: "did:plc:follow1", + Handle: "follow1.bsky.social", + }, + }, + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + follows, err := svc.GetFollows(context.Background(), "test.bsky.social", 50, "test-cursor") + if err != nil { + t.Fatalf("GetFollows failed: %v", err) + } + + if follows.Cursor != "next-cursor" { + t.Errorf("expected cursor 'next-cursor', got %s", follows.Cursor) + } + if len(follows.Follows) != 1 { + t.Errorf("expected 1 follow, got %d", len(follows.Follows)) + } +} + +// TestBlueskyService_Close verifies cleanup. +func TestBlueskyService_Close(t *testing.T) { + svc := NewBlueskyService("") + svc.SetTokens("test-token", "refresh-token") + + if err := svc.Close(); err != nil { + t.Errorf("Close failed: %v", err) + } + + if svc.Authenticated() { + t.Error("service should not be authenticated after close") + } + if svc.GetAccessToken() != "" { + t.Error("access token should be cleared after close") + } +} + +// TestBlueskyService_SetTokens verifies token setting. +func TestBlueskyService_SetTokens(t *testing.T) { + svc := NewBlueskyService("") + svc.SetTokens("access-token", "refresh-token") + + if !svc.Authenticated() { + t.Error("service should be authenticated after setting tokens") + } + if svc.GetAccessToken() != "access-token" { + t.Errorf("expected access token 'access-token', got %s", svc.GetAccessToken()) + } + if svc.GetRefreshToken() != "refresh-token" { + t.Errorf("expected refresh token 'refresh-token', got %s", svc.GetRefreshToken()) + } +} + +// TestParseJWTExpiry verifies JWT expiry parsing. +func TestParseJWTExpiry(t *testing.T) { + t.Run("valid JWT", func(t *testing.T) { + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzAwMDAwMDB9.dummysignature" + expiry, err := parseJWTExpiry(token) + if err != nil { + t.Fatalf("parseJWTExpiry failed: %v", err) + } + + expected := time.Unix(1730000000, 0) + if !expiry.Equal(expected) { + t.Errorf("expected expiry %v, got %v", expected, expiry) + } + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := parseJWTExpiry("not.a.jwt") + if err == nil { + t.Error("expected error for invalid JWT") + } + }) + + t.Run("missing exp claim", func(t *testing.T) { + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dummysignature" + _, err := parseJWTExpiry(token) + if err == nil { + t.Error("expected error for JWT without exp claim") + } + }) +} + +// TestBlueskyService_ShouldRefreshToken verifies token refresh logic. +func TestBlueskyService_ShouldRefreshToken(t *testing.T) { + t.Run("no expiry set", func(t *testing.T) { + svc := NewBlueskyService("") + if svc.shouldRefreshToken() { + t.Error("should not refresh when expiry is zero") + } + }) + + t.Run("token expired", func(t *testing.T) { + svc := NewBlueskyService("") + svc.tokenExpiry = time.Now().Add(-1 * time.Second) + if !svc.shouldRefreshToken() { + t.Error("should refresh token when expired") + } + }) + + t.Run("token very close to expiry", func(t *testing.T) { + svc := NewBlueskyService("") + svc.tokenExpiry = time.Now().Add(100 * time.Millisecond) + if svc.shouldRefreshToken() { + t.Error("should not refresh with 100ms remaining") + } + }) + + t.Run("token not near expiry", func(t *testing.T) { + svc := NewBlueskyService("") + svc.tokenExpiry = time.Now().Add(1 * time.Hour) + if svc.shouldRefreshToken() { + t.Error("should not refresh token when far from expiry") + } + }) +} + +// TestBlueskyService_RefreshAccessToken verifies token refresh flow. +func TestBlueskyService_RefreshAccessToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/xrpc/com.atproto.server.refreshSession" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + auth := r.Header.Get("Authorization") + if !strings.Contains(auth, "old-refresh-token") { + t.Errorf("expected refresh token in auth header, got: %s", auth) + } + + response := CreateSessionResponse{ + AccessJwt: "new-access-token", + RefreshJwt: "new-refresh-token", + } + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.accessToken = "old-access-token" + svc.refreshToken = "old-refresh-token" + svc.authenticated = true + + err := svc.refreshAccessToken(context.Background()) + if err != nil { + t.Fatalf("refreshAccessToken failed: %v", err) + } + + if svc.GetAccessToken() != "new-access-token" { + t.Errorf("expected new access token, got %s", svc.GetAccessToken()) + } + if svc.GetRefreshToken() != "new-refresh-token" { + t.Errorf("expected new refresh token, got %s", svc.GetRefreshToken()) + } +} + +// TestBlueskyService_APIErrors verifies error handling for various API failures. +func TestBlueskyService_APIErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + method func(*BlueskyService) error + }{ + { + name: "GetTimeline 500", + statusCode: http.StatusInternalServerError, + method: func(svc *BlueskyService) error { + _, err := svc.GetTimeline(context.Background(), 50, "") + return err + }, + }, + { + name: "GetAuthorFeed 404", + statusCode: http.StatusNotFound, + method: func(svc *BlueskyService) error { + _, err := svc.GetAuthorFeed(context.Background(), "nonexistent", 50, "") + return err + }, + }, + { + name: "GetFollows 403", + statusCode: http.StatusForbidden, + method: func(svc *BlueskyService) error { + _, err := svc.GetFollows(context.Background(), "blocked", 50, "") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(`{"error":"TestError","message":"Test error message"}`)) + })) + defer server.Close() + + svc := NewBlueskyService(server.URL) + svc.SetTokens("test-token", "refresh-token") + + err := tt.method(svc) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} diff --git a/cli/internal/store/migration_test.go b/cli/internal/store/migration_test.go new file mode 100644 index 0000000..4da1135 --- /dev/null +++ b/cli/internal/store/migration_test.go @@ -0,0 +1,359 @@ +package store + +import ( + "database/sql" + "testing" + + "github.com/stormlightlabs/skypanel/cli/internal/utils" +) + +// TestRunMigrations verifies that migrations are applied correctly to a fresh database. +// It checks that the schema_migrations table is created and all migrations are executed in order. +func TestRunMigrations(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := RunMigrations(db); err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) + if err != nil { + t.Fatalf("schema_migrations table not found: %v", err) + } + + if count != 2 { + t.Errorf("expected 2 migrations applied, got %d", count) + } + + err = db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&count) + if err != nil { + t.Errorf("feeds table not created: %v", err) + } + + err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count) + if err != nil { + t.Errorf("posts table not created: %v", err) + } +} + +// TestRunMigrations_Idempotent verifies that running migrations multiple times +// doesn't re-apply already executed migrations. +func TestRunMigrations_Idempotent(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := RunMigrations(db); err != nil { + t.Fatalf("first RunMigrations failed: %v", err) + } + + if err := RunMigrations(db); err != nil { + t.Fatalf("second RunMigrations failed: %v", err) + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) + if err != nil { + t.Fatalf("failed to query migrations: %v", err) + } + + if count != 2 { + t.Errorf("expected 2 migrations, got %d", count) + } +} + +// TestRollback verifies that down migrations correctly revert database changes. +func TestRollback(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := RunMigrations(db); err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + if err := Rollback(db, 1); err != nil { + t.Fatalf("Rollback failed: %v", err) + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) + if err != nil { + t.Fatalf("failed to query migrations: %v", err) + } + + if count != 1 { + t.Errorf("expected 1 migration after rollback, got %d", count) + } + + err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count) + if err == nil { + t.Error("posts table should not exist after rollback") + } + + err = db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&count) + if err != nil { + t.Errorf("feeds table should still exist: %v", err) + } +} + +// TestRollback_Complete verifies that rolling back to version 0 removes all migrations. +func TestRollback_Complete(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := RunMigrations(db); err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + if err := Rollback(db, 0); err != nil { + t.Fatalf("Rollback failed: %v", err) + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count) + if err != nil { + t.Fatalf("failed to query migrations: %v", err) + } + + if count != 0 { + t.Errorf("expected 0 migrations after complete rollback, got %d", count) + } + + var feedCount int + err = db.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&feedCount) + if err == nil { + t.Error("feeds table should not exist after complete rollback") + } + + var postCount int + err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&postCount) + if err == nil { + t.Error("posts table should not exist after complete rollback") + } +} + +// TestMigrationOrdering verifies that migrations are applied in correct version order. +func TestMigrationOrdering(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := RunMigrations(db); err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") + if err != nil { + t.Fatalf("failed to query migrations: %v", err) + } + defer rows.Close() + + expectedVersions := []int{1, 2} + var actualVersions []int + + for rows.Next() { + var version int + if err := rows.Scan(&version); err != nil { + t.Fatalf("failed to scan version: %v", err) + } + actualVersions = append(actualVersions, version) + } + + if len(actualVersions) != len(expectedVersions) { + t.Errorf("expected %d versions, got %d", len(expectedVersions), len(actualVersions)) + } + + for i, expected := range expectedVersions { + if i >= len(actualVersions) || actualVersions[i] != expected { + t.Errorf("migration %d: expected version %d, got %d", i, expected, actualVersions[i]) + } + } +} + +// TestGetAppliedMigrations verifies the helper function correctly retrieves applied migrations. +func TestGetAppliedMigrations(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := createMigrationsTable(db); err != nil { + t.Fatalf("failed to create migrations table: %v", err) + } + + _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?), (?)", 1, 2) + if err != nil { + t.Fatalf("failed to insert test migrations: %v", err) + } + + applied, err := getAppliedMigrations(db) + if err != nil { + t.Fatalf("getAppliedMigrations failed: %v", err) + } + + if !applied[1] { + t.Error("migration 1 should be applied") + } + if !applied[2] { + t.Error("migration 2 should be applied") + } + if applied[3] { + t.Error("migration 3 should not be applied") + } +} + +// TestLoadMigrations verifies that migration files are correctly loaded from embedded FS. +func TestLoadMigrations(t *testing.T) { + upMigrations, err := loadMigrations("up") + if err != nil { + t.Fatalf("failed to load up migrations: %v", err) + } + + if len(upMigrations) != 2 { + t.Errorf("expected 2 up migrations, got %d", len(upMigrations)) + } + + for i := 1; i < len(upMigrations); i++ { + if upMigrations[i-1].Version >= upMigrations[i].Version { + t.Errorf("migrations not sorted: %d >= %d", upMigrations[i-1].Version, upMigrations[i].Version) + } + } + + downMigrations, err := loadMigrations("down") + if err != nil { + t.Fatalf("failed to load down migrations: %v", err) + } + + if len(downMigrations) != 2 { + t.Errorf("expected 2 down migrations, got %d", len(downMigrations)) + } +} + +// TestExecuteMigration verifies that SQL is correctly executed. +func TestExecuteMigration(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + m := migration{ + Version: 1, + Name: "test_migration", + SQL: "CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)", + } + + if err := executeMigration(db, m); err != nil { + t.Fatalf("executeMigration failed: %v", err) + } + + var count int + err := db.QueryRow("SELECT COUNT(*) FROM test").Scan(&count) + if err != nil { + t.Errorf("test table not created: %v", err) + } +} + +// TestRecordAndRemoveMigration verifies the migration tracking functions. +func TestRecordAndRemoveMigration(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := createMigrationsTable(db); err != nil { + t.Fatalf("failed to create migrations table: %v", err) + } + + if err := recordMigration(db, 42); err != nil { + t.Fatalf("recordMigration failed: %v", err) + } + + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", 42).Scan(&exists) + if err != nil { + t.Fatalf("failed to check migration: %v", err) + } + if !exists { + t.Error("migration 42 should be recorded") + } + + if err := removeMigration(db, 42); err != nil { + t.Fatalf("removeMigration failed: %v", err) + } + + err = db.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?)", 42).Scan(&exists) + if err != nil { + t.Fatalf("failed to check migration: %v", err) + } + if exists { + t.Error("migration 42 should be removed") + } +} + +// TestMigrationWithForeignKey verifies that foreign key constraints work correctly. +func TestMigrationWithForeignKey(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + t.Fatalf("failed to enable foreign keys: %v", err) + } + + if err := RunMigrations(db); err != nil { + t.Fatalf("RunMigrations failed: %v", err) + } + + _, err := db.Exec(` + INSERT INTO feeds (id, created_at, updated_at, name, source, is_local) + VALUES ('feed1', datetime('now'), datetime('now'), 'Test Feed', 'timeline', 1) + `) + if err != nil { + t.Fatalf("failed to insert feed: %v", err) + } + + _, err = db.Exec(` + INSERT INTO posts (id, created_at, updated_at, uri, author_did, text, feed_id, indexed_at) + VALUES ('post1', datetime('now'), datetime('now'), 'at://test', 'did:test', 'Hello', 'feed1', datetime('now')) + `) + if err != nil { + t.Fatalf("failed to insert post: %v", err) + } + + _, err = db.Exec(` + INSERT INTO posts (id, created_at, updated_at, uri, author_did, text, feed_id, indexed_at) + VALUES ('post2', datetime('now'), datetime('now'), 'at://test2', 'did:test', 'Hello', 'nonexistent', datetime('now')) + `) + if err == nil { + t.Error("expected foreign key constraint error, got nil") + } +} + +// TestCreateMigrationsTable verifies the migrations tracking table is created correctly. +func TestCreateMigrationsTable(t *testing.T) { + db, cleanup := utils.NewTestDB(t) + defer cleanup() + + if err := createMigrationsTable(db); err != nil { + t.Fatalf("createMigrationsTable failed: %v", err) + } + + rows, err := db.Query("PRAGMA table_info(schema_migrations)") + if err != nil { + t.Fatalf("failed to get table info: %v", err) + } + defer rows.Close() + + columns := make(map[string]bool) + for rows.Next() { + var cid int + var name, colType string + var notNull, pk int + var dfltValue sql.NullString + + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + t.Fatalf("failed to scan column info: %v", err) + } + columns[name] = true + } + + if !columns["version"] { + t.Error("version column missing") + } + if !columns["applied_at"] { + t.Error("applied_at column missing") + } +} diff --git a/cli/internal/store/service_test.go b/cli/internal/store/service_test.go new file mode 100644 index 0000000..576c73f --- /dev/null +++ b/cli/internal/store/service_test.go @@ -0,0 +1,142 @@ +package store + +import ( + "testing" +) + +// TestBlueskyServiceImplementsServiceInterface verifies that BlueskyService +// implements the Service interface correctly. +func TestBlueskyServiceImplementsServiceInterface(t *testing.T) { + var _ Service = (*BlueskyService)(nil) +} + +// TestServiceIdentifierTypes verifies the type aliases are correctly defined. +func TestServiceIdentifierTypes(t *testing.T) { + var id ServiceID = 1 + if id != 1 { + t.Errorf("ServiceID type alias broken") + } + + var identifier ServiceIdentifier = "Bluesky" + if identifier != "Bluesky" { + t.Errorf("ServiceIdentifier type alias broken") + } +} + +// TestCreateSessionResponse verifies the response struct can be instantiated. +func TestCreateSessionResponse(t *testing.T) { + response := CreateSessionResponse{ + Did: "did:plc:test123", + Handle: "test.bsky.social", + Active: true, + // AccessJwt: "access-token", + // RefreshJwt: "refresh-token", + } + + if response.Did != "did:plc:test123" { + t.Errorf("expected Did 'did:plc:test123', got %s", response.Did) + } + if response.Handle != "test.bsky.social" { + t.Errorf("expected Handle 'test.bsky.social', got %s", response.Handle) + } + if !response.Active { + t.Error("expected Active to be true") + } +} + +// TestActorProfile verifies the ActorProfile struct. +func TestActorProfile(t *testing.T) { + profile := ActorProfile{ + Did: "did:plc:test", + FollowersCount: 100, + // Handle: "test.bsky.social", + // DisplayName: "Test User", + // FollowsCount: 50, + // PostsCount: 25, + } + + if profile.Did != "did:plc:test" { + t.Errorf("expected Did 'did:plc:test', got %s", profile.Did) + } + if profile.FollowersCount != 100 { + t.Errorf("expected FollowersCount 100, got %d", profile.FollowersCount) + } +} + +// TestPostView verifies the PostView struct. +func TestPostView(t *testing.T) { + post := PostView{ + Uri: "at://did:plc:test/app.bsky.feed.post/123", + LikeCount: 25, + // Cid: "bafyrei123", + // ReplyCount: 5, + // RepostCount: 10, + // QuoteCount: 2, + } + + if post.Uri != "at://did:plc:test/app.bsky.feed.post/123" { + t.Errorf("unexpected Uri: %s", post.Uri) + } + if post.LikeCount != 25 { + t.Errorf("expected LikeCount 25, got %d", post.LikeCount) + } +} + +// TestGetTimelineResponse verifies timeline response structure. +func TestGetTimelineResponse(t *testing.T) { + response := GetTimelineResponse{ + Cursor: "next-cursor-123", + Feed: []FeedViewPost{ + { + Post: &PostView{ + Uri: "at://test/post1", + }, + }, + }, + } + + if response.Cursor != "next-cursor-123" { + t.Errorf("unexpected Cursor: %s", response.Cursor) + } + if len(response.Feed) != 1 { + t.Errorf("expected 1 post, got %d", len(response.Feed)) + } +} + +// TestViewerState verifies viewer state structure. +func TestViewerState(t *testing.T) { + state := ViewerState{ + Muted: false, + Following: "at://did:plc:me/app.bsky.graph.follow/abc", + Bookmarked: true, + // BlockedBy: false, + // Like: "at://did:plc:me/app.bsky.feed.like/xyz", + } + + if state.Muted { + t.Error("expected Muted to be false") + } + if !state.Bookmarked { + t.Error("expected Bookmarked to be true") + } + if state.Following == "" { + t.Error("expected Following to be set") + } +} + +// TestLabel verifies label structure. +func TestLabel(t *testing.T) { + label := Label{ + Src: "did:plc:moderator", + Val: "nsfw", + // Uri: "at://test/post1", + // Cts: "2024-01-01T00:00:00Z", + } + + if label.Val != "nsfw" { + t.Errorf("expected Val 'nsfw', got %s", label.Val) + } + if label.Src != "did:plc:moderator" { + t.Errorf("expected Src 'did:plc:moderator', got %s", label.Src) + } +} diff --git a/cli/internal/utils/testing.go b/cli/internal/utils/testing.go index e00a85c..c064b36 100644 --- a/cli/internal/utils/testing.go +++ b/cli/internal/utils/testing.go @@ -2,8 +2,12 @@ package utils import ( "bytes" + "database/sql" "io" "os" + "testing" + + _ "github.com/mattn/go-sqlite3" ) // CaptureOutput captures stdout during function execution @@ -21,3 +25,23 @@ func CaptureOutput(f func()) string { io.Copy(&buf, r) return buf.String() } + +// NewTestDB creates an in-memory SQLite database for testing. +// Returns the database connection and a cleanup function. +// The cleanup function should be called with defer to ensure proper cleanup. +func NewTestDB(t *testing.T) (*sql.DB, func()) { + t.Helper() + + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open in-memory database: %v", err) + } + + cleanup := func() { + if err := db.Close(); err != nil { + t.Errorf("failed to close test database: %v", err) + } + } + + return db, cleanup +}