this repo has no description
1
fork

Configure Feed

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

fix: validate client filters and use COUNT for stats

Enforce hierarchical client filter dependencies so that
client_network requires client_type and client_channel
requires both. Returns 400 for invalid combinations.

Replace stats handler's full-table fetches with COUNT(*)
queries via new CountIRCLinks/CountQuotes store methods.

Update OpenAPI spec with 400 responses and fix incorrect
208 reference on link creation endpoint.

+171 -47
+37 -7
internal/assets/openapi.json
··· 773 773 "in": "query", 774 774 "required": false, 775 775 "schema": { "type": "string" }, 776 - "description": "Filter by client network (requires client_type)" 776 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 777 777 }, 778 778 { 779 779 "name": "client_channel", 780 780 "in": "query", 781 781 "required": false, 782 782 "schema": { "type": "string" }, 783 - "description": "Filter by client channel (requires client_type and client_network)" 783 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 784 784 } 785 785 ], 786 786 "responses": { ··· 800 800 } 801 801 } 802 802 }, 803 + "400": { 804 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 805 + "content": { 806 + "application/json": { 807 + "schema": { 808 + "$ref": "#/components/schemas/APIError" 809 + } 810 + } 811 + } 812 + }, 803 813 "500": { 804 814 "description": "Server error", 805 815 "content": { ··· 828 838 }, 829 839 "responses": { 830 840 "201": { 831 - "description": "Link created successfully. If duplicate detection finds a prior submission (scoped per client when client fields are provided), the response includes is_duplicate=true and previous_submissions. Returns 208 instead when the link is an exact duplicate within the same client scope.", 841 + "description": "Link created successfully. If duplicate detection finds a prior submission (scoped per client when client fields are provided), the response includes is_duplicate=true and previous_submissions.", 832 842 "content": { 833 843 "application/json": { 834 844 "schema": { ··· 1016 1026 "in": "query", 1017 1027 "required": false, 1018 1028 "schema": { "type": "string" }, 1019 - "description": "Filter by client network (requires client_type)" 1029 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 1020 1030 }, 1021 1031 { 1022 1032 "name": "client_channel", 1023 1033 "in": "query", 1024 1034 "required": false, 1025 1035 "schema": { "type": "string" }, 1026 - "description": "Filter by client channel (requires client_type and client_network)" 1036 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 1027 1037 } 1028 1038 ], 1029 1039 "responses": { ··· 1039 1049 "schema": { 1040 1050 "type": "string", 1041 1051 "description": "Plain text representation of quotes" 1052 + } 1053 + } 1054 + } 1055 + }, 1056 + "400": { 1057 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 1058 + "content": { 1059 + "application/json": { 1060 + "schema": { 1061 + "$ref": "#/components/schemas/APIError" 1042 1062 } 1043 1063 } 1044 1064 } ··· 1843 1863 "in": "query", 1844 1864 "required": false, 1845 1865 "schema": { "type": "string" }, 1846 - "description": "Filter by client network (requires client_type)" 1866 + "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided." 1847 1867 }, 1848 1868 { 1849 1869 "name": "client_channel", 1850 1870 "in": "query", 1851 1871 "required": false, 1852 1872 "schema": { "type": "string" }, 1853 - "description": "Filter by client channel (requires client_type and client_network)" 1873 + "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing." 1854 1874 } 1855 1875 ], 1856 1876 "responses": { ··· 1866 1886 "schema": { 1867 1887 "type": "string", 1868 1888 "description": "Plain text representation" 1889 + } 1890 + } 1891 + } 1892 + }, 1893 + "400": { 1894 + "description": "Invalid client filter parameters (e.g., client_network without client_type)", 1895 + "content": { 1896 + "application/json": { 1897 + "schema": { 1898 + "$ref": "#/components/schemas/APIError" 1869 1899 } 1870 1900 } 1871 1901 }
+57
internal/data/client_filter_test.go
··· 50 50 } 51 51 } 52 52 53 + func TestClientFilter_Validate(t *testing.T) { 54 + tests := []struct { 55 + name string 56 + filter ClientFilter 57 + wantError bool 58 + }{ 59 + { 60 + name: "empty filter is valid", 61 + filter: ClientFilter{}, 62 + }, 63 + { 64 + name: "type only is valid", 65 + filter: ClientFilter{ClientType: strPtr("irc")}, 66 + }, 67 + { 68 + name: "type and network is valid", 69 + filter: ClientFilter{ClientType: strPtr("irc"), ClientNetwork: strPtr("libera")}, 70 + }, 71 + { 72 + name: "all three is valid", 73 + filter: ClientFilter{ClientType: strPtr("irc"), ClientNetwork: strPtr("libera"), ClientChannel: strPtr("#general")}, 74 + }, 75 + { 76 + name: "network without type is invalid", 77 + filter: ClientFilter{ClientNetwork: strPtr("libera")}, 78 + wantError: true, 79 + }, 80 + { 81 + name: "channel without type is invalid", 82 + filter: ClientFilter{ClientChannel: strPtr("#general")}, 83 + wantError: true, 84 + }, 85 + { 86 + name: "channel without network is invalid", 87 + filter: ClientFilter{ClientType: strPtr("irc"), ClientChannel: strPtr("#general")}, 88 + wantError: true, 89 + }, 90 + { 91 + name: "channel and network without type is invalid", 92 + filter: ClientFilter{ClientNetwork: strPtr("libera"), ClientChannel: strPtr("#general")}, 93 + wantError: true, 94 + }, 95 + } 96 + 97 + for _, tt := range tests { 98 + t.Run(tt.name, func(t *testing.T) { 99 + err := tt.filter.Validate() 100 + if tt.wantError && err == nil { 101 + t.Error("expected error, got nil") 102 + } 103 + if !tt.wantError && err != nil { 104 + t.Errorf("expected no error, got %v", err) 105 + } 106 + }) 107 + } 108 + } 109 + 53 110 func strPtr(s string) *string { 54 111 return &s 55 112 }
+12
internal/data/gorm_store.go
··· 277 277 return nil 278 278 } 279 279 280 + func (s *GormStore) CountIRCLinks(ctx context.Context) (int64, error) { 281 + var count int64 282 + err := s.db.WithContext(ctx).Model(&IRCLink{}).Count(&count).Error 283 + return count, err 284 + } 285 + 286 + func (s *GormStore) CountQuotes(ctx context.Context) (int64, error) { 287 + var count int64 288 + err := s.db.WithContext(ctx).Model(&Quote{}).Count(&count).Error 289 + return count, err 290 + } 291 + 280 292 func (s *GormStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 281 293 var stats []UserStat 282 294
+15
internal/data/store.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "time" 6 7 ) 7 8 ··· 95 96 return f.ClientType == nil && f.ClientNetwork == nil && f.ClientChannel == nil 96 97 } 97 98 99 + // Validate checks that hierarchical filter dependencies are satisfied. 100 + // client_network requires client_type, and client_channel requires both. 101 + func (f ClientFilter) Validate() error { 102 + if f.ClientNetwork != nil && f.ClientType == nil { 103 + return fmt.Errorf("client_network requires client_type") 104 + } 105 + if f.ClientChannel != nil && (f.ClientType == nil || f.ClientNetwork == nil) { 106 + return fmt.Errorf("client_channel requires client_type and client_network") 107 + } 108 + return nil 109 + } 110 + 98 111 type Tag struct { 99 112 ID int `json:"id" gorm:"column:id;primaryKey;autoIncrement"` 100 113 Tag string `json:"tag" gorm:"column:tag;type:varchar(255);index"` ··· 153 166 DeleteQuote(ctx context.Context, id int) error 154 167 155 168 // Stats 169 + CountIRCLinks(ctx context.Context) (int64, error) 170 + CountQuotes(ctx context.Context) (int64, error) 156 171 GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) 157 172 GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) 158 173 GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error)
+14
internal/handler/api_v1_integration_test.go
··· 107 107 return m.err 108 108 } 109 109 110 + func (m *integrationMockStore) CountIRCLinks(ctx context.Context) (int64, error) { 111 + if m.err != nil { 112 + return 0, m.err 113 + } 114 + return int64(len(m.links)), nil 115 + } 116 + 117 + func (m *integrationMockStore) CountQuotes(ctx context.Context) (int64, error) { 118 + if m.err != nil { 119 + return 0, m.err 120 + } 121 + return int64(len(m.quotes)), nil 122 + } 123 + 110 124 func (m *integrationMockStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]data.UserStat, error) { 111 125 if m.err != nil { 112 126 return nil, m.err
+4
internal/handler/api_v1_links.go
··· 81 81 if sc := r.URL.Query().Get("client_channel"); sc != "" { 82 82 clientFilter.ClientChannel = &sc 83 83 } 84 + if err := clientFilter.Validate(); err != nil { 85 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 86 + return 87 + } 84 88 85 89 // Fetch all links from the last year 86 90 // We fetch more than needed so we can paginate in-memory
+4
internal/handler/api_v1_quotes.go
··· 81 81 if sc := r.URL.Query().Get("client_channel"); sc != "" { 82 82 clientFilter.ClientChannel = &sc 83 83 } 84 + if err := clientFilter.Validate(); err != nil { 85 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 86 + return 87 + } 84 88 85 89 // Fetch all quotes from the last year 86 90 // We fetch more than needed so we can paginate in-memory
+4
internal/handler/api_v1_search.go
··· 74 74 if sc := r.URL.Query().Get("client_channel"); sc != "" { 75 75 clientFilter.ClientChannel = &sc 76 76 } 77 + if err := clientFilter.Validate(); err != nil { 78 + writeAPIError(w, http.StatusBadRequest, "invalid_params", err.Error()) 79 + return 80 + } 77 81 78 82 // Initialize response 79 83 resp := APISearchResponse{
+6 -8
internal/handler/api_v1_stats.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 "strings" 7 - 8 - "tumble/internal/data" 9 7 ) 10 8 11 9 // APIv1StatsHandler routes requests to /api/v1/stats endpoint. ··· 62 60 } 63 61 64 62 // Get total links and quotes for site stats 65 - links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0, data.ClientFilter{}) // ~100 years to get all 63 + totalLinks, err := h.Store.CountIRCLinks(ctx) 66 64 if err != nil { 67 - writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 65 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to count links") 68 66 return 69 67 } 70 68 71 - quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0, data.ClientFilter{}) // ~100 years to get all 69 + totalQuotes, err := h.Store.CountQuotes(ctx) 72 70 if err != nil { 73 - writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 71 + writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to count quotes") 74 72 return 75 73 } 76 74 ··· 86 84 87 85 resp := APIStatsResponse{ 88 86 Site: APISiteStats{ 89 - TotalLinks: len(links), 90 - TotalQuotes: len(quotes), 87 + TotalLinks: int(totalLinks), 88 + TotalQuotes: int(totalQuotes), 91 89 TotalUsers: totalUsers, 92 90 }, 93 91 Leaderboard: leaderboardData,
+18 -32
internal/handler/api_v1_stats_test.go
··· 16 16 data.Store 17 17 userStats []data.UserStat 18 18 userStatsFn func(sortBy string, limit int, offset int) ([]data.UserStat, error) 19 - links []data.IRCLink 20 - quotes []data.Quote 19 + linkCount int64 20 + quoteCount int64 21 21 err error 22 22 } 23 23 ··· 31 31 return m.userStats, nil 32 32 } 33 33 34 - func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.IRCLink, error) { 34 + func (m *mockStatsStore) CountIRCLinks(ctx context.Context) (int64, error) { 35 35 if m.err != nil { 36 - return nil, m.err 36 + return 0, m.err 37 37 } 38 - return m.links, nil 38 + return m.linkCount, nil 39 39 } 40 40 41 - func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.ClientFilter) ([]data.Quote, error) { 41 + func (m *mockStatsStore) CountQuotes(ctx context.Context) (int64, error) { 42 42 if m.err != nil { 43 - return nil, m.err 43 + return 0, m.err 44 44 } 45 - return m.quotes, nil 45 + return m.quoteCount, nil 46 46 } 47 47 48 48 func TestAPIv1_Stats(t *testing.T) { ··· 51 51 method string 52 52 path string 53 53 userStats []data.UserStat 54 - links []data.IRCLink 55 - quotes []data.Quote 54 + linkCount int64 55 + quoteCount int64 56 56 storeErr error 57 57 expectedStatus int 58 58 checkBody func(t *testing.T, body []byte) ··· 62 62 method: http.MethodGet, 63 63 path: "/api/v1/stats", 64 64 userStats: []data.UserStat{}, 65 - links: []data.IRCLink{}, 66 - quotes: []data.Quote{}, 65 + linkCount: 0, 66 + quoteCount: 0, 67 67 expectedStatus: http.StatusOK, 68 68 checkBody: func(t *testing.T, body []byte) { 69 69 var resp APIStatsResponse ··· 101 101 {User: "alice", LinkCount: 500, QuoteCount: 120}, 102 102 {User: "bob", LinkCount: 300, QuoteCount: 80}, 103 103 }, 104 - links: make([]data.IRCLink, 15000), 105 - quotes: make([]data.Quote, 3200), 104 + linkCount: 15000, 105 + quoteCount: 3200, 106 106 expectedStatus: http.StatusOK, 107 107 checkBody: func(t *testing.T, body []byte) { 108 108 var resp APIStatsResponse ··· 143 143 {User: "alice", LinkCount: 500, QuoteCount: 120}, 144 144 {User: "bob", LinkCount: 300, QuoteCount: 80}, 145 145 }, 146 - links: []data.IRCLink{}, 147 - quotes: []data.Quote{}, 148 146 expectedStatus: http.StatusOK, 149 147 checkBody: func(t *testing.T, body []byte) { 150 148 var resp APIStatsResponse ··· 171 169 {User: "alice", LinkCount: 500, QuoteCount: 120}, 172 170 {User: "bob", LinkCount: 300, QuoteCount: 80}, 173 171 }, 174 - links: []data.IRCLink{}, 175 - quotes: []data.Quote{}, 176 172 expectedStatus: http.StatusOK, 177 173 checkBody: func(t *testing.T, body []byte) { 178 174 var resp APIStatsResponse ··· 195 191 method: http.MethodGet, 196 192 path: "/api/v1/stats?limit=5000", 197 193 userStats: []data.UserStat{}, 198 - links: []data.IRCLink{}, 199 - quotes: []data.Quote{}, 200 194 expectedStatus: http.StatusOK, 201 195 checkBody: func(t *testing.T, body []byte) { 202 196 var resp APIStatsResponse ··· 213 207 method: http.MethodGet, 214 208 path: "/api/v1/stats?limit=abc", 215 209 userStats: []data.UserStat{}, 216 - links: []data.IRCLink{}, 217 - quotes: []data.Quote{}, 218 210 expectedStatus: http.StatusOK, 219 211 checkBody: func(t *testing.T, body []byte) { 220 212 var resp APIStatsResponse ··· 233 225 userStats: []data.UserStat{ 234 226 {User: "alice", LinkCount: 500, QuoteCount: 120}, 235 227 }, 236 - links: []data.IRCLink{}, 237 - quotes: []data.Quote{}, 238 228 expectedStatus: http.StatusOK, 239 229 checkBody: func(t *testing.T, body []byte) { 240 230 var resp APIStatsResponse ··· 254 244 method: http.MethodPost, 255 245 path: "/api/v1/stats", 256 246 userStats: []data.UserStat{}, 257 - links: []data.IRCLink{}, 258 - quotes: []data.Quote{}, 259 247 expectedStatus: http.StatusMethodNotAllowed, 260 248 checkBody: func(t *testing.T, body []byte) { 261 249 var resp APIErrorResponse ··· 274 262 userStats: []data.UserStat{ 275 263 {User: "alice", LinkCount: 500, QuoteCount: 120}, 276 264 }, 277 - links: []data.IRCLink{}, 278 - quotes: []data.Quote{}, 279 265 expectedStatus: http.StatusOK, 280 266 checkBody: func(t *testing.T, body []byte) { 281 267 var resp APIStatsResponse ··· 292 278 for _, tt := range tests { 293 279 t.Run(tt.name, func(t *testing.T) { 294 280 store := &mockStatsStore{ 295 - userStats: tt.userStats, 296 - links: tt.links, 297 - quotes: tt.quotes, 298 - err: tt.storeErr, 281 + userStats: tt.userStats, 282 + linkCount: tt.linkCount, 283 + quoteCount: tt.quoteCount, 284 + err: tt.storeErr, 299 285 } 300 286 handler := &Handler{ 301 287 Store: store,