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.

test: added coverage for task & publication handlers

* added mock for ATProto service

+1008 -12
+1 -1
internal/handlers/publication.go
··· 20 20 db *store.Database 21 21 config *store.Config 22 22 repos *repo.Repositories 23 - atproto *services.ATProtoService 23 + atproto services.ATProtoClient 24 24 } 25 25 26 26 // NewPublicationHandler creates a new publication handler
+256
internal/handlers/publication_test.go
··· 1967 1967 } 1968 1968 }) 1969 1969 }) 1970 + 1971 + t.Run("Auth Success Path", func(t *testing.T) { 1972 + suite := NewHandlerTestSuite(t) 1973 + defer suite.Cleanup() 1974 + 1975 + handler := CreateHandler(t, NewPublicationHandler) 1976 + ctx := context.Background() 1977 + 1978 + mock := services.SetupSuccessfulAuthMocks() 1979 + handler.atproto = mock 1980 + 1981 + err := handler.Auth(ctx, "test.bsky.social", "password123") 1982 + suite.AssertNoError(err, "authentication should succeed") 1983 + 1984 + if !handler.atproto.IsAuthenticated() { 1985 + t.Error("Expected handler to be authenticated after successful auth") 1986 + } 1987 + 1988 + session, err := handler.atproto.GetSession() 1989 + suite.AssertNoError(err, "get session should succeed") 1990 + 1991 + if session.Handle != "test.bsky.social" { 1992 + t.Errorf("Expected handle 'test.bsky.social', got '%s'", session.Handle) 1993 + } 1994 + 1995 + if session.DID == "" { 1996 + t.Error("Expected DID to be set") 1997 + } 1998 + }) 1999 + 2000 + t.Run("Pull Success Path", func(t *testing.T) { 2001 + suite := NewHandlerTestSuite(t) 2002 + defer suite.Cleanup() 2003 + 2004 + handler := CreateHandler(t, NewPublicationHandler) 2005 + ctx := context.Background() 2006 + 2007 + mock := services.SetupSuccessfulPullMocks() 2008 + handler.atproto = mock 2009 + 2010 + err := handler.Pull(ctx) 2011 + suite.AssertNoError(err, "pull should succeed") 2012 + 2013 + notes, err := handler.repos.Notes.GetLeafletNotes(ctx) 2014 + suite.AssertNoError(err, "get leaflet notes should succeed") 2015 + 2016 + if len(notes) != 1 { 2017 + t.Errorf("Expected 1 note created, got %d", len(notes)) 2018 + } 2019 + 2020 + if notes[0].Title != "Test Document" { 2021 + t.Errorf("Expected title 'Test Document', got '%s'", notes[0].Title) 2022 + } 2023 + 2024 + if notes[0].LeafletRKey == nil || *notes[0].LeafletRKey != "test_rkey" { 2025 + t.Error("Expected leaflet rkey to be set correctly") 2026 + } 2027 + }) 2028 + 2029 + t.Run("Post Success Path", func(t *testing.T) { 2030 + suite := NewHandlerTestSuite(t) 2031 + defer suite.Cleanup() 2032 + 2033 + handler := CreateHandler(t, NewPublicationHandler) 2034 + ctx := context.Background() 2035 + 2036 + mock := services.NewMockATProtoService() 2037 + mock.IsAuthenticatedVal = true 2038 + mock.Session = &services.Session{ 2039 + DID: "did:plc:test123", 2040 + Handle: "test.bsky.social", 2041 + AccessJWT: "mock_access", 2042 + RefreshJWT: "mock_refresh", 2043 + PDSURL: "https://bsky.social", 2044 + ExpiresAt: time.Now().Add(2 * time.Hour), 2045 + Authenticated: true, 2046 + } 2047 + handler.atproto = mock 2048 + 2049 + note := &models.Note{ 2050 + Title: "Test Post", 2051 + Content: "# Test Content\n\nThis is a test.", 2052 + } 2053 + 2054 + id, err := handler.repos.Notes.Create(ctx, note) 2055 + suite.AssertNoError(err, "create note should succeed") 2056 + 2057 + err = handler.Post(ctx, id, false) 2058 + suite.AssertNoError(err, "post should succeed") 2059 + 2060 + updatedNote, err := handler.repos.Notes.Get(ctx, id) 2061 + suite.AssertNoError(err, "get updated note should succeed") 2062 + 2063 + if updatedNote.LeafletRKey == nil || *updatedNote.LeafletRKey != "mock_rkey_123" { 2064 + t.Error("Expected leaflet rkey to be set after post") 2065 + } 2066 + 2067 + if updatedNote.LeafletCID == nil || *updatedNote.LeafletCID != "mock_cid_456" { 2068 + t.Error("Expected leaflet cid to be set after post") 2069 + } 2070 + 2071 + if updatedNote.IsDraft { 2072 + t.Error("Expected note to be marked as published") 2073 + } 2074 + 2075 + if updatedNote.PublishedAt == nil { 2076 + t.Error("Expected published at to be set") 2077 + } 2078 + }) 2079 + 2080 + t.Run("Post Draft Success Path", func(t *testing.T) { 2081 + suite := NewHandlerTestSuite(t) 2082 + defer suite.Cleanup() 2083 + 2084 + handler := CreateHandler(t, NewPublicationHandler) 2085 + ctx := context.Background() 2086 + 2087 + mock := services.NewMockATProtoService() 2088 + mock.IsAuthenticatedVal = true 2089 + mock.Session = &services.Session{ 2090 + DID: "did:plc:test123", 2091 + Handle: "test.bsky.social", 2092 + AccessJWT: "mock_access", 2093 + RefreshJWT: "mock_refresh", 2094 + PDSURL: "https://bsky.social", 2095 + ExpiresAt: time.Now().Add(2 * time.Hour), 2096 + Authenticated: true, 2097 + } 2098 + handler.atproto = mock 2099 + 2100 + note := &models.Note{ 2101 + Title: "Test Draft", 2102 + Content: "# Draft Content", 2103 + } 2104 + 2105 + id, err := handler.repos.Notes.Create(ctx, note) 2106 + suite.AssertNoError(err, "create note should succeed") 2107 + 2108 + err = handler.Post(ctx, id, true) 2109 + suite.AssertNoError(err, "post draft should succeed") 2110 + 2111 + updatedNote, err := handler.repos.Notes.Get(ctx, id) 2112 + suite.AssertNoError(err, "get updated note should succeed") 2113 + 2114 + if !updatedNote.IsDraft { 2115 + t.Error("Expected note to be marked as draft") 2116 + } 2117 + 2118 + if updatedNote.PublishedAt != nil { 2119 + t.Error("Expected published at to be nil for draft") 2120 + } 2121 + }) 2122 + 2123 + t.Run("Patch Success Path", func(t *testing.T) { 2124 + suite := NewHandlerTestSuite(t) 2125 + defer suite.Cleanup() 2126 + 2127 + handler := CreateHandler(t, NewPublicationHandler) 2128 + ctx := context.Background() 2129 + 2130 + mock := services.NewMockATProtoService() 2131 + mock.IsAuthenticatedVal = true 2132 + mock.Session = &services.Session{ 2133 + DID: "did:plc:test123", 2134 + Handle: "test.bsky.social", 2135 + AccessJWT: "mock_access", 2136 + RefreshJWT: "mock_refresh", 2137 + PDSURL: "https://bsky.social", 2138 + ExpiresAt: time.Now().Add(2 * time.Hour), 2139 + Authenticated: true, 2140 + } 2141 + handler.atproto = mock 2142 + 2143 + rkey := "existing_rkey" 2144 + cid := "existing_cid" 2145 + publishedAt := time.Now().Add(-24 * time.Hour) 2146 + note := &models.Note{ 2147 + Title: "Updated Note", 2148 + Content: "# Updated Content", 2149 + LeafletRKey: &rkey, 2150 + LeafletCID: &cid, 2151 + PublishedAt: &publishedAt, 2152 + IsDraft: false, 2153 + } 2154 + 2155 + id, err := handler.repos.Notes.Create(ctx, note) 2156 + suite.AssertNoError(err, "create note should succeed") 2157 + 2158 + err = handler.Patch(ctx, id) 2159 + suite.AssertNoError(err, "patch should succeed") 2160 + 2161 + updatedNote, err := handler.repos.Notes.Get(ctx, id) 2162 + suite.AssertNoError(err, "get updated note should succeed") 2163 + 2164 + if updatedNote.LeafletCID == nil || *updatedNote.LeafletCID != "mock_cid_updated_789" { 2165 + t.Error("Expected leaflet cid to be updated after patch") 2166 + } 2167 + }) 2168 + 2169 + t.Run("Delete Success Path", func(t *testing.T) { 2170 + suite := NewHandlerTestSuite(t) 2171 + defer suite.Cleanup() 2172 + 2173 + handler := CreateHandler(t, NewPublicationHandler) 2174 + ctx := context.Background() 2175 + 2176 + mock := services.NewMockATProtoService() 2177 + mock.IsAuthenticatedVal = true 2178 + mock.Session = &services.Session{ 2179 + DID: "did:plc:test123", 2180 + Handle: "test.bsky.social", 2181 + AccessJWT: "mock_access", 2182 + RefreshJWT: "mock_refresh", 2183 + PDSURL: "https://bsky.social", 2184 + ExpiresAt: time.Now().Add(2 * time.Hour), 2185 + Authenticated: true, 2186 + } 2187 + handler.atproto = mock 2188 + 2189 + rkey := "test_rkey" 2190 + cid := "test_cid" 2191 + publishedAt := time.Now().Add(-24 * time.Hour) 2192 + note := &models.Note{ 2193 + Title: "Note to Delete", 2194 + Content: "# Content", 2195 + LeafletRKey: &rkey, 2196 + LeafletCID: &cid, 2197 + PublishedAt: &publishedAt, 2198 + IsDraft: false, 2199 + } 2200 + 2201 + id, err := handler.repos.Notes.Create(ctx, note) 2202 + suite.AssertNoError(err, "create note should succeed") 2203 + 2204 + err = handler.Delete(ctx, id) 2205 + suite.AssertNoError(err, "delete should succeed") 2206 + 2207 + updatedNote, err := handler.repos.Notes.Get(ctx, id) 2208 + suite.AssertNoError(err, "get updated note should succeed") 2209 + 2210 + if updatedNote.LeafletRKey != nil { 2211 + t.Error("Expected leaflet rkey to be cleared after delete") 2212 + } 2213 + 2214 + if updatedNote.LeafletCID != nil { 2215 + t.Error("Expected leaflet cid to be cleared after delete") 2216 + } 2217 + 2218 + if updatedNote.PublishedAt != nil { 2219 + t.Error("Expected published at to be cleared after delete") 2220 + } 2221 + 2222 + if updatedNote.IsDraft { 2223 + t.Error("Expected draft flag to be false after delete") 2224 + } 2225 + }) 1970 2226 }
+521 -11
internal/handlers/tasks_test.go
··· 772 772 t.Error("Expected 'a' and 'c' to remain in slice") 773 773 } 774 774 }) 775 + 776 + t.Run("parseDescription extracts project", func(t *testing.T) { 777 + parsed := parseDescription("Buy groceries +shopping") 778 + 779 + if parsed.Description != "Buy groceries" { 780 + t.Errorf("Expected description 'Buy groceries', got '%s'", parsed.Description) 781 + } 782 + if parsed.Project != "shopping" { 783 + t.Errorf("Expected project 'shopping', got '%s'", parsed.Project) 784 + } 785 + }) 786 + 787 + t.Run("parseDescription extracts context", func(t *testing.T) { 788 + parsed := parseDescription("Call boss @work") 789 + 790 + if parsed.Description != "Call boss" { 791 + t.Errorf("Expected description 'Call boss', got '%s'", parsed.Description) 792 + } 793 + if parsed.Context != "work" { 794 + t.Errorf("Expected context 'work', got '%s'", parsed.Context) 795 + } 796 + }) 797 + 798 + t.Run("parseDescription extracts tags", func(t *testing.T) { 799 + parsed := parseDescription("Fix bug #urgent #backend") 800 + 801 + if parsed.Description != "Fix bug" { 802 + t.Errorf("Expected description 'Fix bug', got '%s'", parsed.Description) 803 + } 804 + if len(parsed.Tags) != 2 { 805 + t.Errorf("Expected 2 tags, got %d", len(parsed.Tags)) 806 + } 807 + if parsed.Tags[0] != "urgent" || parsed.Tags[1] != "backend" { 808 + t.Errorf("Expected tags 'urgent' and 'backend', got %v", parsed.Tags) 809 + } 810 + }) 811 + 812 + t.Run("parseDescription extracts due date", func(t *testing.T) { 813 + parsed := parseDescription("Submit report due:2024-12-31") 814 + 815 + if parsed.Description != "Submit report" { 816 + t.Errorf("Expected description 'Submit report', got '%s'", parsed.Description) 817 + } 818 + if parsed.Due != "2024-12-31" { 819 + t.Errorf("Expected due '2024-12-31', got '%s'", parsed.Due) 820 + } 821 + }) 822 + 823 + t.Run("parseDescription extracts recurrence", func(t *testing.T) { 824 + parsed := parseDescription("Weekly meeting recur:FREQ=WEEKLY") 825 + 826 + if parsed.Description != "Weekly meeting" { 827 + t.Errorf("Expected description 'Weekly meeting', got '%s'", parsed.Description) 828 + } 829 + if parsed.Recur != "FREQ=WEEKLY" { 830 + t.Errorf("Expected recur 'FREQ=WEEKLY', got '%s'", parsed.Recur) 831 + } 832 + }) 833 + 834 + t.Run("parseDescription extracts until date", func(t *testing.T) { 835 + parsed := parseDescription("Daily standup until:2024-12-31") 836 + 837 + if parsed.Description != "Daily standup" { 838 + t.Errorf("Expected description 'Daily standup', got '%s'", parsed.Description) 839 + } 840 + if parsed.Until != "2024-12-31" { 841 + t.Errorf("Expected until '2024-12-31', got '%s'", parsed.Until) 842 + } 843 + }) 844 + 845 + t.Run("parseDescription extracts parent UUID", func(t *testing.T) { 846 + parentUUID := "550e8400-e29b-41d4-a716-446655440000" 847 + text := "Subtask parent:" + parentUUID 848 + parsed := parseDescription(text) 849 + 850 + if parsed.Description != "Subtask" { 851 + t.Errorf("Expected description 'Subtask', got '%s'", parsed.Description) 852 + } 853 + if parsed.ParentUUID != parentUUID { 854 + t.Errorf("Expected parent UUID '%s', got '%s'", parentUUID, parsed.ParentUUID) 855 + } 856 + }) 857 + 858 + t.Run("parseDescription extracts dependencies", func(t *testing.T) { 859 + uuid1 := "550e8400-e29b-41d4-a716-446655440000" 860 + uuid2 := "660e8400-e29b-41d4-a716-446655440001" 861 + text := "Task with deps depends:" + uuid1 + "," + uuid2 862 + parsed := parseDescription(text) 863 + 864 + if parsed.Description != "Task with deps" { 865 + t.Errorf("Expected description 'Task with deps', got '%s'", parsed.Description) 866 + } 867 + if len(parsed.DependsOn) != 2 { 868 + t.Errorf("Expected 2 dependencies, got %d", len(parsed.DependsOn)) 869 + } 870 + if parsed.DependsOn[0] != uuid1 || parsed.DependsOn[1] != uuid2 { 871 + t.Errorf("Expected dependencies [%s, %s], got %v", uuid1, uuid2, parsed.DependsOn) 872 + } 873 + }) 874 + 875 + t.Run("parseDescription extracts all metadata", func(t *testing.T) { 876 + text := "Complex task +project @context #tag1 #tag2 due:2024-12-31 recur:FREQ=DAILY until:2025-01-31" 877 + parsed := parseDescription(text) 878 + 879 + if parsed.Description != "Complex task" { 880 + t.Errorf("Expected description 'Complex task', got '%s'", parsed.Description) 881 + } 882 + if parsed.Project != "project" { 883 + t.Errorf("Expected project 'project', got '%s'", parsed.Project) 884 + } 885 + if parsed.Context != "context" { 886 + t.Errorf("Expected context 'context', got '%s'", parsed.Context) 887 + } 888 + if len(parsed.Tags) != 2 { 889 + t.Errorf("Expected 2 tags, got %d", len(parsed.Tags)) 890 + } 891 + if parsed.Due != "2024-12-31" { 892 + t.Errorf("Expected due '2024-12-31', got '%s'", parsed.Due) 893 + } 894 + if parsed.Recur != "FREQ=DAILY" { 895 + t.Errorf("Expected recur 'FREQ=DAILY', got '%s'", parsed.Recur) 896 + } 897 + if parsed.Until != "2025-01-31" { 898 + t.Errorf("Expected until '2025-01-31', got '%s'", parsed.Until) 899 + } 900 + }) 901 + 902 + t.Run("parseDescription handles plain text without metadata", func(t *testing.T) { 903 + parsed := parseDescription("Just a simple task") 904 + 905 + if parsed.Description != "Just a simple task" { 906 + t.Errorf("Expected description 'Just a simple task', got '%s'", parsed.Description) 907 + } 908 + if parsed.Project != "" { 909 + t.Errorf("Expected empty project, got '%s'", parsed.Project) 910 + } 911 + if parsed.Context != "" { 912 + t.Errorf("Expected empty context, got '%s'", parsed.Context) 913 + } 914 + if len(parsed.Tags) != 0 { 915 + t.Errorf("Expected no tags, got %d", len(parsed.Tags)) 916 + } 917 + }) 775 918 }) 776 919 777 920 t.Run("Print", func(t *testing.T) { ··· 800 943 Modified: now, 801 944 } 802 945 803 - t.Run("printTask doesn't panic", func(t *testing.T) { 804 - defer func() { 805 - if r := recover(); r != nil { 806 - t.Errorf("printTask panicked: %v", r) 807 - } 946 + t.Run("printTask outputs basic fields", func(t *testing.T) { 947 + var buf bytes.Buffer 948 + oldStdout := os.Stdout 949 + r, w, _ := os.Pipe() 950 + os.Stdout = w 951 + 952 + outputChan := make(chan string, 1) 953 + go func() { 954 + buf.ReadFrom(r) 955 + outputChan <- buf.String() 808 956 }() 809 957 810 958 printTask(task) 959 + w.Close() 960 + os.Stdout = oldStdout 961 + output := <-outputChan 962 + 963 + if !strings.Contains(output, "Test task") { 964 + t.Error("Output should contain task description") 965 + } 966 + if !strings.Contains(output, "[A]") { 967 + t.Error("Output should contain priority") 968 + } 969 + if !strings.Contains(output, "+test") { 970 + t.Error("Output should contain project") 971 + } 811 972 }) 812 973 813 - t.Run("printTaskDetail doesn't panic", func(t *testing.T) { 814 - defer func() { 815 - if r := recover(); r != nil { 816 - t.Errorf("printTaskDetail panicked: %v", r) 817 - } 974 + t.Run("printTask outputs context", func(t *testing.T) { 975 + taskWithContext := &models.Task{ 976 + ID: 1, 977 + UUID: uuid.New().String(), 978 + Description: "Test task", 979 + Status: "pending", 980 + Context: "work", 981 + } 982 + 983 + var buf bytes.Buffer 984 + oldStdout := os.Stdout 985 + r, w, _ := os.Pipe() 986 + os.Stdout = w 987 + 988 + outputChan := make(chan string, 1) 989 + go func() { 990 + buf.ReadFrom(r) 991 + outputChan <- buf.String() 992 + }() 993 + 994 + printTask(taskWithContext) 995 + w.Close() 996 + os.Stdout = oldStdout 997 + output := <-outputChan 998 + 999 + if !strings.Contains(output, "@work") { 1000 + t.Error("Output should contain context") 1001 + } 1002 + }) 1003 + 1004 + t.Run("printTask outputs recur indicator", func(t *testing.T) { 1005 + taskWithRecur := &models.Task{ 1006 + ID: 1, 1007 + UUID: uuid.New().String(), 1008 + Description: "Recurring task", 1009 + Status: "pending", 1010 + Recur: "FREQ=DAILY", 1011 + } 1012 + 1013 + var buf bytes.Buffer 1014 + oldStdout := os.Stdout 1015 + r, w, _ := os.Pipe() 1016 + os.Stdout = w 1017 + 1018 + outputChan := make(chan string, 1) 1019 + go func() { 1020 + buf.ReadFrom(r) 1021 + outputChan <- buf.String() 818 1022 }() 819 1023 820 - printTaskDetail(task, false) 1024 + printTask(taskWithRecur) 1025 + w.Close() 1026 + os.Stdout = oldStdout 1027 + output := <-outputChan 1028 + 1029 + if !strings.Contains(output, "\u21bb") { 1030 + t.Error("Output should contain recurrence indicator") 1031 + } 1032 + }) 1033 + 1034 + t.Run("printTask outputs dependency count", func(t *testing.T) { 1035 + taskWithDeps := &models.Task{ 1036 + ID: 1, 1037 + UUID: uuid.New().String(), 1038 + Description: "Task with dependencies", 1039 + Status: "pending", 1040 + DependsOn: []string{"uuid1", "uuid2"}, 1041 + } 1042 + 1043 + var buf bytes.Buffer 1044 + oldStdout := os.Stdout 1045 + r, w, _ := os.Pipe() 1046 + os.Stdout = w 1047 + 1048 + outputChan := make(chan string, 1) 1049 + go func() { 1050 + buf.ReadFrom(r) 1051 + outputChan <- buf.String() 1052 + }() 1053 + 1054 + printTask(taskWithDeps) 1055 + w.Close() 1056 + os.Stdout = oldStdout 1057 + output := <-outputChan 1058 + 1059 + if !strings.Contains(output, "\u29372") { 1060 + t.Error("Output should contain dependency count") 1061 + } 1062 + }) 1063 + 1064 + t.Run("printTaskDetail outputs context", func(t *testing.T) { 1065 + taskWithContext := &models.Task{ 1066 + ID: 1, 1067 + UUID: uuid.New().String(), 1068 + Description: "Test task", 1069 + Status: "pending", 1070 + Context: "office", 1071 + Entry: now, 1072 + Modified: now, 1073 + } 1074 + 1075 + var buf bytes.Buffer 1076 + oldStdout := os.Stdout 1077 + r, w, _ := os.Pipe() 1078 + os.Stdout = w 1079 + 1080 + outputChan := make(chan string, 1) 1081 + go func() { 1082 + buf.ReadFrom(r) 1083 + outputChan <- buf.String() 1084 + }() 1085 + 1086 + printTaskDetail(taskWithContext, false) 1087 + w.Close() 1088 + os.Stdout = oldStdout 1089 + output := <-outputChan 1090 + 1091 + if !strings.Contains(output, "Context: office") { 1092 + t.Error("Output should contain context field") 1093 + } 1094 + }) 1095 + 1096 + t.Run("printTaskDetail outputs recurrence", func(t *testing.T) { 1097 + taskWithRecur := &models.Task{ 1098 + ID: 1, 1099 + UUID: uuid.New().String(), 1100 + Description: "Recurring task", 1101 + Status: "pending", 1102 + Recur: "FREQ=WEEKLY", 1103 + Entry: now, 1104 + Modified: now, 1105 + } 1106 + 1107 + var buf bytes.Buffer 1108 + oldStdout := os.Stdout 1109 + r, w, _ := os.Pipe() 1110 + os.Stdout = w 1111 + 1112 + outputChan := make(chan string, 1) 1113 + go func() { 1114 + buf.ReadFrom(r) 1115 + outputChan <- buf.String() 1116 + }() 1117 + 1118 + printTaskDetail(taskWithRecur, false) 1119 + w.Close() 1120 + os.Stdout = oldStdout 1121 + output := <-outputChan 1122 + 1123 + if !strings.Contains(output, "Recurrence: FREQ=WEEKLY") { 1124 + t.Error("Output should contain recurrence field") 1125 + } 1126 + }) 1127 + 1128 + t.Run("printTaskDetail outputs until date", func(t *testing.T) { 1129 + until := now.Add(30 * 24 * time.Hour) 1130 + taskWithUntil := &models.Task{ 1131 + ID: 1, 1132 + UUID: uuid.New().String(), 1133 + Description: "Task with until", 1134 + Status: "pending", 1135 + Until: &until, 1136 + Entry: now, 1137 + Modified: now, 1138 + } 1139 + 1140 + var buf bytes.Buffer 1141 + oldStdout := os.Stdout 1142 + r, w, _ := os.Pipe() 1143 + os.Stdout = w 1144 + 1145 + outputChan := make(chan string, 1) 1146 + go func() { 1147 + buf.ReadFrom(r) 1148 + outputChan <- buf.String() 1149 + }() 1150 + 1151 + printTaskDetail(taskWithUntil, false) 1152 + w.Close() 1153 + os.Stdout = oldStdout 1154 + output := <-outputChan 1155 + 1156 + if !strings.Contains(output, "Recur Until:") { 1157 + t.Error("Output should contain recur until field") 1158 + } 1159 + }) 1160 + 1161 + t.Run("printTaskDetail outputs parent UUID", func(t *testing.T) { 1162 + parentUUID := "550e8400-e29b-41d4-a716-446655440000" 1163 + taskWithParent := &models.Task{ 1164 + ID: 1, 1165 + UUID: uuid.New().String(), 1166 + Description: "Subtask", 1167 + Status: "pending", 1168 + ParentUUID: &parentUUID, 1169 + Entry: now, 1170 + Modified: now, 1171 + } 1172 + 1173 + var buf bytes.Buffer 1174 + oldStdout := os.Stdout 1175 + r, w, _ := os.Pipe() 1176 + os.Stdout = w 1177 + 1178 + outputChan := make(chan string, 1) 1179 + go func() { 1180 + buf.ReadFrom(r) 1181 + outputChan <- buf.String() 1182 + }() 1183 + 1184 + printTaskDetail(taskWithParent, false) 1185 + w.Close() 1186 + os.Stdout = oldStdout 1187 + output := <-outputChan 1188 + 1189 + if !strings.Contains(output, "Parent Task:") { 1190 + t.Error("Output should contain parent task field") 1191 + } 1192 + if !strings.Contains(output, parentUUID) { 1193 + t.Error("Output should contain parent UUID") 1194 + } 1195 + }) 1196 + 1197 + t.Run("printTaskDetail outputs dependencies", func(t *testing.T) { 1198 + taskWithDeps := &models.Task{ 1199 + ID: 1, 1200 + UUID: uuid.New().String(), 1201 + Description: "Task with deps", 1202 + Status: "pending", 1203 + DependsOn: []string{"uuid1", "uuid2"}, 1204 + Entry: now, 1205 + Modified: now, 1206 + } 1207 + 1208 + var buf bytes.Buffer 1209 + oldStdout := os.Stdout 1210 + r, w, _ := os.Pipe() 1211 + os.Stdout = w 1212 + 1213 + outputChan := make(chan string, 1) 1214 + go func() { 1215 + buf.ReadFrom(r) 1216 + outputChan <- buf.String() 1217 + }() 1218 + 1219 + printTaskDetail(taskWithDeps, false) 1220 + w.Close() 1221 + os.Stdout = oldStdout 1222 + output := <-outputChan 1223 + 1224 + if !strings.Contains(output, "Depends On:") { 1225 + t.Error("Output should contain depends on field") 1226 + } 1227 + if !strings.Contains(output, "uuid1") || !strings.Contains(output, "uuid2") { 1228 + t.Error("Output should contain dependency UUIDs") 1229 + } 1230 + }) 1231 + 1232 + t.Run("printTaskDetail outputs start time", func(t *testing.T) { 1233 + start := now.Add(-1 * time.Hour) 1234 + taskWithStart := &models.Task{ 1235 + ID: 1, 1236 + UUID: uuid.New().String(), 1237 + Description: "Started task", 1238 + Status: "pending", 1239 + Start: &start, 1240 + Entry: now, 1241 + Modified: now, 1242 + } 1243 + 1244 + var buf bytes.Buffer 1245 + oldStdout := os.Stdout 1246 + r, w, _ := os.Pipe() 1247 + os.Stdout = w 1248 + 1249 + outputChan := make(chan string, 1) 1250 + go func() { 1251 + buf.ReadFrom(r) 1252 + outputChan <- buf.String() 1253 + }() 1254 + 1255 + printTaskDetail(taskWithStart, false) 1256 + w.Close() 1257 + os.Stdout = oldStdout 1258 + output := <-outputChan 1259 + 1260 + if !strings.Contains(output, "Started:") { 1261 + t.Error("Output should contain started field") 1262 + } 1263 + }) 1264 + 1265 + t.Run("printTaskDetail outputs end time", func(t *testing.T) { 1266 + end := now.Add(-1 * time.Hour) 1267 + taskWithEnd := &models.Task{ 1268 + ID: 1, 1269 + UUID: uuid.New().String(), 1270 + Description: "Completed task", 1271 + Status: "completed", 1272 + End: &end, 1273 + Entry: now, 1274 + Modified: now, 1275 + } 1276 + 1277 + var buf bytes.Buffer 1278 + oldStdout := os.Stdout 1279 + r, w, _ := os.Pipe() 1280 + os.Stdout = w 1281 + 1282 + outputChan := make(chan string, 1) 1283 + go func() { 1284 + buf.ReadFrom(r) 1285 + outputChan <- buf.String() 1286 + }() 1287 + 1288 + printTaskDetail(taskWithEnd, false) 1289 + w.Close() 1290 + os.Stdout = oldStdout 1291 + output := <-outputChan 1292 + 1293 + if !strings.Contains(output, "Completed:") { 1294 + t.Error("Output should contain completed field") 1295 + } 1296 + }) 1297 + 1298 + t.Run("printTaskDetail outputs annotations", func(t *testing.T) { 1299 + taskWithAnnotations := &models.Task{ 1300 + ID: 1, 1301 + UUID: uuid.New().String(), 1302 + Description: "Task with notes", 1303 + Status: "pending", 1304 + Annotations: []string{"Note 1", "Note 2"}, 1305 + Entry: now, 1306 + Modified: now, 1307 + } 1308 + 1309 + var buf bytes.Buffer 1310 + oldStdout := os.Stdout 1311 + r, w, _ := os.Pipe() 1312 + os.Stdout = w 1313 + 1314 + outputChan := make(chan string, 1) 1315 + go func() { 1316 + buf.ReadFrom(r) 1317 + outputChan <- buf.String() 1318 + }() 1319 + 1320 + printTaskDetail(taskWithAnnotations, false) 1321 + w.Close() 1322 + os.Stdout = oldStdout 1323 + output := <-outputChan 1324 + 1325 + if !strings.Contains(output, "Annotations:") { 1326 + t.Error("Output should contain annotations field") 1327 + } 1328 + if !strings.Contains(output, "Note 1") || !strings.Contains(output, "Note 2") { 1329 + t.Error("Output should contain annotation texts") 1330 + } 821 1331 }) 822 1332 }) 823 1333
+14
internal/services/atproto.go
··· 48 48 Authenticated bool // Whether session is valid 49 49 } 50 50 51 + // ATProtoClient defines the interface for AT Protocol operations 52 + type ATProtoClient interface { 53 + Authenticate(ctx context.Context, handle, password string) error 54 + GetSession() (*Session, error) 55 + IsAuthenticated() bool 56 + RestoreSession(session *Session) error 57 + PullDocuments(ctx context.Context) ([]DocumentWithMeta, error) 58 + PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 59 + PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 60 + DeleteDocument(ctx context.Context, rkey string, isDraft bool) error 61 + UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 62 + Close() error 63 + } 64 + 51 65 // ATProtoService provides AT Protocol operations for leaflet integration 52 66 type ATProtoService struct { 53 67 handle string
+216
internal/services/test_utilities.go
··· 7 7 "errors" 8 8 "strings" 9 9 "testing" 10 + "time" 10 11 11 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 + "github.com/stormlightlabs/noteleaf/internal/public" 12 14 ) 13 15 14 16 // From: https://www.rottentomatoes.com/m/the_fantastic_four_first_steps ··· 259 261 260 262 AssertTVShowInResults(t, results, expectedTitleFragment) 261 263 } 264 + 265 + // MockATProtoService is a mock implementation of ATProtoService for testing 266 + type MockATProtoService struct { 267 + AuthenticateFunc func(ctx context.Context, handle, password string) error 268 + GetSessionFunc func() (*Session, error) 269 + IsAuthenticatedVal bool 270 + RestoreSessionFunc func(session *Session) error 271 + PullDocumentsFunc func(ctx context.Context) ([]DocumentWithMeta, error) 272 + PostDocumentFunc func(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 273 + PatchDocumentFunc func(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 274 + DeleteDocumentFunc func(ctx context.Context, rkey string, isDraft bool) error 275 + UploadBlobFunc func(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 276 + CloseFunc func() error 277 + Session *Session // Exported for test access 278 + } 279 + 280 + // NewMockATProtoService creates a new mock AT Proto service 281 + func NewMockATProtoService() *MockATProtoService { 282 + return &MockATProtoService{IsAuthenticatedVal: false} 283 + } 284 + 285 + // Authenticate mocks authentication 286 + func (m *MockATProtoService) Authenticate(ctx context.Context, handle, password string) error { 287 + if m.AuthenticateFunc != nil { 288 + return m.AuthenticateFunc(ctx, handle, password) 289 + } 290 + 291 + // Default successful authentication 292 + m.Session = &Session{ 293 + DID: "did:plc:test123", 294 + Handle: handle, 295 + AccessJWT: "mock_access_token", 296 + RefreshJWT: "mock_refresh_token", 297 + PDSURL: "https://bsky.social", 298 + ExpiresAt: time.Now().Add(2 * time.Hour), 299 + Authenticated: true, 300 + } 301 + m.IsAuthenticatedVal = true 302 + return nil 303 + } 304 + 305 + // GetSession returns the current session 306 + func (m *MockATProtoService) GetSession() (*Session, error) { 307 + if m.GetSessionFunc != nil { 308 + return m.GetSessionFunc() 309 + } 310 + 311 + if m.Session == nil || !m.Session.Authenticated { 312 + return nil, errors.New("not authenticated - run 'noteleaf pub auth' first") 313 + } 314 + return m.Session, nil 315 + } 316 + 317 + // IsAuthenticated returns authentication status 318 + func (m *MockATProtoService) IsAuthenticated() bool { 319 + return m.IsAuthenticatedVal 320 + } 321 + 322 + // RestoreSession restores a session 323 + func (m *MockATProtoService) RestoreSession(session *Session) error { 324 + if m.RestoreSessionFunc != nil { 325 + return m.RestoreSessionFunc(session) 326 + } 327 + 328 + m.Session = session 329 + m.IsAuthenticatedVal = true 330 + return nil 331 + } 332 + 333 + // PullDocuments mocks pulling documents 334 + func (m *MockATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, error) { 335 + if m.PullDocumentsFunc != nil { 336 + return m.PullDocumentsFunc(ctx) 337 + } 338 + return []DocumentWithMeta{}, nil 339 + } 340 + 341 + // PostDocument mocks posting a document 342 + func (m *MockATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 343 + if m.PostDocumentFunc != nil { 344 + return m.PostDocumentFunc(ctx, doc, isDraft) 345 + } 346 + 347 + // Default successful post 348 + return &DocumentWithMeta{ 349 + Document: doc, 350 + Meta: public.DocumentMeta{ 351 + RKey: "mock_rkey_123", 352 + CID: "mock_cid_456", 353 + URI: "at://did:plc:test123/pub.leaflet.document/mock_rkey_123", 354 + IsDraft: isDraft, 355 + FetchedAt: time.Now(), 356 + }, 357 + }, nil 358 + } 359 + 360 + // PatchDocument mocks patching a document 361 + func (m *MockATProtoService) PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 362 + if m.PatchDocumentFunc != nil { 363 + return m.PatchDocumentFunc(ctx, rkey, doc, isDraft) 364 + } 365 + 366 + return &DocumentWithMeta{ 367 + Document: doc, 368 + Meta: public.DocumentMeta{ 369 + RKey: rkey, 370 + CID: "mock_cid_updated_789", 371 + URI: "at://did:plc:test123/pub.leaflet.document/" + rkey, 372 + IsDraft: isDraft, 373 + FetchedAt: time.Now(), 374 + }, 375 + }, nil 376 + } 377 + 378 + // DeleteDocument mocks deleting a document 379 + func (m *MockATProtoService) DeleteDocument(ctx context.Context, rkey string, isDraft bool) error { 380 + if m.DeleteDocumentFunc != nil { 381 + return m.DeleteDocumentFunc(ctx, rkey, isDraft) 382 + } 383 + return nil 384 + } 385 + 386 + // UploadBlob mocks blob upload 387 + func (m *MockATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) { 388 + if m.UploadBlobFunc != nil { 389 + return m.UploadBlobFunc(ctx, data, mimeType) 390 + } 391 + 392 + return public.Blob{ 393 + Type: public.TypeBlob, 394 + Ref: public.CID{Link: "mock_blob_cid"}, 395 + MimeType: mimeType, 396 + Size: len(data), 397 + }, nil 398 + } 399 + 400 + // Close mocks cleanup 401 + func (m *MockATProtoService) Close() error { 402 + if m.CloseFunc != nil { 403 + return m.CloseFunc() 404 + } 405 + m.Session = nil 406 + m.IsAuthenticatedVal = false 407 + return nil 408 + } 409 + 410 + // SetupSuccessfulAuthMocks configures mock for successful authentication 411 + func SetupSuccessfulAuthMocks() *MockATProtoService { 412 + mock := NewMockATProtoService() 413 + mock.AuthenticateFunc = func(ctx context.Context, handle, password string) error { 414 + mock.Session = &Session{ 415 + DID: "did:plc:test123", 416 + Handle: handle, 417 + AccessJWT: "mock_access_token", 418 + RefreshJWT: "mock_refresh_token", 419 + PDSURL: "https://bsky.social", 420 + ExpiresAt: time.Now().Add(2 * time.Hour), 421 + Authenticated: true, 422 + } 423 + mock.IsAuthenticatedVal = true 424 + return nil 425 + } 426 + return mock 427 + } 428 + 429 + // SetupSuccessfulPullMocks configures mock for successful document pull 430 + func SetupSuccessfulPullMocks() *MockATProtoService { 431 + mock := NewMockATProtoService() 432 + mock.IsAuthenticatedVal = true 433 + mock.Session = &Session{ 434 + DID: "did:plc:test123", 435 + Handle: "test.bsky.social", 436 + AccessJWT: "mock_access", 437 + RefreshJWT: "mock_refresh", 438 + PDSURL: "https://bsky.social", 439 + ExpiresAt: time.Now().Add(2 * time.Hour), 440 + Authenticated: true, 441 + } 442 + 443 + mock.PullDocumentsFunc = func(ctx context.Context) ([]DocumentWithMeta, error) { 444 + return []DocumentWithMeta{ 445 + { 446 + Document: public.Document{ 447 + Type: public.TypeDocument, 448 + Title: "Test Document", 449 + Pages: []public.LinearDocument{ 450 + { 451 + Type: public.TypeLinearDocument, 452 + Blocks: []public.BlockWrap{ 453 + { 454 + Type: "pub.leaflet.pages.linearDocument#block", 455 + Block: public.TextBlock{ 456 + Type: "pub.leaflet.pages.linearDocument#textBlock", 457 + Plaintext: "Test content", 458 + }, 459 + }, 460 + }, 461 + }, 462 + }, 463 + PublishedAt: time.Now().Format(time.RFC3339), 464 + }, 465 + Meta: public.DocumentMeta{ 466 + RKey: "test_rkey", 467 + CID: "test_cid", 468 + URI: "at://did:plc:test123/pub.leaflet.document/test_rkey", 469 + IsDraft: false, 470 + FetchedAt: time.Now(), 471 + }, 472 + }, 473 + }, nil 474 + } 475 + 476 + return mock 477 + }