cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 593 lines 17 kB view raw
1package ui 2 3import ( 4 "bytes" 5 "context" 6 "fmt" 7 "strings" 8 "testing" 9 "time" 10 11 "github.com/stormlightlabs/noteleaf/internal/models" 12 "github.com/stormlightlabs/noteleaf/internal/repo" 13) 14 15type mockPublicationRepository struct { 16 notes []*models.Note 17 err error 18 published []*models.Note 19 drafts []*models.Note 20 leafletAll []*models.Note 21} 22 23func (m *mockPublicationRepository) ListPublished(ctx context.Context) ([]*models.Note, error) { 24 if m.err != nil { 25 return nil, m.err 26 } 27 if m.published != nil { 28 return m.published, nil 29 } 30 var published []*models.Note 31 for _, note := range m.notes { 32 if !note.IsDraft { 33 published = append(published, note) 34 } 35 } 36 return published, nil 37} 38 39func (m *mockPublicationRepository) ListDrafts(ctx context.Context) ([]*models.Note, error) { 40 if m.err != nil { 41 return nil, m.err 42 } 43 if m.drafts != nil { 44 return m.drafts, nil 45 } 46 var drafts []*models.Note 47 for _, note := range m.notes { 48 if note.IsDraft { 49 drafts = append(drafts, note) 50 } 51 } 52 return drafts, nil 53} 54 55func (m *mockPublicationRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, error) { 56 if m.err != nil { 57 return nil, m.err 58 } 59 if m.leafletAll != nil { 60 return m.leafletAll, nil 61 } 62 return m.notes, nil 63} 64 65func (m *mockPublicationRepository) List(ctx context.Context, options repo.NoteListOptions) ([]*models.Note, error) { 66 if m.err != nil { 67 return nil, m.err 68 } 69 return m.notes, nil 70} 71 72func TestPublicationAdapter(t *testing.T) { 73 t.Run("PublicationRecord", func(t *testing.T) { 74 rkey := "test-rkey-123" 75 cid := "test-cid-456" 76 publishedAt := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 77 78 note := &models.Note{ 79 ID: 1, 80 Title: "Test Publication", 81 Content: "Publication content", 82 Tags: []string{"article", "tech"}, 83 IsDraft: false, 84 PublishedAt: &publishedAt, 85 Modified: time.Date(2024, 1, 16, 12, 0, 0, 0, time.UTC), 86 LeafletRKey: &rkey, 87 LeafletCID: &cid, 88 } 89 record := &PublicationRecord{Note: note} 90 91 t.Run("GetField returns all publication fields", func(t *testing.T) { 92 tests := []struct { 93 field string 94 expected any 95 name string 96 }{ 97 {"id", int64(1), "id field"}, 98 {"title", "Test Publication", "title field"}, 99 {"status", "published", "status for published note"}, 100 {"published_at", &publishedAt, "published_at field"}, 101 {"modified", note.Modified, "modified field"}, 102 {"leaflet_rkey", &rkey, "leaflet_rkey field"}, 103 {"leaflet_cid", &cid, "leaflet_cid field"}, 104 {"unknown", "", "unknown field returns empty string"}, 105 } 106 107 for _, tt := range tests { 108 t.Run(tt.name, func(t *testing.T) { 109 result := record.GetField(tt.field) 110 if fmt.Sprintf("%v", result) != fmt.Sprintf("%v", tt.expected) { 111 t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected) 112 } 113 }) 114 } 115 }) 116 117 t.Run("GetField returns draft status", func(t *testing.T) { 118 draftNote := &models.Note{ 119 ID: 2, 120 Title: "Draft Note", 121 IsDraft: true, 122 } 123 draftRecord := &PublicationRecord{Note: draftNote} 124 125 status := draftRecord.GetField("status") 126 if status != "draft" { 127 t.Errorf("GetField(status) for draft = %v, want 'draft'", status) 128 } 129 }) 130 131 t.Run("GetTitle formats with ID and status", func(t *testing.T) { 132 title := record.GetTitle() 133 if !strings.Contains(title, "[1]") { 134 t.Errorf("GetTitle() should contain ID [1], got: %s", title) 135 } 136 if !strings.Contains(title, "Test Publication") { 137 t.Errorf("GetTitle() should contain title, got: %s", title) 138 } 139 if !strings.Contains(title, "(published)") { 140 t.Errorf("GetTitle() should contain status (published), got: %s", title) 141 } 142 }) 143 144 t.Run("GetTitle shows draft status", func(t *testing.T) { 145 draftNote := &models.Note{ 146 ID: 3, 147 Title: "Draft Article", 148 IsDraft: true, 149 } 150 draftRecord := &PublicationRecord{Note: draftNote} 151 152 title := draftRecord.GetTitle() 153 if !strings.Contains(title, "(draft)") { 154 t.Errorf("GetTitle() for draft should contain (draft), got: %s", title) 155 } 156 }) 157 158 t.Run("GetDescription includes all metadata", func(t *testing.T) { 159 description := record.GetDescription() 160 161 if !strings.Contains(description, "Published: 2024-01-15 10:00") { 162 t.Errorf("GetDescription() should contain published date, got: %s", description) 163 } 164 if !strings.Contains(description, "Modified: 2024-01-16 12:00") { 165 t.Errorf("GetDescription() should contain modified date, got: %s", description) 166 } 167 if !strings.Contains(description, "rkey: test-rkey-123") { 168 t.Errorf("GetDescription() should contain rkey, got: %s", description) 169 } 170 }) 171 172 t.Run("GetDescription handles missing fields", func(t *testing.T) { 173 minimalNote := &models.Note{ 174 ID: 4, 175 Title: "Minimal Note", 176 Modified: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 177 } 178 minimalRecord := &PublicationRecord{Note: minimalNote} 179 180 description := minimalRecord.GetDescription() 181 182 if strings.Contains(description, "Published:") { 183 t.Errorf("GetDescription() should not contain Published for unpublished note, got: %s", description) 184 } 185 if strings.Contains(description, "rkey:") { 186 t.Errorf("GetDescription() should not contain rkey when not set, got: %s", description) 187 } 188 if !strings.Contains(description, "Modified: 2024-01-01 00:00") { 189 t.Errorf("GetDescription() should always contain Modified, got: %s", description) 190 } 191 }) 192 193 t.Run("GetFilterValue includes searchable text", func(t *testing.T) { 194 filterValue := record.GetFilterValue() 195 196 if !strings.Contains(filterValue, "Test Publication") { 197 t.Errorf("GetFilterValue() should contain title, got: %s", filterValue) 198 } 199 if !strings.Contains(filterValue, "Publication content") { 200 t.Errorf("GetFilterValue() should contain content, got: %s", filterValue) 201 } 202 if !strings.Contains(filterValue, "test-rkey-123") { 203 t.Errorf("GetFilterValue() should contain rkey, got: %s", filterValue) 204 } 205 }) 206 207 t.Run("GetFilterValue handles missing rkey", func(t *testing.T) { 208 noteWithoutRKey := &models.Note{ 209 ID: 5, 210 Title: "No RKey Note", 211 Content: "Some content", 212 } 213 recordWithoutRKey := &PublicationRecord{Note: noteWithoutRKey} 214 215 filterValue := recordWithoutRKey.GetFilterValue() 216 217 if !strings.Contains(filterValue, "No RKey Note") { 218 t.Errorf("GetFilterValue() should contain title, got: %s", filterValue) 219 } 220 if !strings.Contains(filterValue, "Some content") { 221 t.Errorf("GetFilterValue() should contain content, got: %s", filterValue) 222 } 223 }) 224 }) 225 226 t.Run("PublicationDataSource", func(t *testing.T) { 227 rkey1 := "rkey-published" 228 rkey2 := "rkey-draft" 229 publishedAt := time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC) 230 231 notes := []*models.Note{ 232 { 233 ID: 1, 234 Title: "Published Article", 235 Content: "Published content", 236 IsDraft: false, 237 PublishedAt: &publishedAt, 238 LeafletRKey: &rkey1, 239 Modified: time.Now(), 240 }, 241 { 242 ID: 2, 243 Title: "Draft Article", 244 Content: "Draft content", 245 IsDraft: true, 246 LeafletRKey: &rkey2, 247 Modified: time.Now(), 248 }, 249 { 250 ID: 3, 251 Title: "Another Published", 252 Content: "More published content", 253 IsDraft: false, 254 Modified: time.Now(), 255 }, 256 } 257 258 t.Run("Load with all filter", func(t *testing.T) { 259 mockRepo := &mockPublicationRepository{notes: notes} 260 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 261 262 items, err := source.Load(context.Background(), ListOptions{}) 263 if err != nil { 264 t.Fatalf("Load() failed: %v", err) 265 } 266 267 if len(items) != 3 { 268 t.Errorf("Load() with filter 'all' returned %d items, want 3", len(items)) 269 } 270 }) 271 272 t.Run("Load with published filter", func(t *testing.T) { 273 mockRepo := &mockPublicationRepository{notes: notes} 274 source := &PublicationDataSource{repo: mockRepo, filter: "published"} 275 276 items, err := source.Load(context.Background(), ListOptions{}) 277 if err != nil { 278 t.Fatalf("Load() failed: %v", err) 279 } 280 281 if len(items) != 2 { 282 t.Errorf("Load() with filter 'published' returned %d items, want 2", len(items)) 283 } 284 285 for _, item := range items { 286 pubRecord := item.(*PublicationRecord) 287 if pubRecord.IsDraft { 288 t.Error("Load() with 'published' filter should not return drafts") 289 } 290 } 291 }) 292 293 t.Run("Load with draft filter", func(t *testing.T) { 294 mockRepo := &mockPublicationRepository{notes: notes} 295 source := &PublicationDataSource{repo: mockRepo, filter: "draft"} 296 297 items, err := source.Load(context.Background(), ListOptions{}) 298 if err != nil { 299 t.Fatalf("Load() failed: %v", err) 300 } 301 302 if len(items) != 1 { 303 t.Errorf("Load() with filter 'draft' returned %d items, want 1", len(items)) 304 } 305 306 pubRecord := items[0].(*PublicationRecord) 307 if !pubRecord.IsDraft { 308 t.Error("Load() with 'draft' filter should only return drafts") 309 } 310 }) 311 312 t.Run("Load with search query", func(t *testing.T) { 313 mockRepo := &mockPublicationRepository{notes: notes} 314 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 315 316 items, err := source.Load(context.Background(), ListOptions{Search: "Draft"}) 317 if err != nil { 318 t.Fatalf("Load() with search failed: %v", err) 319 } 320 321 if len(items) != 1 { 322 t.Errorf("Load() with search 'Draft' returned %d items, want 1", len(items)) 323 } 324 325 if items[0].GetTitle() != "[2] Draft Article (draft)" { 326 t.Errorf("Search result title = %q, want '[2] Draft Article (draft)'", items[0].GetTitle()) 327 } 328 }) 329 330 t.Run("Load with search in content", func(t *testing.T) { 331 mockRepo := &mockPublicationRepository{notes: notes} 332 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 333 334 items, err := source.Load(context.Background(), ListOptions{Search: "Draft content"}) 335 if err != nil { 336 t.Fatalf("Load() with content search failed: %v", err) 337 } 338 339 if len(items) != 1 { 340 t.Errorf("Load() searching content returned %d items, want 1", len(items)) 341 } 342 }) 343 344 t.Run("Load with search in rkey", func(t *testing.T) { 345 mockRepo := &mockPublicationRepository{notes: notes} 346 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 347 348 items, err := source.Load(context.Background(), ListOptions{Search: "rkey-draft"}) 349 if err != nil { 350 t.Fatalf("Load() with rkey search failed: %v", err) 351 } 352 353 if len(items) != 1 { 354 t.Errorf("Load() searching rkey returned %d items, want 1", len(items)) 355 } 356 357 pubRecord := items[0].(*PublicationRecord) 358 if *pubRecord.LeafletRKey != "rkey-draft" { 359 t.Errorf("Found note with rkey %q, want 'rkey-draft'", *pubRecord.LeafletRKey) 360 } 361 }) 362 363 t.Run("Load with limit", func(t *testing.T) { 364 mockRepo := &mockPublicationRepository{notes: notes} 365 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 366 367 items, err := source.Load(context.Background(), ListOptions{Limit: 2}) 368 if err != nil { 369 t.Fatalf("Load() with limit failed: %v", err) 370 } 371 372 if len(items) != 2 { 373 t.Errorf("Load() with limit 2 returned %d items, want 2", len(items)) 374 } 375 }) 376 377 t.Run("Load error handling", func(t *testing.T) { 378 testErr := fmt.Errorf("database error") 379 mockRepo := &mockPublicationRepository{err: testErr} 380 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 381 382 _, err := source.Load(context.Background(), ListOptions{}) 383 if err != testErr { 384 t.Errorf("Load() error = %v, want %v", err, testErr) 385 } 386 }) 387 388 t.Run("Count", func(t *testing.T) { 389 mockRepo := &mockPublicationRepository{notes: notes} 390 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 391 392 count, err := source.Count(context.Background(), ListOptions{}) 393 if err != nil { 394 t.Fatalf("Count() failed: %v", err) 395 } 396 397 if count != 3 { 398 t.Errorf("Count() = %d, want 3", count) 399 } 400 }) 401 402 t.Run("Count with filter", func(t *testing.T) { 403 mockRepo := &mockPublicationRepository{notes: notes} 404 source := &PublicationDataSource{repo: mockRepo, filter: "draft"} 405 406 count, err := source.Count(context.Background(), ListOptions{}) 407 if err != nil { 408 t.Fatalf("Count() with filter failed: %v", err) 409 } 410 411 if count != 1 { 412 t.Errorf("Count() with draft filter = %d, want 1", count) 413 } 414 }) 415 416 t.Run("Search", func(t *testing.T) { 417 mockRepo := &mockPublicationRepository{notes: notes} 418 source := &PublicationDataSource{repo: mockRepo, filter: "all"} 419 420 items, err := source.Search(context.Background(), "Published", ListOptions{}) 421 if err != nil { 422 t.Fatalf("Search() failed: %v", err) 423 } 424 425 if len(items) != 2 { 426 t.Errorf("Search() for 'Published' returned %d items, want 2", len(items)) 427 } 428 }) 429 }) 430 431 t.Run("NewPublicationDataList", func(t *testing.T) { 432 notes := []*models.Note{ 433 { 434 ID: 1, 435 Title: "Test Publication", 436 Content: "Test content", 437 IsDraft: false, 438 Modified: time.Now(), 439 }, 440 } 441 442 mockRepo := &mockPublicationRepository{notes: notes} 443 444 opts := DataListOptions{ 445 Output: &bytes.Buffer{}, 446 Input: strings.NewReader("q\n"), 447 Static: true, 448 } 449 450 list := NewPublicationDataList(mockRepo, opts, "all") 451 if list == nil { 452 t.Fatal("NewPublicationDataList() returned nil") 453 } 454 455 err := list.Browse(context.Background()) 456 if err != nil { 457 t.Errorf("Browse() failed: %v", err) 458 } 459 }) 460 461 t.Run("NewPublicationListFromList", func(t *testing.T) { 462 notes := []*models.Note{ 463 { 464 ID: 1, 465 Title: "Test Publication", 466 Content: "Test content", 467 IsDraft: false, 468 Modified: time.Now(), 469 }, 470 } 471 472 mockRepo := &mockPublicationRepository{notes: notes} 473 474 output := &bytes.Buffer{} 475 input := strings.NewReader("q\n") 476 477 list := NewPublicationListFromList(mockRepo, output, input, true, "all") 478 if list == nil { 479 t.Fatal("NewPublicationListFromList() returned nil") 480 } 481 482 err := list.Browse(context.Background()) 483 if err != nil { 484 t.Errorf("Browse() failed: %v", err) 485 } 486 487 outputStr := output.String() 488 if !strings.Contains(outputStr, "Publications") { 489 t.Error("Output should contain 'Publications' title") 490 } 491 if !strings.Contains(outputStr, "Test Publication") { 492 t.Error("Output should contain publication title") 493 } 494 }) 495 496 t.Run("buildPublicationMarkdown", func(t *testing.T) { 497 t.Run("formats published note with all metadata", func(t *testing.T) { 498 rkey := "test-rkey" 499 cid := "test-cid" 500 publishedAt := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 501 502 note := &models.Note{ 503 ID: 1, 504 Title: "Test Article", 505 Content: "# Test Article\n\nThis is the article content.", 506 IsDraft: false, 507 PublishedAt: &publishedAt, 508 Modified: time.Date(2024, 1, 16, 12, 0, 0, 0, time.UTC), 509 LeafletRKey: &rkey, 510 LeafletCID: &cid, 511 } 512 513 result := buildPublicationMarkdown(note) 514 515 if !strings.Contains(result, "Test Article") { 516 t.Error("Markdown should contain title") 517 } 518 if !strings.Contains(result, "published") { 519 t.Error("Markdown should contain status 'published'") 520 } 521 if !strings.Contains(result, "2024-01-15") { 522 t.Error("Markdown should contain published date") 523 } 524 if !strings.Contains(result, "2024-01-16") { 525 t.Error("Markdown should contain modified date") 526 } 527 }) 528 529 t.Run("formats draft note", func(t *testing.T) { 530 note := &models.Note{ 531 ID: 2, 532 Title: "Draft Article", 533 Content: "Draft content here.", 534 IsDraft: true, 535 Modified: time.Date(2024, 1, 20, 14, 0, 0, 0, time.UTC), 536 } 537 538 result := buildPublicationMarkdown(note) 539 540 if !strings.Contains(result, "Draft Article") { 541 t.Error("Markdown should contain title") 542 } 543 if !strings.Contains(result, "draft") { 544 t.Error("Markdown should contain status 'draft'") 545 } 546 if strings.Contains(result, "Published:") { 547 t.Error("Draft markdown should not contain published date") 548 } 549 if !strings.Contains(result, "2024-01-20 14:00") { 550 t.Error("Markdown should contain modified date") 551 } 552 }) 553 554 t.Run("handles content without title header", func(t *testing.T) { 555 note := &models.Note{ 556 ID: 3, 557 Title: "Plain Content", 558 Content: "This content has no markdown header.", 559 IsDraft: false, 560 Modified: time.Now(), 561 } 562 563 result := buildPublicationMarkdown(note) 564 565 if !strings.Contains(result, "Plain Content") { 566 t.Error("Markdown should contain title") 567 } 568 if !strings.Contains(result, "This content has no markdown header") { 569 t.Error("Markdown should contain full content") 570 } 571 }) 572 573 t.Run("strips duplicate title from content", func(t *testing.T) { 574 note := &models.Note{ 575 ID: 4, 576 Title: "Article Title", 577 Content: "# Article Title\n\nContent after title.", 578 IsDraft: false, 579 Modified: time.Now(), 580 } 581 582 result := buildPublicationMarkdown(note) 583 584 titleCount := strings.Count(result, "Article Title") 585 if titleCount < 1 { 586 t.Error("Markdown should contain title at least once") 587 } 588 if !strings.Contains(result, "Content after title") { 589 t.Error("Markdown should contain content after title") 590 } 591 }) 592 }) 593}