cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}