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