1package cef
2
3import (
4 "context"
5 "fmt"
6 "path/filepath"
7 "testing"
8
9 purecef "github.com/bnema/purego-cef/cef"
10 cefmocks "github.com/bnema/purego-cef/cef/mocks"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/application/usecase"
15)
16
17type stubDownloadItem struct {
18 id uint32
19 url string
20 suggested string
21 mimeType string
22 fullPath string
23 complete bool
24 canceled bool
25 interrupted bool
26 reason purecef.DownloadInterruptReason
27 percentComplete int32
28 totalBytes int64
29 receivedBytes int64
30}
31
32func (s stubDownloadItem) IsValid() bool { return true }
33func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted }
34func (s stubDownloadItem) IsComplete() bool { return s.complete }
35func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
36func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
37func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
38 return s.reason
39}
40func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
41func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete }
42func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes }
43func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes }
44func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
45func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
46func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
47func (s stubDownloadItem) GetID() uint32 { return s.id }
48func (s stubDownloadItem) GetURL() string { return s.url }
49func (s stubDownloadItem) GetOriginalURL() string { return s.url }
50func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested }
51func (s stubDownloadItem) GetContentDisposition() string { return "" }
52func (s stubDownloadItem) GetMimeType() string { return s.mimeType }
53func (s stubDownloadItem) IsPaused() bool { return false }
54
55type stubBeforeDownloadCallback struct {
56 path string
57 showDialog int32
58}
59
60func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
61 s.path = downloadPath
62 s.showDialog = showDialog
63}
64
65type captureDownloadEvents struct {
66 events []port.DownloadEvent
67}
68
69func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
70 c.events = append(c.events, event)
71}
72
73func TestCEFDownloadHandlerLifecycle(t *testing.T) {
74 ctx := context.Background()
75 preparer := usecase.NewPrepareDownloadUseCase(nil)
76 events := &captureDownloadEvents{}
77 downloadDir := t.TempDir()
78 handler := newDownloadHandler(downloadDir, events, preparer, nil)
79 callback := &stubBeforeDownloadCallback{}
80
81 item := stubDownloadItem{
82 id: 7,
83 url: "https://example.com/image.iso",
84 suggested: "image.iso",
85 mimeType: "application/x-iso9660-image",
86 }
87
88 ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
89
90 require.True(t, ok)
91 require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path)
92 require.Len(t, events.events, 1)
93 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
94
95 item.complete = true
96 item.fullPath = callback.path
97 handler.onDownloadUpdated(ctx, nil, item, nil)
98 // Repeated completion updates should not emit duplicate finished events.
99 handler.onDownloadUpdated(ctx, nil, item, nil)
100
101 require.Len(t, events.events, 2)
102 require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
103 require.Equal(t, "image.iso", events.events[1].Filename)
104
105 // Active map should be cleaned up after terminal event.
106 handler.mu.Lock()
107 _, inActive := handler.active[7]
108 _, inFinished := handler.finished[7]
109 handler.mu.Unlock()
110 require.False(t, inActive, "completed download should be removed from active map")
111 require.True(t, inFinished, "completed download should be in finished set")
112}
113
114func TestCanDownload_AllowsAllMethods(t *testing.T) {
115 preparer := usecase.NewPrepareDownloadUseCase(nil)
116 handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
117
118 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
119 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
120 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
121 require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
122}
123
124func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
125 preparer := usecase.NewPrepareDownloadUseCase(nil)
126 handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
127
128 // Simulate a download that was tracked through onBeforeDownload.
129 handler.mu.Lock()
130 handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
131 handler.mu.Unlock()
132
133 require.True(t, handler.markFinished(42), "first terminal event should succeed")
134
135 // Simulate a concurrent update re-populating active before a duplicate terminal update.
136 handler.mu.Lock()
137 handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
138 handler.mu.Unlock()
139
140 require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
141
142 // Active map should not contain the entry.
143 handler.mu.Lock()
144 _, inActive := handler.active[42]
145 handler.mu.Unlock()
146 require.False(t, inActive)
147}
148
149func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
150 ctx := context.Background()
151 preparer := usecase.NewPrepareDownloadUseCase(nil)
152 events := &captureDownloadEvents{}
153 handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
154 callback := &stubBeforeDownloadCallback{}
155
156 item := stubDownloadItem{
157 id: 11,
158 url: "https://example.com/archive.zip",
159 suggested: "archive.zip",
160 mimeType: "application/zip",
161 }
162
163 require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
164
165 item.fullPath = callback.path
166 item.percentComplete = 12
167 item.receivedBytes = 12
168 item.totalBytes = 100
169 handler.onDownloadUpdated(ctx, nil, item, nil)
170 handler.onDownloadUpdated(ctx, nil, item, nil)
171
172 item.percentComplete = 13
173 item.receivedBytes = 13
174 handler.onDownloadUpdated(ctx, nil, item, nil)
175
176 require.Len(t, events.events, 3)
177 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
178 require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
179 require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
180 require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
181 require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
182}
183
184func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
185 ctx := context.Background()
186 preparer := usecase.NewPrepareDownloadUseCase(nil)
187 events := &captureDownloadEvents{}
188 handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
189 callback := &stubBeforeDownloadCallback{}
190
191 item := stubDownloadItem{
192 id: 9,
193 url: "https://example.com/ubuntu.iso",
194 suggested: "ubuntu.iso",
195 mimeType: "application/x-iso9660-image",
196 }
197
198 require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
199
200 item.fullPath = callback.path
201 item.interrupted = true
202 item.reason = purecef.DownloadInterruptReasonServerBadContent
203 handler.onDownloadUpdated(ctx, nil, item, nil)
204
205 require.Len(t, events.events, 2)
206 require.Error(t, events.events[1].Error)
207 expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent)
208 require.ErrorContains(t, events.events[1].Error, expectedCode)
209 require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
210}
211
212func TestDownloadHandlerTracksOwnersByBrowserAndID(t *testing.T) {
213 ctx := context.Background()
214 preparer := usecase.NewPrepareDownloadUseCase(nil)
215 events := &captureDownloadEvents{}
216 tracker := NewRuntimeActivityTracker()
217 handler := newDownloadHandler(t.TempDir(), events, preparer, tracker)
218 callback := &stubBeforeDownloadCallback{}
219
220 first := cefmocks.NewMockBrowser(t)
221 first.EXPECT().GetIdentifier().Return(int32(7))
222 second := cefmocks.NewMockBrowser(t)
223 second.EXPECT().GetIdentifier().Return(int32(9))
224
225 // Same filename from two owners are distinct activity entries.
226 require.True(t, handler.onBeforeDownload(ctx, first,
227 stubDownloadItem{id: 11, url: "https://example.com/a.bin", suggested: "a.bin"}, "a.bin", callback))
228 require.True(t, handler.onBeforeDownload(ctx, second,
229 stubDownloadItem{id: 12, url: "https://other.example/a.bin", suggested: "a.bin"}, "a.bin", callback))
230 require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
231
232 // Terminal for one ID leaves the other owner outstanding.
233 handler.onDownloadUpdated(ctx, first, stubDownloadItem{id: 11, complete: true, fullPath: "/tmp/a.bin"}, nil)
234 require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
235
236 // Lost owners reconcile through the native lifecycle, never removal.
237 tracker.DropBrowser(9)
238 require.True(t, tracker.Snapshot().Quiescent())
239}