cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package handlers
2
3import (
4 "context"
5 "net/http"
6 "net/http/httptest"
7 "runtime"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/stormlightlabs/noteleaf/internal/articles"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14 "github.com/stormlightlabs/noteleaf/internal/repo"
15 "github.com/stormlightlabs/noteleaf/internal/shared"
16)
17
18func TestArticleHandler(t *testing.T) {
19 t.Run("NewArticleHandler", func(t *testing.T) {
20 t.Run("creates handler successfully", func(t *testing.T) {
21 helper := NewArticleTestHelper(t)
22
23 if helper.ArticleHandler == nil {
24 t.Fatal("Handler should not be nil")
25 }
26
27 if helper.db == nil {
28 t.Error("Handler database should not be nil")
29 }
30 if helper.config == nil {
31 t.Error("Handler config should not be nil")
32 }
33 if helper.repos == nil {
34 t.Error("Handler repos should not be nil")
35 }
36 if helper.parser == nil {
37 t.Error("Handler parser should not be nil")
38 }
39 })
40
41 t.Run("handles database initialization error", func(t *testing.T) {
42 envHelper := NewEnvironmentTestHelper()
43 defer envHelper.RestoreEnv()
44
45 if runtime.GOOS == "windows" {
46 envHelper.UnsetEnv("APPDATA")
47 } else {
48 envHelper.UnsetEnv("XDG_CONFIG_HOME")
49 envHelper.UnsetEnv("HOME")
50 }
51
52 _, err := NewArticleHandler()
53 shared.AssertErrorContains(t, err, "failed to initialize database", "NewArticleHandler should fail when database initialization fails")
54 })
55
56 })
57
58 t.Run("Add", func(t *testing.T) {
59 t.Run("adds article successfully", func(t *testing.T) {
60 helper := NewArticleTestHelper(t)
61 ctx := context.Background()
62
63 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64 w.WriteHeader(http.StatusOK)
65 w.Write([]byte(`<html>
66 <head><title>Test Article</title></head>
67 <body>
68 <h1 id="firstHeading">Test Article Title</h1>
69 <div class="author">Test Author</div>
70 <div class="date">2024-01-01</div>
71 <div id="bodyContent">
72 <p>This is test content for the article.</p>
73 </div>
74 </body>
75 </html>`))
76 }))
77 defer server.Close()
78
79 testRule := &articles.ParsingRule{
80 Domain: "127.0.0.1",
81 Title: "//h1[@id='firstHeading']",
82 Author: "//div[@class='author']",
83 Date: "//div[@class='date']",
84 Body: "//div[@id='bodyContent']",
85 }
86 helper.AddTestRule("127.0.0.1", testRule)
87
88 err := helper.Add(ctx, server.URL+"/test-article")
89 shared.AssertNoError(t, err, "Add should succeed with valid URL")
90
91 articles, err := helper.repos.Articles.List(ctx, &repo.ArticleListOptions{})
92 if err != nil {
93 t.Fatalf("Failed to list articles: %v", err)
94 }
95
96 if len(articles) != 1 {
97 t.Errorf("Expected 1 article, got %d", len(articles))
98 }
99
100 article := articles[0]
101 if article.Title != "Test Article Title" {
102 t.Errorf("Expected title 'Test Article Title', got '%s'", article.Title)
103 }
104 if article.Author != "Test Author" {
105 t.Errorf("Expected author 'Test Author', got '%s'", article.Author)
106 }
107 })
108
109 t.Run("handles duplicate article", func(t *testing.T) {
110 helper := NewArticleTestHelper(t)
111 ctx := context.Background()
112
113 duplicateURL := "https://example.com/duplicate"
114
115 existingArticle := &models.Article{
116 URL: duplicateURL,
117 Title: "Existing Article",
118 Author: "Existing Author",
119 Date: "2024-01-01",
120 MarkdownPath: "/path/to/existing.md",
121 HTMLPath: "/path/to/existing.html",
122 Created: time.Now(),
123 Modified: time.Now(),
124 }
125
126 _, err := helper.repos.Articles.Create(ctx, existingArticle)
127 if err != nil {
128 t.Fatalf("Failed to create existing article: %v", err)
129 }
130
131 err = helper.Add(ctx, duplicateURL)
132 shared.AssertNoError(t, err, "Add should succeed with duplicate URL and return existing")
133 })
134
135 t.Run("handles unsupported domain", func(t *testing.T) {
136 helper := NewArticleTestHelper(t)
137 ctx := context.Background()
138
139 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140 w.WriteHeader(http.StatusOK)
141 w.Write([]byte("<html><head><title>Test</title></head><body><p>Content</p></body></html>"))
142 }))
143 defer server.Close()
144
145 err := helper.Add(ctx, server.URL+"/unsupported")
146 shared.AssertErrorContains(t, err, "failed to parse article", "Add should fail with unsupported domain")
147 })
148
149 t.Run("handles HTTP error", func(t *testing.T) {
150 helper := NewArticleTestHelper(t)
151 ctx := context.Background()
152
153 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 w.WriteHeader(http.StatusNotFound)
155 }))
156 defer server.Close()
157
158 err := helper.Add(ctx, server.URL+"/404")
159 shared.AssertErrorContains(t, err, "failed to parse article", "Add should fail with HTTP error")
160 })
161
162 t.Run("handles storage directory error", func(t *testing.T) {
163 helper := NewArticleTestHelper(t)
164 ctx := context.Background()
165
166 envHelper := NewEnvironmentTestHelper()
167 defer envHelper.RestoreEnv()
168
169 // Unset all environment variables that could provide a storage directory
170 envHelper.UnsetEnv("NOTELEAF_DATA_DIR")
171 envHelper.UnsetEnv("NOTELEAF_CONFIG")
172
173 if runtime.GOOS == "windows" {
174 envHelper.UnsetEnv("USERPROFILE")
175 envHelper.UnsetEnv("HOMEDRIVE")
176 envHelper.UnsetEnv("HOMEPATH")
177 envHelper.UnsetEnv("LOCALAPPDATA")
178 } else {
179 envHelper.UnsetEnv("HOME")
180 envHelper.UnsetEnv("XDG_DATA_HOME")
181 }
182
183 err := helper.Add(ctx, "https://example.com/test-article")
184 shared.AssertErrorContains(t, err, "failed to get article storage dir", "Add should fail when storage directory cannot be determined")
185 })
186
187 t.Run("handles database save error", func(t *testing.T) {
188 helper := NewArticleTestHelper(t)
189 ctx := context.Background()
190
191 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192 w.WriteHeader(http.StatusOK)
193 w.Write([]byte(`<html>
194 <head><title>Test Article</title></head>
195 <body>
196 <h1 id="firstHeading">Test Article</h1>
197 <div id="bodyContent">Test content</div>
198 </body>
199 </html>`))
200 }))
201 defer server.Close()
202
203 testRule := &articles.ParsingRule{
204 Domain: "127.0.0.1",
205 Title: "//h1[@id='firstHeading']",
206 Body: "//div[@id='bodyContent']",
207 }
208 helper.AddTestRule("127.0.0.1", testRule)
209
210 helper.db.Exec("DROP TABLE articles")
211
212 err := helper.Add(ctx, server.URL+"/test")
213 shared.AssertErrorContains(t, err, "failed to save article to database", "Add should fail when database save fails")
214 })
215 })
216
217 t.Run("List", func(t *testing.T) {
218 t.Run("lists all articles", func(t *testing.T) {
219 helper := NewArticleTestHelper(t)
220 ctx := context.Background()
221
222 id1 := helper.CreateTestArticle(t, "https://example.com/article1", "First Article", "John Doe", "2024-01-01")
223 id2 := helper.CreateTestArticle(t, "https://example.com/article2", "Second Article", "Jane Smith", "2024-01-02")
224
225 err := helper.List(ctx, "", "", 0)
226 shared.AssertNoError(t, err, "List should succeed")
227
228 AssertExists(t, helper.repos.Articles.Get, id1, "article")
229 AssertExists(t, helper.repos.Articles.Get, id2, "article")
230 })
231
232 t.Run("lists with title filter", func(t *testing.T) {
233 helper := NewArticleTestHelper(t)
234 ctx := context.Background()
235
236 helper.CreateTestArticle(t, "https://example.com/first", "First Article", "John", "2024-01-01")
237 helper.CreateTestArticle(t, "https://example.com/second", "Second Article", "Jane", "2024-01-02")
238
239 err := helper.List(ctx, "First", "", 0)
240 shared.AssertNoError(t, err, "List with title filter should succeed")
241 })
242
243 t.Run("lists with author filter", func(t *testing.T) {
244 helper := NewArticleTestHelper(t)
245 ctx := context.Background()
246
247 helper.CreateTestArticle(t, "https://example.com/john1", "Article by John", "John Doe", "2024-01-01")
248 helper.CreateTestArticle(t, "https://example.com/jane1", "Article by Jane", "Jane Smith", "2024-01-02")
249
250 err := helper.List(ctx, "", "John", 0)
251 shared.AssertNoError(t, err, "List with author filter should succeed")
252 })
253
254 t.Run("lists with limit", func(t *testing.T) {
255 helper := NewArticleTestHelper(t)
256 ctx := context.Background()
257
258 helper.CreateTestArticle(t, "https://example.com/1", "Article 1", "Author", "2024-01-01")
259 helper.CreateTestArticle(t, "https://example.com/2", "Article 2", "Author", "2024-01-02")
260 helper.CreateTestArticle(t, "https://example.com/3", "Article 3", "Author", "2024-01-03")
261
262 err := helper.List(ctx, "", "", 2)
263 shared.AssertNoError(t, err, "List with limit should succeed")
264 })
265
266 t.Run("handles empty results", func(t *testing.T) {
267 helper := NewArticleTestHelper(t)
268 ctx := context.Background()
269
270 err := helper.List(ctx, "nonexistent", "", 0)
271 shared.AssertNoError(t, err, "List with no matches should succeed")
272 })
273
274 t.Run("handles database error", func(t *testing.T) {
275 helper := NewArticleTestHelper(t)
276 ctx := context.Background()
277
278 helper.db.Exec("DROP TABLE articles")
279
280 err := helper.List(ctx, "", "", 0)
281 shared.AssertErrorContains(t, err, "failed to list articles", "List should fail when database is corrupted")
282 })
283 })
284
285 t.Run("View", func(t *testing.T) {
286 t.Run("views article successfully", func(t *testing.T) {
287 helper := NewArticleTestHelper(t)
288 ctx := context.Background()
289
290 id := helper.CreateTestArticle(t, "https://example.com/test", "Test Article", "Test Author", "2024-01-01")
291
292 err := helper.View(ctx, id)
293 shared.AssertNoError(t, err, "View should succeed with valid article ID")
294 })
295
296 t.Run("handles non-existent article", func(t *testing.T) {
297 helper := NewArticleTestHelper(t)
298 ctx := context.Background()
299
300 err := helper.View(ctx, 99999)
301 shared.AssertErrorContains(t, err, "failed to get article", "View should fail with non-existent article ID")
302 })
303
304 t.Run("handles missing files gracefully", func(t *testing.T) {
305 helper := NewArticleTestHelper(t)
306 ctx := context.Background()
307
308 article := &models.Article{
309 URL: "https://example.com/missing-files",
310 Title: "Missing Files Article",
311 Author: "Test Author",
312 Date: "2024-01-01",
313 MarkdownPath: "/non/existent/path.md",
314 HTMLPath: "/non/existent/path.html",
315 Created: time.Now(),
316 Modified: time.Now(),
317 }
318
319 id, err := helper.repos.Articles.Create(ctx, article)
320 if err != nil {
321 t.Fatalf("Failed to create article with missing files: %v", err)
322 }
323
324 err = helper.View(ctx, id)
325 shared.AssertNoError(t, err, "View should succeed even when files are missing")
326 })
327
328 t.Run("handles database error", func(t *testing.T) {
329 helper := NewArticleTestHelper(t)
330 ctx := context.Background()
331
332 helper.db.Exec("DROP TABLE articles")
333
334 err := helper.View(ctx, 1)
335 shared.AssertErrorContains(t, err, "failed to get article", "View should fail when database is corrupted")
336 })
337 })
338
339 t.Run("Read", func(t *testing.T) {
340 t.Run("read renders article successfully", func(t *testing.T) {
341 helper := NewArticleTestHelper(t)
342 ctx := context.Background()
343 id := helper.CreateTestArticle(t, "https://example.com/read", "Read Test Article", "Test Author", "2024-01-01")
344 err := helper.Read(ctx, id)
345 shared.AssertNoError(t, err, "Read should succeed with valid article ID")
346 })
347
348 t.Run("handles non-existent article", func(t *testing.T) {
349 helper := NewArticleTestHelper(t)
350 ctx := context.Background()
351 err := helper.Read(ctx, 99999)
352 shared.AssertErrorContains(t, err, "failed to get article", "Read should fail with non-existent article ID")
353 })
354
355 t.Run("handles missing markdown file", func(t *testing.T) {
356 helper := NewArticleTestHelper(t)
357 ctx := context.Background()
358
359 article := &models.Article{
360 URL: "https://example.com/missing-md",
361 Title: "Missing Markdown Article",
362 Author: "Test Author",
363 Date: "2024-01-01",
364 MarkdownPath: "/non/existent/path.md",
365 HTMLPath: "/some/existent/path.html",
366 Created: time.Now(),
367 Modified: time.Now(),
368 }
369
370 id, err := helper.repos.Articles.Create(ctx, article)
371 if err != nil {
372 t.Fatalf("Failed to create article with missing markdown file: %v", err)
373 }
374
375 err = helper.Read(ctx, id)
376 shared.AssertErrorContains(t, err, "markdown file not found", "Read should fail when markdown file is missing")
377 })
378
379 t.Run("handles database error", func(t *testing.T) {
380 helper := NewArticleTestHelper(t)
381 ctx := context.Background()
382 helper.db.Exec("DROP TABLE articles")
383 err := helper.Read(ctx, 1)
384 shared.AssertErrorContains(t, err, "failed to get article", "Read should fail when database is corrupted")
385 })
386 })
387
388 t.Run("Remove", func(t *testing.T) {
389 t.Run("removes article successfully", func(t *testing.T) {
390 helper := NewArticleTestHelper(t)
391 ctx := context.Background()
392 id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01")
393 AssertExists(t, helper.repos.Articles.Get, id, "article")
394
395 err := helper.Remove(ctx, id)
396 shared.AssertNoError(t, err, "Remove should succeed")
397 AssertNotExists(t, helper.repos.Articles.Get, id, "article")
398 })
399
400 t.Run("handles non-existent article", func(t *testing.T) {
401 helper := NewArticleTestHelper(t)
402 ctx := context.Background()
403 err := helper.Remove(ctx, 99999)
404 shared.AssertErrorContains(t, err, "failed to get article", "Remove should fail with non-existent article ID")
405 })
406
407 t.Run("handles missing files gracefully", func(t *testing.T) {
408 helper := NewArticleTestHelper(t)
409 ctx := context.Background()
410
411 article := &models.Article{
412 URL: "https://example.com/missing-files",
413 Title: "Missing Files Article",
414 Author: "Test Author",
415 Date: "2024-01-01",
416 MarkdownPath: "/non/existent/path.md",
417 HTMLPath: "/non/existent/path.html",
418 Created: time.Now(),
419 Modified: time.Now(),
420 }
421
422 id, err := helper.repos.Articles.Create(ctx, article)
423 if err != nil {
424 t.Fatalf("Failed to create article with missing files: %v", err)
425 }
426
427 err = helper.Remove(ctx, id)
428 shared.AssertNoError(t, err, "Remove should succeed even when files don't exist")
429 })
430
431 t.Run("handles database error", func(t *testing.T) {
432 helper := NewArticleTestHelper(t)
433 ctx := context.Background()
434 id := helper.CreateTestArticle(t, "https://example.com/db-error", "DB Error Test", "Author", "2024-01-01")
435
436 helper.db.Exec("DROP TABLE articles")
437
438 err := helper.Remove(ctx, id)
439 shared.AssertErrorContains(t, err, "failed to get article", "Remove should fail when database is corrupted")
440 })
441 })
442
443 t.Run("Help", func(t *testing.T) {
444 t.Run("shows supported domains", func(t *testing.T) {
445 helper := NewArticleTestHelper(t)
446 err := helper.Help()
447 shared.AssertNoError(t, err, "Help should succeed")
448 })
449
450 t.Run("handles storage directory error", func(t *testing.T) {
451 helper := NewArticleTestHelper(t)
452
453 envHelper := NewEnvironmentTestHelper()
454 defer envHelper.RestoreEnv()
455
456 // Unset all environment variables that could provide a storage directory
457 envHelper.UnsetEnv("NOTELEAF_DATA_DIR")
458 envHelper.UnsetEnv("NOTELEAF_CONFIG")
459
460 if runtime.GOOS == "windows" {
461 envHelper.UnsetEnv("USERPROFILE")
462 envHelper.UnsetEnv("HOMEDRIVE")
463 envHelper.UnsetEnv("HOMEPATH")
464 envHelper.UnsetEnv("LOCALAPPDATA")
465 } else {
466 envHelper.UnsetEnv("HOME")
467 envHelper.UnsetEnv("XDG_DATA_HOME")
468 }
469
470 err := helper.Help()
471 shared.AssertErrorContains(t, err, "failed to get storage directory", "Help should fail when storage directory cannot be determined")
472 })
473 })
474
475 t.Run("Close", func(t *testing.T) {
476 t.Run("closes successfully", func(t *testing.T) {
477 helper := NewArticleTestHelper(t)
478 err := helper.Close()
479 shared.AssertNoError(t, err, "Close should succeed")
480 })
481
482 t.Run("handles nil database gracefully", func(t *testing.T) {
483 helper := NewArticleTestHelper(t)
484 helper.db = nil
485 err := helper.Close()
486 shared.AssertNoError(t, err, "Close should succeed with nil database")
487 })
488 })
489
490 t.Run("getStorageDirectory", func(t *testing.T) {
491 t.Run("returns storage directory successfully", func(t *testing.T) {
492 helper := NewArticleTestHelper(t)
493 dir, err := helper.getStorageDirectory()
494 shared.AssertNoError(t, err, "getStorageDirectory should succeed")
495
496 if dir == "" {
497 t.Error("Storage directory should not be empty")
498 }
499
500 if !strings.Contains(dir, "articles") {
501 t.Errorf("Expected storage directory to contain 'articles', got: %s", dir)
502 }
503 })
504
505 t.Run("handles user home directory error", func(t *testing.T) {
506 helper := NewArticleTestHelper(t)
507
508 envHelper := NewEnvironmentTestHelper()
509 defer envHelper.RestoreEnv()
510
511 // Unset NOTELEAF_DATA_DIR to force GetDataDir to use OS-specific variables
512 envHelper.UnsetEnv("NOTELEAF_DATA_DIR")
513
514 switch runtime.GOOS {
515 case "windows":
516 envHelper.UnsetEnv("LOCALAPPDATA")
517 envHelper.UnsetEnv("APPDATA")
518 case "darwin":
519 envHelper.UnsetEnv("HOME")
520 default:
521 envHelper.UnsetEnv("XDG_DATA_HOME")
522 envHelper.UnsetEnv("HOME")
523 }
524
525 _, err := helper.getStorageDirectory()
526 shared.AssertErrorContains(t, err, "", "getStorageDirectory should fail when home directory cannot be determined")
527 })
528 })
529}
530
531func TestArticleHandlerIntegration(t *testing.T) {
532 t.Run("end-to-end workflow", func(t *testing.T) {
533 helper := NewArticleTestHelper(t)
534 ctx := context.Background()
535
536 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
537 w.WriteHeader(http.StatusOK)
538 w.Write([]byte(`<html>
539 <head><title>Integration Test Article</title></head>
540 <body>
541 <h1 id="firstHeading">Integration Test Article</h1>
542 <div class="author">Integration Author</div>
543 <div id="bodyContent">
544 <p>Integration test content.</p>
545 </div>
546 </body>
547 </html>`))
548 }))
549 defer server.Close()
550
551 testRule := &articles.ParsingRule{
552 Domain: "127.0.0.1",
553 Title: "//h1[@id='firstHeading']",
554 Author: "//div[@class='author']",
555 Body: "//div[@id='bodyContent']",
556 }
557 helper.AddTestRule("127.0.0.1", testRule)
558
559 err := helper.Add(ctx, server.URL+"/integration-test")
560 shared.AssertNoError(t, err, "Add should succeed in integration test")
561
562 err = helper.List(ctx, "", "", 0)
563 shared.AssertNoError(t, err, "List should succeed in integration test")
564
565 articles, err := helper.repos.Articles.List(ctx, &repo.ArticleListOptions{})
566 if err != nil {
567 t.Fatalf("Failed to get articles for integration test: %v", err)
568 }
569
570 if len(articles) == 0 {
571 t.Fatal("Expected at least one article for integration test")
572 }
573
574 articleID := articles[0].ID
575
576 err = helper.View(ctx, articleID)
577 shared.AssertNoError(t, err, "View should succeed in integration test")
578
579 err = helper.Help()
580 shared.AssertNoError(t, err, "Help should succeed in integration test")
581
582 err = helper.Remove(ctx, articleID)
583 shared.AssertNoError(t, err, "Remove should succeed in integration test")
584
585 AssertNotExists(t, helper.repos.Articles.Get, articleID, "article")
586 })
587}