auto-update-daily-20260202
  1package sources
  2
  3import (
  4	"os"
  5	"path/filepath"
  6	"testing"
  7	"time"
  8
  9	"github.com/vdemeester/home/tools/review-tool/internal/config"
 10)
 11
 12func setupTestOrgFile(t *testing.T) string {
 13	t.Helper()
 14	dir := t.TempDir()
 15
 16	content := `#+title: Test TODOs
 17
 18* Work
 19** DONE [#2] Review PR for feature X
 20CLOSED: [2026-01-25 Sat 15:30] SCHEDULED: <2026-01-25 Sat>
 21:PROPERTIES:
 22:CREATED:       [2026-01-20 Tue 10:00]
 23:CATEGORY: work
 24:END:
 25:LOGBOOK:
 26- State "DONE"       from "TODO"       [2026-01-25 Sat 15:30]
 27:END:
 28
 29Reviewed and merged the PR.
 30
 31** DONE Fix critical bug in authentication
 32CLOSED: [2026-01-22 Wed 11:15]
 33:LOGBOOK:
 34- State "DONE"       from "STRT"       [2026-01-22 Wed 11:15]
 35- State "STRT"       from "TODO"       [2026-01-22 Wed 09:00]
 36CLOCK: [2026-01-22 Wed 09:00]--[2026-01-22 Wed 11:15] =>  2:15
 37:END:
 38
 39Fixed the authentication bug.
 40
 41** TODO Pending task (should not appear)
 42SCHEDULED: <2026-01-28 Tue>
 43
 44This is a pending task.
 45
 46* Projects
 47** DONE Keyboard firmware update
 48CLOSED: [2026-01-15 Wed 18:00]
 49:LOGBOOK:
 50- State "DONE"       from "TODO"       [2026-01-15 Wed 18:00]
 51:END:
 52
 53Updated firmware for both keyboards.
 54`
 55	filePath := filepath.Join(dir, "todos.org")
 56	if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
 57		t.Fatalf("failed to write test org file: %v", err)
 58	}
 59
 60	return filePath
 61}
 62
 63func TestOrgSource_ParsesStateChanges(t *testing.T) {
 64	filePath := setupTestOrgFile(t)
 65
 66	cfg := &config.OrgConfig{
 67		Enabled:             true,
 68		Files:               []string{filePath},
 69		IncludeDone:         true,
 70		IncludeStateChanges: true,
 71		IncludeClockEntries: false,
 72	}
 73
 74	source := NewOrgSource(cfg)
 75
 76	// Query for late January 2026
 77	start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
 78	end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
 79
 80	act, err := source.Fetch(t.Context(), start, end)
 81	if err != nil {
 82		t.Fatalf("Fetch() error = %v", err)
 83	}
 84
 85	// Should find 2 items (the 2 that were marked DONE in range)
 86	// Not the one from Jan 15 (out of range) or pending task
 87	if len(act.Items) < 2 {
 88		t.Errorf("expected at least 2 items, got %d", len(act.Items))
 89		for _, item := range act.Items {
 90			t.Logf("  %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
 91		}
 92	}
 93
 94	// Check for specific items
 95	foundReviewPR := false
 96	foundAuthBug := false
 97	for _, item := range act.Items {
 98		if item.Title == "Review PR for feature X" {
 99			foundReviewPR = true
100			if item.Timestamp.Day() != 25 {
101				t.Errorf("Review PR date = %d, want 25", item.Timestamp.Day())
102			}
103		}
104		if item.Title == "Fix critical bug in authentication" {
105			foundAuthBug = true
106			if item.Timestamp.Day() != 22 {
107				t.Errorf("Auth bug date = %d, want 22", item.Timestamp.Day())
108			}
109		}
110	}
111
112	if !foundReviewPR {
113		t.Error("did not find 'Review PR for feature X'")
114	}
115	if !foundAuthBug {
116		t.Error("did not find 'Fix critical bug in authentication'")
117	}
118}
119
120func TestOrgSource_ParsesClockEntries(t *testing.T) {
121	filePath := setupTestOrgFile(t)
122
123	cfg := &config.OrgConfig{
124		Enabled:             true,
125		Files:               []string{filePath},
126		IncludeDone:         false,
127		IncludeStateChanges: false,
128		IncludeClockEntries: true,
129	}
130
131	source := NewOrgSource(cfg)
132
133	start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
134	end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
135
136	act, err := source.Fetch(t.Context(), start, end)
137	if err != nil {
138		t.Fatalf("Fetch() error = %v", err)
139	}
140
141	// Should find 1 clock entry
142	if len(act.Items) != 1 {
143		t.Errorf("expected 1 clock entry, got %d", len(act.Items))
144		for _, item := range act.Items {
145			t.Logf("  %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
146		}
147		return
148	}
149
150	item := act.Items[0]
151	if item.Type != "clock_entry" {
152		t.Errorf("Type = %q, want %q", item.Type, "clock_entry")
153	}
154	if item.Metadata["duration"] != "2:15" {
155		t.Errorf("duration = %q, want %q", item.Metadata["duration"], "2:15")
156	}
157}
158
159func TestOrgSource_FiltersOutsideDateRange(t *testing.T) {
160	filePath := setupTestOrgFile(t)
161
162	cfg := &config.OrgConfig{
163		Enabled:             true,
164		Files:               []string{filePath},
165		IncludeDone:         true,
166		IncludeStateChanges: true,
167		IncludeClockEntries: true,
168	}
169
170	source := NewOrgSource(cfg)
171
172	// Query for December 2025 - should return nothing
173	start := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)
174	end := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC)
175
176	act, err := source.Fetch(t.Context(), start, end)
177	if err != nil {
178		t.Fatalf("Fetch() error = %v", err)
179	}
180
181	if len(act.Items) != 0 {
182		t.Errorf("expected 0 items outside date range, got %d", len(act.Items))
183	}
184}
185
186func TestOrgSource_ExtractsHeadingTitle(t *testing.T) {
187	filePath := setupTestOrgFile(t)
188
189	cfg := &config.OrgConfig{
190		Enabled:             true,
191		Files:               []string{filePath},
192		IncludeDone:         true,
193		IncludeStateChanges: true,
194		IncludeClockEntries: false,
195	}
196
197	source := NewOrgSource(cfg)
198
199	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
200	end := time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC)
201
202	act, err := source.Fetch(t.Context(), start, end)
203	if err != nil {
204		t.Fatalf("Fetch() error = %v", err)
205	}
206
207	// All items should have cleaned titles (no TODO state, no priority)
208	for _, item := range act.Items {
209		if item.Title == "" {
210			t.Error("found item with empty title")
211		}
212		// Title should not contain TODO/DONE keywords
213		if containsKeyword(item.Title) {
214			t.Errorf("title contains org keyword: %q", item.Title)
215		}
216	}
217}
218
219func containsKeyword(s string) bool {
220	keywords := []string{"TODO", "DONE", "STRT", "NEXT", "WAIT", "CANX"}
221	for _, kw := range keywords {
222		if len(s) >= len(kw) && s[:len(kw)] == kw {
223			return true
224		}
225	}
226	return false
227}
228
229func TestOrgSource_ParsesArchiveDirectory(t *testing.T) {
230	// Create main org file
231	mainDir := t.TempDir()
232	mainContent := `#+title: Main TODOs
233
234* Work
235** DONE Main task done this week
236:LOGBOOK:
237- State "DONE"       from "TODO"       [2026-01-25 Sat 10:00]
238:END:
239`
240	mainFile := filepath.Join(mainDir, "todos.org")
241	if err := os.WriteFile(mainFile, []byte(mainContent), 0o644); err != nil {
242		t.Fatalf("failed to write main org file: %v", err)
243	}
244
245	// Create archive directory with archived TODOs
246	archiveDir := filepath.Join(mainDir, "archive")
247	if err := os.MkdirAll(archiveDir, 0o755); err != nil {
248		t.Fatalf("failed to create archive directory: %v", err)
249	}
250
251	// Archive file WITH .org extension
252	archiveContent := `#+title: Archived Work
253
254* Archived
255** DONE Archived task from last week
256:LOGBOOK:
257- State "DONE"       from "TODO"       [2026-01-22 Wed 14:30]
258:END:
259`
260	archiveFile := filepath.Join(archiveDir, "work.org")
261	if err := os.WriteFile(archiveFile, []byte(archiveContent), 0o644); err != nil {
262		t.Fatalf("failed to write archive org file: %v", err)
263	}
264
265	// Archive file WITHOUT extension (common org-mode archive format)
266	noExtContent := `#    -*- mode: org -*-
267
268Archived entries from file /home/user/todos.org
269
270* DONE Task from extensionless archive
271CLOSED: [2026-01-23 Thu 16:00]
272:PROPERTIES:
273:ARCHIVE_TIME: 2026-01-23 Thu 16:00
274:END:
275:LOGBOOK:
276- State "DONE"       from "TODO"       [2026-01-23 Thu 16:00]
277:END:
278`
279	noExtFile := filepath.Join(archiveDir, "work")
280	if err := os.WriteFile(noExtFile, []byte(noExtContent), 0o644); err != nil {
281		t.Fatalf("failed to write extensionless archive file: %v", err)
282	}
283
284	cfg := &config.OrgConfig{
285		Enabled:             true,
286		Files:               []string{mainFile},
287		ArchiveDir:          archiveDir,
288		IncludeDone:         true,
289		IncludeStateChanges: true,
290		IncludeClockEntries: false,
291	}
292
293	source := NewOrgSource(cfg)
294
295	start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
296	end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
297
298	act, err := source.Fetch(t.Context(), start, end)
299	if err != nil {
300		t.Fatalf("Fetch() error = %v", err)
301	}
302
303	// Should find all 3 tasks: main + .org archive + extensionless archive
304	if len(act.Items) != 3 {
305		t.Errorf("expected 3 items (main + 2 archives), got %d", len(act.Items))
306		for _, item := range act.Items {
307			t.Logf("  %s: %s (%s)", item.Type, item.Title, item.Timestamp.Format("2006-01-02"))
308		}
309		return
310	}
311
312	// Check for all tasks
313	foundMain := false
314	foundArchived := false
315	foundNoExt := false
316	for _, item := range act.Items {
317		if item.Title == "Main task done this week" {
318			foundMain = true
319		}
320		if item.Title == "Archived task from last week" {
321			foundArchived = true
322		}
323		if item.Title == "Task from extensionless archive" {
324			foundNoExt = true
325		}
326	}
327
328	if !foundMain {
329		t.Error("did not find main task")
330	}
331	if !foundArchived {
332		t.Error("did not find archived task (.org)")
333	}
334	if !foundNoExt {
335		t.Error("did not find task from extensionless archive")
336	}
337}
338
339func TestOrgSource_TracksSection(t *testing.T) {
340	dir := t.TempDir()
341	content := `#+title: Test TODOs
342
343* Work
344** DONE Task in Work section
345:LOGBOOK:
346- State "DONE"       from "TODO"       [2026-01-25 Sat 10:00]
347:END:
348
349* Systems
350** DONE Task in Systems section
351:LOGBOOK:
352- State "DONE"       from "TODO"       [2026-01-25 Sat 11:00]
353:END:
354
355* Projects
356** DONE Task in Projects section
357:LOGBOOK:
358- State "DONE"       from "TODO"       [2026-01-25 Sat 12:00]
359:END:
360`
361	filePath := filepath.Join(dir, "todos.org")
362	if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
363		t.Fatalf("failed to write test org file: %v", err)
364	}
365
366	cfg := &config.OrgConfig{
367		Enabled:             true,
368		Files:               []string{filePath},
369		IncludeDone:         true,
370		IncludeStateChanges: true,
371	}
372
373	source := NewOrgSource(cfg)
374	start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
375	end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
376
377	act, err := source.Fetch(t.Context(), start, end)
378	if err != nil {
379		t.Fatalf("Fetch() error = %v", err)
380	}
381
382	if len(act.Items) != 3 {
383		t.Fatalf("expected 3 items, got %d", len(act.Items))
384	}
385
386	// Check section metadata
387	sections := make(map[string]string)
388	for _, item := range act.Items {
389		sections[item.Title] = item.Metadata["section"]
390	}
391
392	if sections["Task in Work section"] != "Work" {
393		t.Errorf("Work task section = %q, want %q", sections["Task in Work section"], "Work")
394	}
395	if sections["Task in Systems section"] != "Systems" {
396		t.Errorf("Systems task section = %q, want %q", sections["Task in Systems section"], "Systems")
397	}
398	if sections["Task in Projects section"] != "Projects" {
399		t.Errorf("Projects task section = %q, want %q", sections["Task in Projects section"], "Projects")
400	}
401}
402
403func TestOrgSource_ConvertsOrgLinksToMarkdown(t *testing.T) {
404	dir := t.TempDir()
405	content := `#+title: Test TODOs
406
407* Work
408** DONE Review [[https://github.com/org/repo/pull/123][PR #123 for feature]]
409:LOGBOOK:
410- State "DONE"       from "TODO"       [2026-01-25 Sat 10:00]
411:END:
412
413** DONE Check [[https://example.com]]
414:LOGBOOK:
415- State "DONE"       from "TODO"       [2026-01-25 Sat 11:00]
416:END:
417
418** DONE Look at [[file:notes.org][my notes]] and [[https://docs.example.com][docs]]
419:LOGBOOK:
420- State "DONE"       from "TODO"       [2026-01-25 Sat 12:00]
421:END:
422`
423	filePath := filepath.Join(dir, "todos.org")
424	if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
425		t.Fatalf("failed to write test org file: %v", err)
426	}
427
428	cfg := &config.OrgConfig{
429		Enabled:             true,
430		Files:               []string{filePath},
431		IncludeDone:         true,
432		IncludeStateChanges: true,
433	}
434
435	source := NewOrgSource(cfg)
436	start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
437	end := time.Date(2026, 1, 26, 23, 59, 59, 0, time.UTC)
438
439	act, err := source.Fetch(t.Context(), start, end)
440	if err != nil {
441		t.Fatalf("Fetch() error = %v", err)
442	}
443
444	if len(act.Items) != 3 {
445		t.Fatalf("expected 3 items, got %d", len(act.Items))
446	}
447
448	// Check link conversion
449	titles := make(map[string]bool)
450	for _, item := range act.Items {
451		titles[item.Title] = true
452		t.Logf("Title: %s", item.Title)
453	}
454
455	// [[url][desc]] -> [desc](url)
456	if !titles["Review [PR #123 for feature](https://github.com/org/repo/pull/123)"] {
457		t.Error("expected link with description to be converted")
458	}
459
460	// [[url]] -> [url](url)
461	if !titles["Check [https://example.com](https://example.com)"] {
462		t.Error("expected bare link to be converted")
463	}
464
465	// Multiple links in one title
466	if !titles["Look at [my notes](file:notes.org) and [docs](https://docs.example.com)"] {
467		t.Error("expected multiple links to be converted")
468	}
469}
470
471func TestConvertOrgLinksToMarkdown(t *testing.T) {
472	tests := []struct {
473		input    string
474		expected string
475	}{
476		// Link with description
477		{"[[https://example.com][Example]]", "[Example](https://example.com)"},
478		// Bare link
479		{"[[https://example.com]]", "[https://example.com](https://example.com)"},
480		// Multiple links
481		{"Check [[https://a.com][A]] and [[https://b.com][B]]", "Check [A](https://a.com) and [B](https://b.com)"},
482		// File link
483		{"[[file:notes.org][Notes]]", "[Notes](file:notes.org)"},
484		// No links
485		{"Plain text without links", "Plain text without links"},
486		// Denote link
487		{"[[denote:20250312T050706][Vibhav Bobade]]", "[Vibhav Bobade](denote:20250312T050706)"},
488	}
489
490	for _, tt := range tests {
491		got := convertOrgLinksToMarkdown(tt.input)
492		if got != tt.expected {
493			t.Errorf("convertOrgLinksToMarkdown(%q) = %q, want %q", tt.input, got, tt.expected)
494		}
495	}
496}