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