detached 562ba6b

refactor(filtering): simplify version check to use HEAD request with ETag

commit
562ba6b3c3b2b1823964e1645f2ce9065a3edb26
author
bnema · · 2025-11-29 13:55 UTC
parent
0db378a
signature
GPG Unverified2B82207D370127A0 The key is unknown
2 files · +46 -181
M internal/filtering/updater.go +15 -59
@@ -1,12 +1,9 @@
11 package filtering
22
33 import (
4- "bufio"
5- "bytes"
64 "context"
75 "crypto/sha256"
86 "fmt"
9- "io"
107 "net/http"
118 "strings"
129 "sync"
@@ -144,21 +141,17 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi
144141 return fu.downloadUpdate(ctx, url, "", "")
145142 }
146143
147-// fetchRemoteVersion fetches only the header of a filter list to extract version
148-// Uses HTTP Range request to minimize bandwidth (fetches ~1KB instead of ~100KB+)
144+// fetchRemoteVersion fetches version info using HEAD request (ETag or Last-Modified)
149145 func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (string, error) {
150- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
146+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
151147 if err != nil {
152148 return "", fmt.Errorf("failed to create request: %w", err)
153149 }
154-
155- // Request only first 1024 bytes (enough to get version header)
156- req.Header.Set("Range", "bytes=0-1023")
157150 req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
158151
159152 resp, err := fu.httpClient.Do(req)
160153 if err != nil {
161- return "", fmt.Errorf("range request failed: %w", err)
154+ return "", fmt.Errorf("HEAD request failed: %w", err)
162155 }
163156 defer func() {
164157 if closeErr := resp.Body.Close(); closeErr != nil {
@@ -166,38 +159,19 @@ func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (st
166159 }
167160 }()
168161
169- // Accept both 200 (server ignores Range) and 206 (Partial Content)
170- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
171- return "", fmt.Errorf("request returned %d", resp.StatusCode)
172- }
173-
174- // Read the response (limited to 1KB even if server ignores Range)
175- data, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
176- if err != nil {
177- return "", fmt.Errorf("failed to read response: %w", err)
178- }
179-
180- // Parse version from content header (format: "! Version: 202511291248")
181- scanner := bufio.NewScanner(bytes.NewReader(data))
182- for scanner.Scan() {
183- line := scanner.Text()
184- if strings.HasPrefix(line, "! Version:") {
185- version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
186- if version != "" && !strings.Contains(version, "%") {
187- return version, nil
188- }
189- }
162+ if resp.StatusCode != http.StatusOK {
163+ return "", fmt.Errorf("HEAD request returned %d", resp.StatusCode)
190164 }
191165
192- // Fallback: use HTTP headers (ETag or Last-Modified)
166+ // Use ETag or Last-Modified as version identifier
193167 if etag := resp.Header.Get("ETag"); etag != "" {
194- return "etag:" + etag, nil
168+ return etag, nil
195169 }
196170 if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
197- return "lastmod:" + lastMod, nil
171+ return lastMod, nil
198172 }
199173
200- return "", fmt.Errorf("no version info found for filter list")
174+ return "", fmt.Errorf("no ETag or Last-Modified header")
201175 }
202176
203177 // needsUpdate determines if a filter list needs updating based on version comparison
@@ -278,14 +252,12 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
278252 // Calculate content hash
279253 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
280254
281- // Extract version from content or use HTTP headers as fallback
282- version := fu.extractVersion(currentContent)
283- if version == "" {
284- if etagHeader := resp.Header.Get("ETag"); etagHeader != "" {
285- version = "etag:" + etagHeader
286- } else if lastModHeader := resp.Header.Get("Last-Modified"); lastModHeader != "" {
287- version = "lastmod:" + lastModHeader
288- }
255+ // Use HTTP headers as version identifier (same as fetchRemoteVersion)
256+ version := ""
257+ if etag := resp.Header.Get("ETag"); etag != "" {
258+ version = etag
259+ } else if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
260+ version = lastMod
289261 }
290262
291263 // Create update object
@@ -487,22 +459,6 @@ func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
487459 return nil
488460 }
489461
490-// extractVersion extracts the version from filter list content
491-func (fu *FilterUpdater) extractVersion(content []byte) string {
492- scanner := bufio.NewScanner(bytes.NewReader(content))
493- // Only scan first 20 lines for version header
494- for i := 0; i < 20 && scanner.Scan(); i++ {
495- line := scanner.Text()
496- if strings.HasPrefix(line, "! Version:") {
497- version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
498- // Skip placeholder values like %timestamp%
499- if version != "" && !strings.Contains(version, "%") {
500- return version
501- }
502- }
503- }
504- return ""
505-}
506462
507463 // getListID generates a unique ID for a filter list URL
508464 func (fu *FilterUpdater) getListID(url string) string {
@@ -1,12 +1,9 @@
1 package filtering1 package filtering
2 2
3 import (3 import (
4- "bufio"
5- "bytes"
6 "context"4 "context"
7 "crypto/sha256"5 "crypto/sha256"
8 "fmt"6 "fmt"
9- "io"
10 "net/http"7 "net/http"
11 "strings"8 "strings"
12 "sync"9 "sync"
@@ -144,21 +141,17 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi
144 return fu.downloadUpdate(ctx, url, "", "")141 return fu.downloadUpdate(ctx, url, "", "")
145 }142 }
146 143
147-// fetchRemoteVersion fetches only the header of a filter list to extract version144+// fetchRemoteVersion fetches version info using HEAD request (ETag or Last-Modified)
148-// Uses HTTP Range request to minimize bandwidth (fetches ~1KB instead of ~100KB+)
149 func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (string, error) {145 func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (string, error) {
150- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)146+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
151 if err != nil {147 if err != nil {
152 return "", fmt.Errorf("failed to create request: %w", err)148 return "", fmt.Errorf("failed to create request: %w", err)
153 }149 }
154-
155- // Request only first 1024 bytes (enough to get version header)
156- req.Header.Set("Range", "bytes=0-1023")
157 req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")150 req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
158 151
159 resp, err := fu.httpClient.Do(req)152 resp, err := fu.httpClient.Do(req)
160 if err != nil {153 if err != nil {
161- return "", fmt.Errorf("range request failed: %w", err)154+ return "", fmt.Errorf("HEAD request failed: %w", err)
162 }155 }
163 defer func() {156 defer func() {
164 if closeErr := resp.Body.Close(); closeErr != nil {157 if closeErr := resp.Body.Close(); closeErr != nil {
@@ -166,38 +159,19 @@ func (fu *FilterUpdater) fetchRemoteVersion(ctx context.Context, url string) (st
166 }159 }
167 }()160 }()
168 161
169- // Accept both 200 (server ignores Range) and 206 (Partial Content)162+ if resp.StatusCode != http.StatusOK {
170- if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {163+ return "", fmt.Errorf("HEAD request returned %d", resp.StatusCode)
171- return "", fmt.Errorf("request returned %d", resp.StatusCode)
172- }
173-
174- // Read the response (limited to 1KB even if server ignores Range)
175- data, err := io.ReadAll(io.LimitReader(resp.Body, 1024))
176- if err != nil {
177- return "", fmt.Errorf("failed to read response: %w", err)
178- }
179-
180- // Parse version from content header (format: "! Version: 202511291248")
181- scanner := bufio.NewScanner(bytes.NewReader(data))
182- for scanner.Scan() {
183- line := scanner.Text()
184- if strings.HasPrefix(line, "! Version:") {
185- version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
186- if version != "" && !strings.Contains(version, "%") {
187- return version, nil
188- }
189- }
190 }164 }
191 165
192- // Fallback: use HTTP headers (ETag or Last-Modified)166+ // Use ETag or Last-Modified as version identifier
193 if etag := resp.Header.Get("ETag"); etag != "" {167 if etag := resp.Header.Get("ETag"); etag != "" {
194- return "etag:" + etag, nil168+ return etag, nil
195 }169 }
196 if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {170 if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
197- return "lastmod:" + lastMod, nil171+ return lastMod, nil
198 }172 }
199 173
200- return "", fmt.Errorf("no version info found for filter list")174+ return "", fmt.Errorf("no ETag or Last-Modified header")
201 }175 }
202 176
203 // needsUpdate determines if a filter list needs updating based on version comparison177 // needsUpdate determines if a filter list needs updating based on version comparison
@@ -278,14 +252,12 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified,
278 // Calculate content hash252 // Calculate content hash
279 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))253 hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
280 254
281- // Extract version from content or use HTTP headers as fallback255+ // Use HTTP headers as version identifier (same as fetchRemoteVersion)
282- version := fu.extractVersion(currentContent)256+ version := ""
283- if version == "" {257+ if etag := resp.Header.Get("ETag"); etag != "" {
284- if etagHeader := resp.Header.Get("ETag"); etagHeader != "" {258+ version = etag
285- version = "etag:" + etagHeader259+ } else if lastMod := resp.Header.Get("Last-Modified"); lastMod != "" {
286- } else if lastModHeader := resp.Header.Get("Last-Modified"); lastModHeader != "" {260+ version = lastMod
287- version = "lastmod:" + lastModHeader
288- }
289 }261 }
290 262
291 // Create update object263 // Create update object
@@ -487,22 +459,6 @@ func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
487 return nil459 return nil
488 }460 }
489 461
490-// extractVersion extracts the version from filter list content
491-func (fu *FilterUpdater) extractVersion(content []byte) string {
492- scanner := bufio.NewScanner(bytes.NewReader(content))
493- // Only scan first 20 lines for version header
494- for i := 0; i < 20 && scanner.Scan(); i++ {
495- line := scanner.Text()
496- if strings.HasPrefix(line, "! Version:") {
497- version := strings.TrimSpace(strings.TrimPrefix(line, "! Version:"))
498- // Skip placeholder values like %timestamp%
499- if version != "" && !strings.Contains(version, "%") {
500- return version
501- }
502- }
503- }
504- return ""
505-}
506 462
507 // getListID generates a unique ID for a filter list URL463 // getListID generates a unique ID for a filter list URL
508 func (fu *FilterUpdater) getListID(url string) string {464 func (fu *FilterUpdater) getListID(url string) string {
M internal/filtering/updater_test.go +31 -122
@@ -14,53 +14,42 @@ import (
1414 func TestFetchRemoteVersion(t *testing.T) {
1515 tests := []struct {
1616 name string
17- responseBody string
1817 responseStatus int
18+ etag string
19+ lastModified string
1920 wantVersion string
2021 wantErr bool
21- useETag bool
2222 }{
2323 {
24- name: "valid version header",
25- responseBody: `[Adblock Plus 2.0]
26-! Version: 202511291248
27-! Title: EasyList
28-! Last modified: 29 Nov 2025 12:48 UTC
29-`,
24+ name: "returns ETag when present",
3025 responseStatus: http.StatusOK,
31- wantVersion: "202511291248",
26+ etag: `"abc123"`,
27+ wantVersion: `"abc123"`,
3228 wantErr: false,
3329 },
3430 {
35- name: "partial content response (206)",
36- responseBody: `[Adblock Plus 2.0]
37-! Version: 202501011200
38-! Title: Test
39-`,
40- responseStatus: http.StatusPartialContent,
41- wantVersion: "202501011200",
31+ name: "returns Last-Modified when no ETag",
32+ responseStatus: http.StatusOK,
33+ lastModified: "Sat, 29 Nov 2025 12:48:00 GMT",
34+ wantVersion: "Sat, 29 Nov 2025 12:48:00 GMT",
4235 wantErr: false,
4336 },
4437 {
45- name: "placeholder version falls back to ETag",
46- responseBody: `! Title: uBlock filters
47-! Last modified: %timestamp%
48-`,
38+ name: "prefers ETag over Last-Modified",
4939 responseStatus: http.StatusOK,
50- wantVersion: `etag:"abc123"`,
40+ etag: `"xyz789"`,
41+ lastModified: "Sat, 29 Nov 2025 12:48:00 GMT",
42+ wantVersion: `"xyz789"`,
5143 wantErr: false,
52- useETag: true,
5344 },
5445 {
55- name: "no version info returns error",
56- responseBody: `[Adblock Plus 2.0]\n! Title: No Version\n`,
46+ name: "error when no version headers",
5747 responseStatus: http.StatusOK,
5848 wantVersion: "",
5949 wantErr: true,
6050 },
6151 {
6252 name: "server error",
63- responseBody: "",
6453 responseStatus: http.StatusInternalServerError,
6554 wantVersion: "",
6655 wantErr: true,
@@ -69,26 +58,24 @@ func TestFetchRemoteVersion(t *testing.T) {
6958
7059 for _, tt := range tests {
7160 t.Run(tt.name, func(t *testing.T) {
72- // Create test server
7361 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74- // Verify Range header is set
75- rangeHeader := r.Header.Get("Range")
76- assert.Contains(t, rangeHeader, "bytes=0-")
62+ // Verify HEAD request
63+ assert.Equal(t, http.MethodHead, r.Method)
7764
78- if tt.useETag {
79- w.Header().Set("ETag", `"abc123"`)
65+ if tt.etag != "" {
66+ w.Header().Set("ETag", tt.etag)
67+ }
68+ if tt.lastModified != "" {
69+ w.Header().Set("Last-Modified", tt.lastModified)
8070 }
8171 w.WriteHeader(tt.responseStatus)
82- _, _ = w.Write([]byte(tt.responseBody))
8372 }))
8473 defer server.Close()
8574
86- // Create updater with test server
8775 fu := &FilterUpdater{
8876 httpClient: &http.Client{Timeout: 5 * time.Second},
8977 }
9078
91- // Test fetchRemoteVersion
9279 version, err := fu.fetchRemoteVersion(context.Background(), server.URL)
9380
9481 if tt.wantErr {
@@ -101,109 +88,35 @@ func TestFetchRemoteVersion(t *testing.T) {
10188 }
10289 }
10390
104-func TestExtractVersion(t *testing.T) {
105- tests := []struct {
106- name string
107- content []byte
108- want string
109- }{
110- {
111- name: "standard format",
112- content: []byte(`[Adblock Plus 2.0]
113-! Version: 202511291248
114-! Title: EasyList`),
115- want: "202511291248",
116- },
117- {
118- name: "version with spaces",
119- content: []byte(`! Version: 20251129
120-! Title: Test`),
121- want: "20251129",
122- },
123- {
124- name: "no version",
125- content: []byte(`! Title: No Version\n! Author: Test`),
126- want: "",
127- },
128- {
129- name: "placeholder version ignored",
130- content: []byte(`! Title: uBlock filters
131-! Version: %timestamp%
132-! Expires: 5 days`),
133- want: "",
134- },
135- {
136- name: "empty content",
137- content: []byte{},
138- want: "",
139- },
140- {
141- name: "version after 20 lines (not found)",
142- content: []byte(`line1
143-line2
144-line3
145-line4
146-line5
147-line6
148-line7
149-line8
150-line9
151-line10
152-line11
153-line12
154-line13
155-line14
156-line15
157-line16
158-line17
159-line18
160-line19
161-line20
162-line21
163-! Version: 202511291248`),
164- want: "",
165- },
166- }
167-
168- fu := &FilterUpdater{}
169-
170- for _, tt := range tests {
171- t.Run(tt.name, func(t *testing.T) {
172- got := fu.extractVersion(tt.content)
173- assert.Equal(t, tt.want, got)
174- })
175- }
176-}
177-
17891 func TestNeedsUpdate(t *testing.T) {
17992 tests := []struct {
18093 name string
18194 storedVersion string
182- remoteVersion string
95+ remoteETag string
18396 serverError bool
18497 expectedResult bool
18598 }{
18699 {
187- name: "versions differ - needs update",
188- storedVersion: "202511280000",
189- remoteVersion: "202511291248",
100+ name: "etags differ - needs update",
101+ storedVersion: `"old-etag"`,
102+ remoteETag: `"new-etag"`,
190103 expectedResult: true,
191104 },
192105 {
193- name: "versions same - no update",
194- storedVersion: "202511291248",
195- remoteVersion: "202511291248",
106+ name: "etags same - no update",
107+ storedVersion: `"same-etag"`,
108+ remoteETag: `"same-etag"`,
196109 expectedResult: false,
197110 },
198111 {
199112 name: "no stored version - needs update",
200113 storedVersion: "",
201- remoteVersion: "202511291248",
114+ remoteETag: `"any-etag"`,
202115 expectedResult: true,
203116 },
204117 {
205118 name: "server error - assume update needed",
206- storedVersion: "202511280000",
119+ storedVersion: `"old-etag"`,
207120 serverError: true,
208121 expectedResult: true,
209122 },
@@ -211,32 +124,28 @@ func TestNeedsUpdate(t *testing.T) {
211124
212125 for _, tt := range tests {
213126 t.Run(tt.name, func(t *testing.T) {
214- // Create test server
215127 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216128 if tt.serverError {
217129 w.WriteHeader(http.StatusInternalServerError)
218130 return
219131 }
132+ w.Header().Set("ETag", tt.remoteETag)
220133 w.WriteHeader(http.StatusOK)
221- _, _ = w.Write([]byte("! Version: " + tt.remoteVersion + "\n"))
222134 }))
223135 defer server.Close()
224136
225- // Create mock store
226137 mockStore := &mockFilterStore{
227138 sourceVersions: map[string]string{
228139 server.URL: tt.storedVersion,
229140 },
230141 }
231142
232- // Create manager and updater
233143 fm := &FilterManager{store: mockStore}
234144 fu := &FilterUpdater{
235145 manager: fm,
236146 httpClient: &http.Client{Timeout: 5 * time.Second},
237147 }
238148
239- // Test needsUpdate
240149 result := fu.needsUpdate(context.Background(), server.URL)
241150 assert.Equal(t, tt.expectedResult, result)
242151 })
@@ -14,53 +14,42 @@ import (
14 func TestFetchRemoteVersion(t *testing.T) {14 func TestFetchRemoteVersion(t *testing.T) {
15 tests := []struct {15 tests := []struct {
16 name string16 name string
17- responseBody string
18 responseStatus int17 responseStatus int
18+ etag string
19+ lastModified string
19 wantVersion string20 wantVersion string
20 wantErr bool21 wantErr bool
21- useETag bool
22 }{22 }{
23 {23 {
24- name: "valid version header",24+ name: "returns ETag when present",
25- responseBody: `[Adblock Plus 2.0]
26-! Version: 202511291248
27-! Title: EasyList
28-! Last modified: 29 Nov 2025 12:48 UTC
29-`,
30 responseStatus: http.StatusOK,25 responseStatus: http.StatusOK,
31- wantVersion: "202511291248",26+ etag: `"abc123"`,
27+ wantVersion: `"abc123"`,
32 wantErr: false,28 wantErr: false,
33 },29 },
34 {30 {
35- name: "partial content response (206)",31+ name: "returns Last-Modified when no ETag",
36- responseBody: `[Adblock Plus 2.0]32+ responseStatus: http.StatusOK,
37-! Version: 20250101120033+ lastModified: "Sat, 29 Nov 2025 12:48:00 GMT",
38-! Title: Test34+ wantVersion: "Sat, 29 Nov 2025 12:48:00 GMT",
39-`,
40- responseStatus: http.StatusPartialContent,
41- wantVersion: "202501011200",
42 wantErr: false,35 wantErr: false,
43 },36 },
44 {37 {
45- name: "placeholder version falls back to ETag",38+ name: "prefers ETag over Last-Modified",
46- responseBody: `! Title: uBlock filters
47-! Last modified: %timestamp%
48-`,
49 responseStatus: http.StatusOK,39 responseStatus: http.StatusOK,
50- wantVersion: `etag:"abc123"`,40+ etag: `"xyz789"`,
41+ lastModified: "Sat, 29 Nov 2025 12:48:00 GMT",
42+ wantVersion: `"xyz789"`,
51 wantErr: false,43 wantErr: false,
52- useETag: true,
53 },44 },
54 {45 {
55- name: "no version info returns error",46+ name: "error when no version headers",
56- responseBody: `[Adblock Plus 2.0]\n! Title: No Version\n`,
57 responseStatus: http.StatusOK,47 responseStatus: http.StatusOK,
58 wantVersion: "",48 wantVersion: "",
59 wantErr: true,49 wantErr: true,
60 },50 },
61 {51 {
62 name: "server error",52 name: "server error",
63- responseBody: "",
64 responseStatus: http.StatusInternalServerError,53 responseStatus: http.StatusInternalServerError,
65 wantVersion: "",54 wantVersion: "",
66 wantErr: true,55 wantErr: true,
@@ -69,26 +58,24 @@ func TestFetchRemoteVersion(t *testing.T) {
69 58
70 for _, tt := range tests {59 for _, tt := range tests {
71 t.Run(tt.name, func(t *testing.T) {60 t.Run(tt.name, func(t *testing.T) {
72- // Create test server
73 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {61 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
74- // Verify Range header is set62+ // Verify HEAD request
75- rangeHeader := r.Header.Get("Range")63+ assert.Equal(t, http.MethodHead, r.Method)
76- assert.Contains(t, rangeHeader, "bytes=0-")
77 64
78- if tt.useETag {65+ if tt.etag != "" {
79- w.Header().Set("ETag", `"abc123"`)66+ w.Header().Set("ETag", tt.etag)
67+ }
68+ if tt.lastModified != "" {
69+ w.Header().Set("Last-Modified", tt.lastModified)
80 }70 }
81 w.WriteHeader(tt.responseStatus)71 w.WriteHeader(tt.responseStatus)
82- _, _ = w.Write([]byte(tt.responseBody))
83 }))72 }))
84 defer server.Close()73 defer server.Close()
85 74
86- // Create updater with test server
87 fu := &FilterUpdater{75 fu := &FilterUpdater{
88 httpClient: &http.Client{Timeout: 5 * time.Second},76 httpClient: &http.Client{Timeout: 5 * time.Second},
89 }77 }
90 78
91- // Test fetchRemoteVersion
92 version, err := fu.fetchRemoteVersion(context.Background(), server.URL)79 version, err := fu.fetchRemoteVersion(context.Background(), server.URL)
93 80
94 if tt.wantErr {81 if tt.wantErr {
@@ -101,109 +88,35 @@ func TestFetchRemoteVersion(t *testing.T) {
101 }88 }
102 }89 }
103 90
104-func TestExtractVersion(t *testing.T) {
105- tests := []struct {
106- name string
107- content []byte
108- want string
109- }{
110- {
111- name: "standard format",
112- content: []byte(`[Adblock Plus 2.0]
113-! Version: 202511291248
114-! Title: EasyList`),
115- want: "202511291248",
116- },
117- {
118- name: "version with spaces",
119- content: []byte(`! Version: 20251129
120-! Title: Test`),
121- want: "20251129",
122- },
123- {
124- name: "no version",
125- content: []byte(`! Title: No Version\n! Author: Test`),
126- want: "",
127- },
128- {
129- name: "placeholder version ignored",
130- content: []byte(`! Title: uBlock filters
131-! Version: %timestamp%
132-! Expires: 5 days`),
133- want: "",
134- },
135- {
136- name: "empty content",
137- content: []byte{},
138- want: "",
139- },
140- {
141- name: "version after 20 lines (not found)",
142- content: []byte(`line1
143-line2
144-line3
145-line4
146-line5
147-line6
148-line7
149-line8
150-line9
151-line10
152-line11
153-line12
154-line13
155-line14
156-line15
157-line16
158-line17
159-line18
160-line19
161-line20
162-line21
163-! Version: 202511291248`),
164- want: "",
165- },
166- }
167-
168- fu := &FilterUpdater{}
169-
170- for _, tt := range tests {
171- t.Run(tt.name, func(t *testing.T) {
172- got := fu.extractVersion(tt.content)
173- assert.Equal(t, tt.want, got)
174- })
175- }
176-}
177-
178 func TestNeedsUpdate(t *testing.T) {91 func TestNeedsUpdate(t *testing.T) {
179 tests := []struct {92 tests := []struct {
180 name string93 name string
181 storedVersion string94 storedVersion string
182- remoteVersion string95+ remoteETag string
183 serverError bool96 serverError bool
184 expectedResult bool97 expectedResult bool
185 }{98 }{
186 {99 {
187- name: "versions differ - needs update",100+ name: "etags differ - needs update",
188- storedVersion: "202511280000",101+ storedVersion: `"old-etag"`,
189- remoteVersion: "202511291248",102+ remoteETag: `"new-etag"`,
190 expectedResult: true,103 expectedResult: true,
191 },104 },
192 {105 {
193- name: "versions same - no update",106+ name: "etags same - no update",
194- storedVersion: "202511291248",107+ storedVersion: `"same-etag"`,
195- remoteVersion: "202511291248",108+ remoteETag: `"same-etag"`,
196 expectedResult: false,109 expectedResult: false,
197 },110 },
198 {111 {
199 name: "no stored version - needs update",112 name: "no stored version - needs update",
200 storedVersion: "",113 storedVersion: "",
201- remoteVersion: "202511291248",114+ remoteETag: `"any-etag"`,
202 expectedResult: true,115 expectedResult: true,
203 },116 },
204 {117 {
205 name: "server error - assume update needed",118 name: "server error - assume update needed",
206- storedVersion: "202511280000",119+ storedVersion: `"old-etag"`,
207 serverError: true,120 serverError: true,
208 expectedResult: true,121 expectedResult: true,
209 },122 },
@@ -211,32 +124,28 @@ func TestNeedsUpdate(t *testing.T) {
211 124
212 for _, tt := range tests {125 for _, tt := range tests {
213 t.Run(tt.name, func(t *testing.T) {126 t.Run(tt.name, func(t *testing.T) {
214- // Create test server
215 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {127 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216 if tt.serverError {128 if tt.serverError {
217 w.WriteHeader(http.StatusInternalServerError)129 w.WriteHeader(http.StatusInternalServerError)
218 return130 return
219 }131 }
132+ w.Header().Set("ETag", tt.remoteETag)
220 w.WriteHeader(http.StatusOK)133 w.WriteHeader(http.StatusOK)
221- _, _ = w.Write([]byte("! Version: " + tt.remoteVersion + "\n"))
222 }))134 }))
223 defer server.Close()135 defer server.Close()
224 136
225- // Create mock store
226 mockStore := &mockFilterStore{137 mockStore := &mockFilterStore{
227 sourceVersions: map[string]string{138 sourceVersions: map[string]string{
228 server.URL: tt.storedVersion,139 server.URL: tt.storedVersion,
229 },140 },
230 }141 }
231 142
232- // Create manager and updater
233 fm := &FilterManager{store: mockStore}143 fm := &FilterManager{store: mockStore}
234 fu := &FilterUpdater{144 fu := &FilterUpdater{
235 manager: fm,145 manager: fm,
236 httpClient: &http.Client{Timeout: 5 * time.Second},146 httpClient: &http.Client{Timeout: 5 * time.Second},
237 }147 }
238 148
239- // Test needsUpdate
240 result := fu.needsUpdate(context.Background(), server.URL)149 result := fu.needsUpdate(context.Background(), server.URL)
241 assert.Equal(t, tt.expectedResult, result)150 assert.Equal(t, tt.expectedResult, result)
242 })151 })

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close