cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 491 lines 14 kB view raw
1package services 2 3import ( 4 "bytes" 5 "context" 6 _ "embed" 7 "errors" 8 "strings" 9 "testing" 10 "time" 11 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/public" 14) 15 16// From: https://www.rottentomatoes.com/m/the_fantastic_four_first_steps 17// 18//go:embed samples/movie.html 19var MovieSample []byte 20 21// From: https://www.rottentomatoes.com/search?search=peacemaker 22// 23//go:embed samples/search.html 24var SearchSample []byte 25 26// From: https://www.rottentomatoes.com/tv/peacemaker_2022 27// 28//go:embed samples/series_overview.html 29var SeriesSample []byte 30 31// From: https://www.rottentomatoes.com/tv/peacemaker_2022/s02 32// 33//go:embed samples/series_season.html 34var SeasonSample []byte 35 36// From: https://www.rottentomatoes.com/search?search=Fantastic%20Four 37// 38//go:embed samples/movie_search.html 39var MovieSearchSample []byte 40 41// MockConfig holds configuration for mocking media services 42type MockConfig struct { 43 SearchResults []Media 44 SearchError error 45 MovieResult *Movie 46 MovieError error 47 TVSeriesResult *TVSeries 48 TVSeriesError error 49 TVSeasonResult *TVSeason 50 TVSeasonError error 51 HTMLResult string 52 HTMLError error 53} 54 55// MockSetup contains the original function variables for restoration 56type MockSetup struct { 57 originalSearchRottenTomatoes func(string) ([]Media, error) 58 originalFetchMovie func(string) (*Movie, error) 59 originalFetchTVSeries func(string) (*TVSeries, error) 60 originalFetchTVSeason func(string) (*TVSeason, error) 61 originalFetchHTML func(string) (string, error) 62} 63 64// SetupMediaMocks configures mock functions for media services testing 65func SetupMediaMocks(t *testing.T, config MockConfig) func() { 66 t.Helper() 67 68 setup := &MockSetup{ 69 originalSearchRottenTomatoes: SearchRottenTomatoes, 70 originalFetchMovie: FetchMovie, 71 originalFetchTVSeries: FetchTVSeries, 72 originalFetchTVSeason: FetchTVSeason, 73 originalFetchHTML: FetchHTML, 74 } 75 76 SearchRottenTomatoes = func(q string) ([]Media, error) { 77 if config.SearchError != nil { 78 return nil, config.SearchError 79 } 80 return config.SearchResults, nil 81 } 82 83 FetchMovie = func(url string) (*Movie, error) { 84 if config.MovieError != nil { 85 return nil, config.MovieError 86 } 87 return config.MovieResult, nil 88 } 89 90 FetchTVSeries = func(url string) (*TVSeries, error) { 91 if config.TVSeriesError != nil { 92 return nil, config.TVSeriesError 93 } 94 return config.TVSeriesResult, nil 95 } 96 97 FetchTVSeason = func(url string) (*TVSeason, error) { 98 if config.TVSeasonError != nil { 99 return nil, config.TVSeasonError 100 } 101 return config.TVSeasonResult, nil 102 } 103 104 FetchHTML = func(url string) (string, error) { 105 if config.HTMLError != nil { 106 return "", config.HTMLError 107 } 108 return config.HTMLResult, nil 109 } 110 111 return func() { 112 SearchRottenTomatoes = setup.originalSearchRottenTomatoes 113 FetchMovie = setup.originalFetchMovie 114 FetchTVSeries = setup.originalFetchTVSeries 115 FetchTVSeason = setup.originalFetchTVSeason 116 FetchHTML = setup.originalFetchHTML 117 } 118} 119 120// Sample data access helpers - these use the embedded samples 121func GetSampleMovieSearchResults() ([]Media, error) { 122 return ParseSearch(bytes.NewReader(MovieSearchSample)) 123} 124 125func GetSampleSearchResults() ([]Media, error) { 126 return ParseSearch(bytes.NewReader(SearchSample)) 127} 128 129func GetSampleMovie() (*Movie, error) { 130 return ExtractMovieMetadata(bytes.NewReader(MovieSample)) 131} 132 133func GetSampleTVSeries() (*TVSeries, error) { 134 return ExtractTVSeriesMetadata(bytes.NewReader(SeriesSample)) 135} 136 137func GetSampleTVSeason() (*TVSeason, error) { 138 return ExtractTVSeasonMetadata(bytes.NewReader(SeasonSample)) 139} 140 141// SetupSuccessfulMovieMocks configures mocks for successful movie operations 142func SetupSuccessfulMovieMocks(t *testing.T) func() { 143 t.Helper() 144 145 movieResults, err := GetSampleMovieSearchResults() 146 if err != nil { 147 t.Fatalf("failed to get sample movie results: %v", err) 148 } 149 150 movie, err := GetSampleMovie() 151 if err != nil { 152 t.Fatalf("failed to get sample movie: %v", err) 153 } 154 155 return SetupMediaMocks(t, MockConfig{ 156 SearchResults: movieResults, 157 MovieResult: movie, 158 HTMLResult: "ok", 159 }) 160} 161 162// SetupSuccessfulTVMocks configures mocks for successful TV operations 163func SetupSuccessfulTVMocks(t *testing.T) func() { 164 t.Helper() 165 166 searchResults, err := GetSampleSearchResults() 167 if err != nil { 168 t.Fatalf("failed to get sample search results: %v", err) 169 } 170 171 series, err := GetSampleTVSeries() 172 if err != nil { 173 t.Fatalf("failed to get sample TV series: %v", err) 174 } 175 176 return SetupMediaMocks(t, MockConfig{ 177 SearchResults: searchResults, 178 TVSeriesResult: series, 179 HTMLResult: "ok", 180 }) 181} 182 183// SetupFailureMocks configures mocks that return errors 184func SetupFailureMocks(t *testing.T, errorMsg string) func() { 185 t.Helper() 186 187 err := errors.New(errorMsg) 188 return SetupMediaMocks(t, MockConfig{ 189 SearchError: err, 190 MovieError: err, 191 TVSeriesError: err, 192 TVSeasonError: err, 193 HTMLError: err, 194 }) 195} 196 197// AssertMovieInResults checks if a movie with the given title exists in results 198func AssertMovieInResults(t *testing.T, results []*models.Model, expectedTitle string) { 199 t.Helper() 200 201 for _, result := range results { 202 if movie, ok := (*result).(*models.Movie); ok { 203 if strings.Contains(movie.Title, expectedTitle) { 204 return 205 } 206 } 207 } 208 t.Errorf("expected to find movie containing '%s' in results", expectedTitle) 209} 210 211// AssertTVShowInResults checks if a TV show with the given title exists in results 212func AssertTVShowInResults(t *testing.T, results []*models.Model, expectedTitle string) { 213 t.Helper() 214 215 for _, result := range results { 216 if show, ok := (*result).(*models.TVShow); ok { 217 if strings.Contains(show.Title, expectedTitle) { 218 return // Found it 219 } 220 } 221 } 222 t.Errorf("expected to find TV show containing '%s' in results", expectedTitle) 223} 224 225// CreateMovieService returns a new movie service for testing 226func CreateMovieService() *MovieService { 227 return NewMovieService() 228} 229 230// CreateTVService returns a new TV service for testing 231func CreateTVService() *TVService { 232 return NewTVService() 233} 234 235// TestMovieSearch runs a standard movie search test 236func TestMovieSearch(t *testing.T, service *MovieService, query string, expectedTitleFragment string) { 237 t.Helper() 238 239 results, err := service.Search(context.Background(), query, 1, 10) 240 if err != nil { 241 t.Fatalf("Search failed: %v", err) 242 } 243 if len(results) == 0 { 244 t.Fatal("expected search results, got none") 245 } 246 247 AssertMovieInResults(t, results, expectedTitleFragment) 248} 249 250// TestTVSearch runs a standard TV search test 251func TestTVSearch(t *testing.T, service *TVService, query string, expectedTitleFragment string) { 252 t.Helper() 253 254 results, err := service.Search(context.Background(), query, 1, 10) 255 if err != nil { 256 t.Fatalf("Search failed: %v", err) 257 } 258 if len(results) == 0 { 259 t.Fatal("expected search results, got none") 260 } 261 262 AssertTVShowInResults(t, results, expectedTitleFragment) 263} 264 265// MockATProtoService is a mock implementation of ATProtoService for testing 266type MockATProtoService struct { 267 AuthenticateFunc func(ctx context.Context, handle, password string) error 268 GetSessionFunc func() (*Session, error) 269 IsAuthenticatedVal bool 270 RestoreSessionFunc func(session *Session) error 271 PullDocumentsFunc func(ctx context.Context) ([]DocumentWithMeta, error) 272 PostDocumentFunc func(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 273 PatchDocumentFunc func(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 274 DeleteDocumentFunc func(ctx context.Context, rkey string, isDraft bool) error 275 UploadBlobFunc func(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 276 GetDefaultPublicationFunc func(ctx context.Context) (string, error) 277 CloseFunc func() error 278 Session *Session // Exported for test access 279} 280 281// NewMockATProtoService creates a new mock AT Proto service 282func NewMockATProtoService() *MockATProtoService { 283 return &MockATProtoService{IsAuthenticatedVal: false} 284} 285 286// Authenticate mocks authentication 287func (m *MockATProtoService) Authenticate(ctx context.Context, handle, password string) error { 288 if m.AuthenticateFunc != nil { 289 return m.AuthenticateFunc(ctx, handle, password) 290 } 291 292 // Default successful authentication 293 m.Session = &Session{ 294 DID: "did:plc:test123", 295 Handle: handle, 296 AccessJWT: "mock_access_token", 297 RefreshJWT: "mock_refresh_token", 298 PDSURL: "https://bsky.social", 299 ExpiresAt: time.Now().Add(2 * time.Hour), 300 Authenticated: true, 301 } 302 m.IsAuthenticatedVal = true 303 return nil 304} 305 306// GetSession returns the current session 307func (m *MockATProtoService) GetSession() (*Session, error) { 308 if m.GetSessionFunc != nil { 309 return m.GetSessionFunc() 310 } 311 312 if m.Session == nil || !m.Session.Authenticated { 313 return nil, errors.New("not authenticated - run 'noteleaf pub auth' first") 314 } 315 return m.Session, nil 316} 317 318// IsAuthenticated returns authentication status 319func (m *MockATProtoService) IsAuthenticated() bool { 320 return m.IsAuthenticatedVal 321} 322 323// RestoreSession restores a session 324func (m *MockATProtoService) RestoreSession(session *Session) error { 325 if m.RestoreSessionFunc != nil { 326 return m.RestoreSessionFunc(session) 327 } 328 329 m.Session = session 330 m.IsAuthenticatedVal = true 331 return nil 332} 333 334// PullDocuments mocks pulling documents 335func (m *MockATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, error) { 336 if m.PullDocumentsFunc != nil { 337 return m.PullDocumentsFunc(ctx) 338 } 339 return []DocumentWithMeta{}, nil 340} 341 342// PostDocument mocks posting a document 343func (m *MockATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 344 if m.PostDocumentFunc != nil { 345 return m.PostDocumentFunc(ctx, doc, isDraft) 346 } 347 348 // Default successful post 349 return &DocumentWithMeta{ 350 Document: doc, 351 Meta: public.DocumentMeta{ 352 RKey: "mock_rkey_123", 353 CID: "mock_cid_456", 354 URI: "at://did:plc:test123/pub.leaflet.document/mock_rkey_123", 355 IsDraft: isDraft, 356 FetchedAt: time.Now(), 357 }, 358 }, nil 359} 360 361// PatchDocument mocks patching a document 362func (m *MockATProtoService) PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 363 if m.PatchDocumentFunc != nil { 364 return m.PatchDocumentFunc(ctx, rkey, doc, isDraft) 365 } 366 367 return &DocumentWithMeta{ 368 Document: doc, 369 Meta: public.DocumentMeta{ 370 RKey: rkey, 371 CID: "mock_cid_updated_789", 372 URI: "at://did:plc:test123/pub.leaflet.document/" + rkey, 373 IsDraft: isDraft, 374 FetchedAt: time.Now(), 375 }, 376 }, nil 377} 378 379// DeleteDocument mocks deleting a document 380func (m *MockATProtoService) DeleteDocument(ctx context.Context, rkey string, isDraft bool) error { 381 if m.DeleteDocumentFunc != nil { 382 return m.DeleteDocumentFunc(ctx, rkey, isDraft) 383 } 384 return nil 385} 386 387// UploadBlob mocks blob upload 388func (m *MockATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) { 389 if m.UploadBlobFunc != nil { 390 return m.UploadBlobFunc(ctx, data, mimeType) 391 } 392 393 return public.Blob{ 394 Type: public.TypeBlob, 395 Ref: public.CID{Link: "mock_blob_cid"}, 396 MimeType: mimeType, 397 Size: len(data), 398 }, nil 399} 400 401// GetDefaultPublication mocks getting the default publication 402func (m *MockATProtoService) GetDefaultPublication(ctx context.Context) (string, error) { 403 if m.GetDefaultPublicationFunc != nil { 404 return m.GetDefaultPublicationFunc(ctx) 405 } 406 407 // Default returns a mock publication URI 408 if !m.IsAuthenticatedVal { 409 return "", errors.New("not authenticated") 410 } 411 return "at://did:plc:test123/pub.leaflet.publication/mock_pub_rkey", nil 412} 413 414// Close mocks cleanup 415func (m *MockATProtoService) Close() error { 416 if m.CloseFunc != nil { 417 return m.CloseFunc() 418 } 419 m.Session = nil 420 m.IsAuthenticatedVal = false 421 return nil 422} 423 424// SetupSuccessfulAuthMocks configures mock for successful authentication 425func SetupSuccessfulAuthMocks() *MockATProtoService { 426 mock := NewMockATProtoService() 427 mock.AuthenticateFunc = func(ctx context.Context, handle, password string) error { 428 mock.Session = &Session{ 429 DID: "did:plc:test123", 430 Handle: handle, 431 AccessJWT: "mock_access_token", 432 RefreshJWT: "mock_refresh_token", 433 PDSURL: "https://bsky.social", 434 ExpiresAt: time.Now().Add(2 * time.Hour), 435 Authenticated: true, 436 } 437 mock.IsAuthenticatedVal = true 438 return nil 439 } 440 return mock 441} 442 443// SetupSuccessfulPullMocks configures mock for successful document pull 444func SetupSuccessfulPullMocks() *MockATProtoService { 445 mock := NewMockATProtoService() 446 mock.IsAuthenticatedVal = true 447 mock.Session = &Session{ 448 DID: "did:plc:test123", 449 Handle: "test.bsky.social", 450 AccessJWT: "mock_access", 451 RefreshJWT: "mock_refresh", 452 PDSURL: "https://bsky.social", 453 ExpiresAt: time.Now().Add(2 * time.Hour), 454 Authenticated: true, 455 } 456 457 mock.PullDocumentsFunc = func(ctx context.Context) ([]DocumentWithMeta, error) { 458 return []DocumentWithMeta{ 459 { 460 Document: public.Document{ 461 Type: public.TypeDocument, 462 Title: "Test Document", 463 Pages: []public.LinearDocument{ 464 { 465 Type: public.TypeLinearDocument, 466 Blocks: []public.BlockWrap{ 467 { 468 Type: "pub.leaflet.pages.linearDocument#block", 469 Block: public.TextBlock{ 470 Type: "pub.leaflet.pages.linearDocument#textBlock", 471 Plaintext: "Test content", 472 }, 473 }, 474 }, 475 }, 476 }, 477 PublishedAt: time.Now().Format(time.RFC3339), 478 }, 479 Meta: public.DocumentMeta{ 480 RKey: "test_rkey", 481 CID: "test_cid", 482 URI: "at://did:plc:test123/pub.leaflet.document/test_rkey", 483 IsDraft: false, 484 FetchedAt: time.Now(), 485 }, 486 }, 487 }, nil 488 } 489 490 return mock 491}