cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

build: tests for publication handler

+357 -16
+1
internal/handlers/articles.go
··· 1 + // TODO: more article sanitizing 1 2 package handlers 2 3 3 4 import (
+6 -16
internal/handlers/publication.go
··· 1 1 // Package handlers provides command handlers for leaflet publication operations. 2 2 // 3 - // Pull command: 4 - // 1. Authenticates with AT Protocol 5 - // 2. Fetches all pub.leaflet.document records 6 - // 3. Creates new notes for documents not seen before 7 - // 4. Updates existing notes (matched by leaflet_rkey) 8 - // 5. Shows summary of pulled documents 9 - // 10 - // List command: 11 - // 1. Query notes where leaflet_rkey IS NOT NULL 12 - // 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all) 13 - // 3. Static output (TUI viewing marked as TODO) 14 - // 3 + // TODO: Post (create 1) 4 + // TODO: Push (create or update - more than 1) 15 5 // TODO: Add TUI viewing for document details 16 6 package handlers 17 7 ··· 160 150 if err == nil && existing != nil { 161 151 content, err := documentToMarkdown(doc) 162 152 if err != nil { 163 - ui.Warningln("โš  Skipping document %s: %v", doc.Document.Title, err) 153 + ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) 164 154 continue 165 155 } 166 156 ··· 177 167 } 178 168 179 169 if err := h.repos.Notes.Update(ctx, existing); err != nil { 180 - ui.Warningln("โš  Failed to update note for document %s: %v", doc.Document.Title, err) 170 + ui.Warningln("Failed to update note for document %s: %v", doc.Document.Title, err) 181 171 continue 182 172 } 183 173 ··· 186 176 } else { 187 177 content, err := documentToMarkdown(doc) 188 178 if err != nil { 189 - ui.Warningln("โš  Skipping document %s: %v", doc.Document.Title, err) 179 + ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) 190 180 continue 191 181 } 192 182 ··· 207 197 208 198 _, err = h.repos.Notes.Create(ctx, note) 209 199 if err != nil { 210 - ui.Warningln("โš  Failed to create note for document %s: %v", doc.Document.Title, err) 200 + ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err) 211 201 continue 212 202 } 213 203
+339
internal/handlers/publication_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "strings" 5 6 "testing" 6 7 "time" 7 8 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + "github.com/stormlightlabs/noteleaf/internal/public" 8 11 "github.com/stormlightlabs/noteleaf/internal/services" 9 12 "github.com/stormlightlabs/noteleaf/internal/store" 10 13 ) ··· 280 283 if err != nil { 281 284 t.Errorf("Expected no error on close, got %v", err) 282 285 } 286 + }) 287 + }) 288 + 289 + t.Run("documentToMarkdown", func(t *testing.T) { 290 + t.Run("converts simple document with text blocks", func(t *testing.T) { 291 + doc := services.DocumentWithMeta{ 292 + Document: public.Document{ 293 + Pages: []public.LinearDocument{ 294 + { 295 + Blocks: []public.BlockWrap{ 296 + { 297 + Type: "pub.leaflet.pages.linearDocument#block", 298 + Block: public.TextBlock{ 299 + Type: "pub.leaflet.pages.linearDocument#textBlock", 300 + Plaintext: "Hello world", 301 + }, 302 + }, 303 + }, 304 + }, 305 + }, 306 + }, 307 + } 308 + 309 + markdown, err := documentToMarkdown(doc) 310 + if err != nil { 311 + t.Fatalf("Expected no error, got %v", err) 312 + } 313 + 314 + if markdown != "Hello world" { 315 + t.Errorf("Expected 'Hello world', got '%s'", markdown) 316 + } 317 + }) 318 + 319 + t.Run("converts document with headers", func(t *testing.T) { 320 + doc := services.DocumentWithMeta{ 321 + Document: public.Document{ 322 + Pages: []public.LinearDocument{ 323 + { 324 + Blocks: []public.BlockWrap{ 325 + { 326 + Type: "pub.leaflet.pages.linearDocument#block", 327 + Block: public.HeaderBlock{ 328 + Type: "pub.leaflet.pages.linearDocument#headerBlock", 329 + Level: 1, 330 + Plaintext: "Main Title", 331 + }, 332 + }, 333 + { 334 + Type: "pub.leaflet.pages.linearDocument#block", 335 + Block: public.TextBlock{ 336 + Type: "pub.leaflet.pages.linearDocument#textBlock", 337 + Plaintext: "Content here", 338 + }, 339 + }, 340 + }, 341 + }, 342 + }, 343 + }, 344 + } 345 + 346 + markdown, err := documentToMarkdown(doc) 347 + if err != nil { 348 + t.Fatalf("Expected no error, got %v", err) 349 + } 350 + 351 + expected := "# Main Title\n\nContent here" 352 + if markdown != expected { 353 + t.Errorf("Expected '%s', got '%s'", expected, markdown) 354 + } 355 + }) 356 + 357 + t.Run("converts document with code blocks", func(t *testing.T) { 358 + doc := services.DocumentWithMeta{ 359 + Document: public.Document{ 360 + Pages: []public.LinearDocument{ 361 + { 362 + Blocks: []public.BlockWrap{ 363 + { 364 + Type: "pub.leaflet.pages.linearDocument#block", 365 + Block: public.CodeBlock{ 366 + Type: "pub.leaflet.pages.linearDocument#codeBlock", 367 + Plaintext: "fmt.Println(\"hello\")", 368 + Language: "go", 369 + }, 370 + }, 371 + }, 372 + }, 373 + }, 374 + }, 375 + } 376 + 377 + markdown, err := documentToMarkdown(doc) 378 + if err != nil { 379 + t.Fatalf("Expected no error, got %v", err) 380 + } 381 + 382 + expected := "```go\nfmt.Println(\"hello\")\n```" 383 + if markdown != expected { 384 + t.Errorf("Expected '%s', got '%s'", expected, markdown) 385 + } 386 + }) 387 + 388 + t.Run("converts document with multiple pages", func(t *testing.T) { 389 + doc := services.DocumentWithMeta{ 390 + Document: public.Document{ 391 + Pages: []public.LinearDocument{ 392 + { 393 + Blocks: []public.BlockWrap{ 394 + { 395 + Type: "pub.leaflet.pages.linearDocument#block", 396 + Block: public.TextBlock{ 397 + Type: "pub.leaflet.pages.linearDocument#textBlock", 398 + Plaintext: "Page one", 399 + }, 400 + }, 401 + }, 402 + }, 403 + { 404 + Blocks: []public.BlockWrap{ 405 + { 406 + Type: "pub.leaflet.pages.linearDocument#block", 407 + Block: public.TextBlock{ 408 + Type: "pub.leaflet.pages.linearDocument#textBlock", 409 + Plaintext: "Page two", 410 + }, 411 + }, 412 + }, 413 + }, 414 + }, 415 + }, 416 + } 417 + 418 + markdown, err := documentToMarkdown(doc) 419 + if err != nil { 420 + t.Fatalf("Expected no error, got %v", err) 421 + } 422 + 423 + expected := "Page one\n\nPage two" 424 + if markdown != expected { 425 + t.Errorf("Expected '%s', got '%s'", expected, markdown) 426 + } 427 + }) 428 + 429 + t.Run("handles empty document", func(t *testing.T) { 430 + doc := services.DocumentWithMeta{ 431 + Document: public.Document{ 432 + Pages: []public.LinearDocument{}, 433 + }, 434 + } 435 + 436 + markdown, err := documentToMarkdown(doc) 437 + if err != nil { 438 + t.Fatalf("Expected no error, got %v", err) 439 + } 440 + 441 + if markdown != "" { 442 + t.Errorf("Expected empty string, got '%s'", markdown) 443 + } 444 + }) 445 + }) 446 + 447 + t.Run("Pull", func(t *testing.T) { 448 + t.Run("returns error when not authenticated", func(t *testing.T) { 449 + suite := NewHandlerTestSuite(t) 450 + defer suite.Cleanup() 451 + 452 + handler := CreateHandler(t, NewPublicationHandler) 453 + ctx := context.Background() 454 + 455 + err := handler.Pull(ctx) 456 + if err == nil { 457 + t.Error("Expected error when not authenticated") 458 + } 459 + 460 + expectedMsg := "not authenticated" 461 + if err != nil && !strings.Contains(err.Error(), expectedMsg) { 462 + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) 463 + } 464 + }) 465 + }) 466 + 467 + t.Run("List", func(t *testing.T) { 468 + t.Run("lists all leaflet notes", func(t *testing.T) { 469 + suite := NewHandlerTestSuite(t) 470 + defer suite.Cleanup() 471 + 472 + handler := CreateHandler(t, NewPublicationHandler) 473 + ctx := context.Background() 474 + 475 + rkey1 := "test_rkey_1" 476 + cid1 := "test_cid_1" 477 + publishedAt := time.Now() 478 + 479 + note1 := &models.Note{ 480 + Title: "Published Note", 481 + Content: "Content 1", 482 + LeafletRKey: &rkey1, 483 + LeafletCID: &cid1, 484 + PublishedAt: &publishedAt, 485 + IsDraft: false, 486 + } 487 + 488 + _, err := handler.repos.Notes.Create(ctx, note1) 489 + suite.AssertNoError(err, "create published note") 490 + 491 + rkey2 := "test_rkey_2" 492 + cid2 := "test_cid_2" 493 + note2 := &models.Note{ 494 + Title: "Draft Note", 495 + Content: "Content 2", 496 + LeafletRKey: &rkey2, 497 + LeafletCID: &cid2, 498 + IsDraft: true, 499 + } 500 + 501 + _, err = handler.repos.Notes.Create(ctx, note2) 502 + suite.AssertNoError(err, "create draft note") 503 + 504 + err = handler.List(ctx, "all") 505 + suite.AssertNoError(err, "list all notes") 506 + 507 + err = handler.List(ctx, "") 508 + suite.AssertNoError(err, "list with empty filter") 509 + }) 510 + 511 + t.Run("lists only published notes", func(t *testing.T) { 512 + suite := NewHandlerTestSuite(t) 513 + defer suite.Cleanup() 514 + 515 + handler := CreateHandler(t, NewPublicationHandler) 516 + ctx := context.Background() 517 + 518 + rkey := "published_rkey" 519 + cid := "published_cid" 520 + publishedAt := time.Now() 521 + 522 + note := &models.Note{ 523 + Title: "Published Note", 524 + Content: "Content", 525 + LeafletRKey: &rkey, 526 + LeafletCID: &cid, 527 + PublishedAt: &publishedAt, 528 + IsDraft: false, 529 + } 530 + 531 + _, err := handler.repos.Notes.Create(ctx, note) 532 + suite.AssertNoError(err, "create published note") 533 + 534 + err = handler.List(ctx, "published") 535 + suite.AssertNoError(err, "list published notes") 536 + }) 537 + 538 + t.Run("lists only draft notes", func(t *testing.T) { 539 + suite := NewHandlerTestSuite(t) 540 + defer suite.Cleanup() 541 + 542 + handler := CreateHandler(t, NewPublicationHandler) 543 + ctx := context.Background() 544 + 545 + rkey := "draft_rkey" 546 + cid := "draft_cid" 547 + 548 + note := &models.Note{ 549 + Title: "Draft Note", 550 + Content: "Content", 551 + LeafletRKey: &rkey, 552 + LeafletCID: &cid, 553 + IsDraft: true, 554 + } 555 + 556 + _, err := handler.repos.Notes.Create(ctx, note) 557 + suite.AssertNoError(err, "create draft note") 558 + 559 + err = handler.List(ctx, "draft") 560 + suite.AssertNoError(err, "list draft notes") 561 + }) 562 + 563 + t.Run("handles empty results gracefully", func(t *testing.T) { 564 + suite := NewHandlerTestSuite(t) 565 + defer suite.Cleanup() 566 + 567 + handler := CreateHandler(t, NewPublicationHandler) 568 + ctx := context.Background() 569 + 570 + err := handler.List(ctx, "all") 571 + suite.AssertNoError(err, "list with no notes") 572 + }) 573 + 574 + t.Run("returns error for invalid filter", func(t *testing.T) { 575 + suite := NewHandlerTestSuite(t) 576 + defer suite.Cleanup() 577 + 578 + handler := CreateHandler(t, NewPublicationHandler) 579 + ctx := context.Background() 580 + 581 + err := handler.List(ctx, "invalid_filter") 582 + if err == nil { 583 + t.Error("Expected error for invalid filter") 584 + } 585 + 586 + expectedMsg := "invalid filter" 587 + if err != nil && !strings.Contains(err.Error(), expectedMsg) { 588 + t.Errorf("Expected error message to contain '%s', got '%s'", expectedMsg, err.Error()) 589 + } 590 + }) 591 + 592 + t.Run("only lists notes with leaflet metadata", func(t *testing.T) { 593 + suite := NewHandlerTestSuite(t) 594 + defer suite.Cleanup() 595 + 596 + handler := CreateHandler(t, NewPublicationHandler) 597 + ctx := context.Background() 598 + 599 + regularNote := &models.Note{ 600 + Title: "Regular Note", 601 + Content: "No leaflet data", 602 + } 603 + 604 + _, err := handler.repos.Notes.Create(ctx, regularNote) 605 + suite.AssertNoError(err, "create regular note") 606 + 607 + rkey := "leaflet_rkey" 608 + cid := "leaflet_cid" 609 + leafletNote := &models.Note{ 610 + Title: "Leaflet Note", 611 + Content: "Has leaflet data", 612 + LeafletRKey: &rkey, 613 + LeafletCID: &cid, 614 + IsDraft: false, 615 + } 616 + 617 + _, err = handler.repos.Notes.Create(ctx, leafletNote) 618 + suite.AssertNoError(err, "create leaflet note") 619 + 620 + err = handler.List(ctx, "all") 621 + suite.AssertNoError(err, "list all leaflet notes") 283 622 }) 284 623 }) 285 624 }
+11
internal/ui/common.go
··· 19 19 func errorMsg(msg string) string { return ErrorStyle.Render("โœ— " + msg) } 20 20 func warning(msg string) string { return WarningStyle.Render("โš  " + msg) } 21 21 func info(msg string) string { return InfoStyle.Render("โ„น " + msg) } 22 + func infop(msg string) string { return InfoStyle.Render(msg) } 22 23 func title(msg string) string { return TitleStyle.Render(msg) } 23 24 func subtitle(msg string) string { return SubtitleStyle.Render(msg) } 24 25 func box(content string) string { return BoxStyle.Render(content) } ··· 63 64 64 65 // Infoln prints a formatted info message with a newline 65 66 func Infoln(format string, a ...any) { 67 + fmt.Println(infop(fmt.Sprintf(format, a...))) 68 + } 69 + 70 + // Infop prints a formatted info message, sans icon 71 + func Infop(format string, a ...any) { 72 + fmt.Print(infop(fmt.Sprintf(format, a...))) 73 + } 74 + 75 + // Infopln prints a formatted info message with a newline, sans icon 76 + func Infopln(format string, a ...any) { 66 77 fmt.Println(info(fmt.Sprintf(format, a...))) 67 78 } 68 79