1package systemviewsbridge
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 systemviewsbridgemocks "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge/mocks"
13 "github.com/stretchr/testify/mock"
14)
15
16func TestBuildMessageEnvelope(t *testing.T) {
17 t.Parallel()
18
19 payload := struct {
20 RequestID string `json:"requestId"`
21 Limit int `json:"limit"`
22 }{RequestID: "req-1", Limit: 25}
23
24 got, err := buildMessageEnvelope("history_timeline", payload)
25 if err != nil {
26 t.Fatalf("buildMessageEnvelope() error = %v", err)
27 }
28
29 var msg port.WebUIMessage
30 if err := json.Unmarshal(got, &msg); err != nil {
31 t.Fatalf("unmarshal envelope: %v", err)
32 }
33
34 if msg.Type != "history_timeline" {
35 t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
36 }
37
38 if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
39 t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
40 }
41}
42
43func TestClientSendPrefersNativeTransport(t *testing.T) {
44 t.Parallel()
45
46 native := newTransportRecorder(t, true, []byte(`{"transport":"native"}`))
47 fetch := newTransportRecorder(t, true, []byte(`{"transport":"fetch"}`))
48
49 client := NewClient(native, fetch)
50 got, err := client.Send(context.Background(), "favorite_list", struct {
51 RequestID string `json:"requestId"`
52 }{RequestID: "req-2"})
53 if err != nil {
54 t.Fatalf("Send() error = %v", err)
55 }
56
57 if string(got) != `{"transport":"native"}` {
58 t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
59 }
60
61 if !native.called {
62 t.Fatal("native transport was not used")
63 }
64 if fetch.called {
65 t.Fatal("fetch transport was used unexpectedly")
66 }
67
68 var msg port.WebUIMessage
69 if err := json.Unmarshal(native.last, &msg); err != nil {
70 t.Fatalf("unmarshal sent envelope: %v", err)
71 }
72 if msg.Type != "favorite_list" {
73 t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
74 }
75}
76
77func TestClientSendFallsBackToFetchTransport(t *testing.T) {
78 t.Parallel()
79
80 native := newTransportRecorder(t, false, []byte(`{"transport":"native"}`))
81 fetch := newTransportRecorder(t, true, []byte(`{"transport":"fetch"}`))
82
83 client := NewClient(native, fetch)
84 got, err := client.Send(context.Background(), "favorite_list", struct {
85 RequestID string `json:"requestId"`
86 }{RequestID: "req-3"})
87 if err != nil {
88 t.Fatalf("Send() error = %v", err)
89 }
90
91 if string(got) != `{"transport":"fetch"}` {
92 t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
93 }
94
95 if native.called {
96 t.Fatal("native transport was used unexpectedly")
97 }
98 if !fetch.called {
99 t.Fatal("fetch transport was not used")
100 }
101}
102
103func TestClientTimelineDecodesEntries(t *testing.T) {
104 t.Parallel()
105
106 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`))
107 client := NewClient(native, nil)
108
109 entries, err := client.Timeline(context.Background(), 25, 0)
110 if err != nil {
111 t.Fatalf("Timeline() error = %v", err)
112 }
113 if len(entries) != 1 {
114 t.Fatalf("Timeline() len = %d, want 1", len(entries))
115 }
116 if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
117 t.Fatalf("Timeline() entry = %+v", entries[0])
118 }
119
120 var msg port.WebUIMessage
121 if err := json.Unmarshal(native.last, &msg); err != nil {
122 t.Fatalf("unmarshal sent envelope: %v", err)
123 }
124 if msg.Type != "history_timeline" {
125 t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
126 }
127 if gotPayload := string(msg.Payload); gotPayload == "" {
128 t.Fatal("sent payload was empty")
129 }
130}
131
132func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
133 t.Parallel()
134
135 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
136 client := NewClient(native, nil)
137
138 _, err := client.TimelineWindow(context.Background(), time.Time{}, 0, "")
139 if err != nil {
140 t.Fatalf("TimelineWindow() error = %v", err)
141 }
142
143 var msg port.WebUIMessage
144 if err := json.Unmarshal(native.last, &msg); err != nil {
145 t.Fatalf("unmarshal sent envelope: %v", err)
146 }
147 if msg.Type != "history_timeline_window" {
148 t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window")
149 }
150 var payload map[string]any
151 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
152 t.Fatalf("unmarshal payload: %v", err)
153 }
154 if _, ok := payload["before"]; ok {
155 t.Fatalf("zero TimelineWindow cursor should be omit before, payload = %s", msg.Payload)
156 }
157 if _, ok := payload["beforeId"]; ok {
158 t.Fatalf("zero TimelineWindow cursor should omit beforeId, payload = %s", msg.Payload)
159 }
160}
161
162func TestClientTimelineWindowSendsCompleteCursor(t *testing.T) {
163 t.Parallel()
164
165 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
166 client := NewClient(native, nil)
167 before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
168
169 _, err := client.TimelineWindow(context.Background(), before, 42, "example.com")
170 if err != nil {
171 t.Fatalf("TimelineWindow() error = %v", err)
172 }
173
174 var msg port.WebUIMessage
175 if err := json.Unmarshal(native.last, &msg); err != nil {
176 t.Fatalf("unmarshal sent envelope: %v", err)
177 }
178 var payload map[string]any
179 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
180 t.Fatalf("unmarshal payload: %v", err)
181 }
182 if payload["before"] != before.Format(time.RFC3339Nano) {
183 t.Fatalf("before = %v, want %s", payload["before"], before.Format(time.RFC3339Nano))
184 }
185 if payload["beforeId"] != float64(42) {
186 t.Fatalf("beforeId = %v, want 42", payload["beforeId"])
187 }
188}
189
190func TestClientTimelineWindowRejectsPartialCursor(t *testing.T) {
191 t.Parallel()
192
193 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-window","success":true}`))
194 client := NewClient(native, nil)
195 before := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
196
197 if _, err := client.TimelineWindow(context.Background(), before, 0, ""); err == nil {
198 t.Fatal("TimelineWindow(before without beforeID) error = nil")
199 }
200 if _, err := client.TimelineWindow(context.Background(), time.Time{}, 42, ""); err == nil {
201 t.Fatal("TimelineWindow(beforeID without before) error = nil")
202 }
203 if _, err := client.TimelineWindow(context.Background(), time.Time{}, -1, ""); err == nil {
204 t.Fatal("TimelineWindow(negative beforeID without before) error = nil")
205 }
206 if native.called {
207 t.Fatal("TimelineWindow sent request for invalid partial cursor")
208 }
209}
210
211func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
212 t.Parallel()
213
214 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-range-empty","success":true}`))
215 client := NewClient(transport, nil)
216
217 err := client.DeleteRange(context.Background(), " ")
218 if err == nil {
219 t.Fatal("DeleteRange() error = nil, want validation error")
220 }
221 if transport.called {
222 t.Fatal("DeleteRange() sent a request for an invalid range")
223 }
224}
225
226func TestClientDeleteRangeSendsRange(t *testing.T) {
227 t.Parallel()
228
229 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-10","success":true}`))
230 client := NewClient(native, nil)
231
232 if err := client.DeleteRange(context.Background(), "week"); err != nil {
233 t.Fatalf("DeleteRange() error = %v", err)
234 }
235
236 var msg port.WebUIMessage
237 if err := json.Unmarshal(native.last, &msg); err != nil {
238 t.Fatalf("unmarshal sent envelope: %v", err)
239 }
240 if msg.Type != "history_delete_range" {
241 t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
242 }
243}
244
245func TestClientListDecodesFavorites(t *testing.T) {
246 t.Parallel()
247
248 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`))
249 client := NewClient(native, nil)
250
251 favorites, err := client.List(context.Background())
252 if err != nil {
253 t.Fatalf("List() error = %v", err)
254 }
255 if len(favorites) != 1 {
256 t.Fatalf("List() len = %d, want 1", len(favorites))
257 }
258 if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
259 t.Fatalf("List() favorite = %+v", favorites[0])
260 }
261
262 var msg port.WebUIMessage
263 if err := json.Unmarshal(native.last, &msg); err != nil {
264 t.Fatalf("unmarshal sent envelope: %v", err)
265 }
266 if msg.Type != "favorite_list" {
267 t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
268 }
269}
270
271func TestClientListFoldersDecodesFolders(t *testing.T) {
272 t.Parallel()
273
274 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`))
275 client := NewClient(native, nil)
276
277 folders, err := client.ListFolders(context.Background())
278 if err != nil {
279 t.Fatalf("ListFolders() error = %v", err)
280 }
281 if len(folders) != 1 {
282 t.Fatalf("ListFolders() len = %d, want 1", len(folders))
283 }
284 if folders[0].Name != "Read Later" {
285 t.Fatalf("ListFolders() folder = %+v", folders[0])
286 }
287
288 var msg port.WebUIMessage
289 if err := json.Unmarshal(native.last, &msg); err != nil {
290 t.Fatalf("unmarshal sent envelope: %v", err)
291 }
292 if msg.Type != "folder_list" {
293 t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
294 }
295}
296
297func TestClientListTagsDecodesTags(t *testing.T) {
298 t.Parallel()
299
300 native := newTransportRecorder(t, true, []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`))
301 client := NewClient(native, nil)
302
303 tags, err := client.ListTags(context.Background())
304 if err != nil {
305 t.Fatalf("ListTags() error = %v", err)
306 }
307 if len(tags) != 1 {
308 t.Fatalf("ListTags() len = %d, want 1", len(tags))
309 }
310 if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
311 t.Fatalf("ListTags() tag = %+v", tags[0])
312 }
313
314 var msg port.WebUIMessage
315 if err := json.Unmarshal(native.last, &msg); err != nil {
316 t.Fatalf("unmarshal sent envelope: %v", err)
317 }
318 if msg.Type != "tag_list" {
319 t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
320 }
321}
322
323func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
324 t.Parallel()
325
326 resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
327
328 currentTransport := newTransportRecorder(t, true, resp)
329 currentClient := NewClient(currentTransport, nil)
330 current, err := currentClient.Current(context.Background())
331 if err != nil {
332 t.Fatalf("Current() error = %v", err)
333 }
334 if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
335 t.Fatalf("Current() = %+v", current)
336 }
337
338 var msg port.WebUIMessage
339 if unmarshalErr := json.Unmarshal(currentTransport.last, &msg); unmarshalErr != nil {
340 t.Fatalf("unmarshal sent envelope: %v", unmarshalErr)
341 }
342 if msg.Type != "/api/config" {
343 t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
344 }
345
346 defaultTransport := newTransportRecorder(t, true, resp)
347 defaultClient := NewClient(defaultTransport, nil)
348 defaultCfg, err := defaultClient.Default(context.Background())
349 if err != nil {
350 t.Fatalf("Default() error = %v", err)
351 }
352 if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
353 t.Fatalf("Default() = %+v", defaultCfg)
354 }
355
356 if err := json.Unmarshal(defaultTransport.last, &msg); err != nil {
357 t.Fatalf("unmarshal sent envelope: %v", err)
358 }
359 if msg.Type != "/api/config/default" {
360 t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
361 }
362}
363
364func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
365 t.Parallel()
366
367 tests := []struct {
368 name string
369 call func(*testing.T, *Client) error
370 want string
371 payload any
372 }{
373 {
374 name: "save config",
375 call: func(_ *testing.T, client *Client) error {
376 return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
377 },
378 want: "save_config",
379 payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
380 },
381 {
382 name: "get keybindings",
383 call: func(t *testing.T, client *Client) error {
384 got, err := client.GetKeybindings(context.Background())
385 if err != nil {
386 return err
387 }
388 if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
389 t.Fatalf("GetKeybindings() = %#v", got)
390 }
391 return nil
392 },
393 want: "get_keybindings",
394 },
395 {
396 name: "set keybinding",
397 call: func(t *testing.T, client *Client) error {
398 got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
399 if err != nil {
400 return err
401 }
402 if len(got.Conflicts) != 0 {
403 t.Fatalf("SetKeybinding() = %#v", got)
404 }
405 return nil
406 },
407 want: "set_keybinding",
408 payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
409 },
410 {
411 name: "reset keybinding",
412 call: func(_ *testing.T, client *Client) error {
413 return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
414 },
415 want: "reset_keybinding",
416 payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
417 },
418 {
419 name: "reset all keybindings",
420 call: func(_ *testing.T, client *Client) error {
421 return client.ResetAllKeybindings(context.Background())
422 },
423 want: "reset_all_keybindings",
424 },
425 }
426
427 for _, tt := range tests {
428 t.Run(tt.name, func(t *testing.T) {
429 t.Parallel()
430
431 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`))
432 if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
433 transport.response = []byte(`{"requestId":"req-15","success":true}`)
434 }
435 if tt.name == "get keybindings" {
436 transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
437 }
438 client := NewClient(transport, nil)
439
440 if err := tt.call(t, client); err != nil {
441 t.Fatalf("call() error = %v", err)
442 }
443
444 var msg port.WebUIMessage
445 if err := json.Unmarshal(transport.last, &msg); err != nil {
446 t.Fatalf("unmarshal sent envelope: %v", err)
447 }
448 if msg.Type != tt.want {
449 t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
450 }
451 if tt.payload != nil && len(msg.Payload) == 0 {
452 t.Fatal("sent payload was empty")
453 }
454 })
455 }
456}
457
458func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
459 t.Parallel()
460
461 transport := &blockingTransport{available: true}
462 client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
463
464 _, err := client.Timeline(context.Background(), 25, 0)
465 if !errors.Is(err, context.DeadlineExceeded) {
466 t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
467 }
468 if !transport.called {
469 t.Fatal("transport was not called")
470 }
471}
472
473func TestClientCreateFolderIncludesParentID(t *testing.T) {
474 t.Parallel()
475
476 parentID := int64(42)
477 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`))
478 client := NewClient(transport, nil)
479
480 folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
481 if err != nil {
482 t.Fatalf("CreateFolder() error = %v", err)
483 }
484 if folder == nil {
485 t.Fatal("CreateFolder() returned nil folder")
486 }
487 if folder.Name != "Nested" {
488 t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
489 }
490 if folder.Icon != "📁" {
491 t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
492 }
493 if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
494 t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
495 }
496
497 var msg port.WebUIMessage
498 if err := json.Unmarshal(transport.last, &msg); err != nil {
499 t.Fatalf("unmarshal sent envelope: %v", err)
500 }
501 if msg.Type != "folder_create" {
502 t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
503 }
504
505 var payload struct {
506 RequestID string `json:"requestId"`
507 Name string `json:"name"`
508 Icon *string `json:"icon"`
509 ParentID *int64 `json:"parent_id"`
510 }
511 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
512 t.Fatalf("unmarshal sent payload: %v", err)
513 }
514 if payload.ParentID == nil || *payload.ParentID != parentID {
515 t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
516 }
517 if payload.Name != "Nested" {
518 t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
519 }
520 if payload.Icon == nil || *payload.Icon != "📁" {
521 t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
522 }
523 if payload.RequestID == "" {
524 t.Fatal("sent requestId was empty")
525 }
526}
527
528func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
529 t.Parallel()
530
531 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-17","success":true,"data":[]}`))
532 client := NewClient(transport, nil)
533
534 _, err := client.DomainStats(context.Background(), -1)
535 if err == nil {
536 t.Fatal("DomainStats() error = nil, want validation error")
537 }
538 if transport.called {
539 t.Fatal("DomainStats() sent a request for a negative limit")
540 }
541}
542
543func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
544 t.Parallel()
545
546 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-domain-empty","success":true}`))
547 client := NewClient(transport, nil)
548
549 err := client.DeleteDomain(context.Background(), " ")
550 if err == nil {
551 t.Fatal("DeleteDomain() error = nil, want validation error")
552 }
553 if transport.called {
554 t.Fatal("DeleteDomain() sent a request for an empty domain")
555 }
556}
557
558func TestClientUpdateFolderTrimsIcon(t *testing.T) {
559 t.Parallel()
560
561 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-18","success":true}`))
562 client := NewClient(transport, nil)
563
564 if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil {
565 t.Fatalf("UpdateFolder() error = %v", err)
566 }
567
568 var msg port.WebUIMessage
569 if err := json.Unmarshal(transport.last, &msg); err != nil {
570 t.Fatalf("unmarshal sent envelope: %v", err)
571 }
572 if msg.Type != "folder_update" {
573 t.Fatalf("sent type = %q, want folder_update", msg.Type)
574 }
575 var payload struct {
576 Icon *string `json:"icon"`
577 }
578 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
579 t.Fatalf("unmarshal sent payload: %v", err)
580 }
581 if payload.Icon == nil || *payload.Icon != "📚" {
582 t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
583 }
584}
585
586func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
587 t.Parallel()
588
589 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-19","success":true}`))
590 client := NewClient(transport, nil)
591
592 if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil {
593 t.Fatalf("UpdateFolder() error = %v", err)
594 }
595
596 var msg port.WebUIMessage
597 if err := json.Unmarshal(transport.last, &msg); err != nil {
598 t.Fatalf("unmarshal sent envelope: %v", err)
599 }
600 var payload struct {
601 Icon *string `json:"icon"`
602 }
603 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
604 t.Fatalf("unmarshal sent payload: %v", err)
605 }
606 if payload.Icon != nil {
607 t.Fatalf("sent icon = %#v, want nil", payload.Icon)
608 }
609}
610
611func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
612 t.Parallel()
613
614 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req-20","success":true}`))
615 client := NewClient(transport, nil)
616
617 err := client.DeleteEntry(context.Background(), 0)
618 if err == nil {
619 t.Fatal("DeleteEntry() error = nil, want validation error")
620 }
621 if transport.called {
622 t.Fatal("DeleteEntry() sent a request for an invalid id")
623 }
624}
625
626func TestClientTagActionsTrimFields(t *testing.T) {
627 t.Parallel()
628
629 createTransport := newTransportRecorder(t, true, []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`))
630 client := NewClient(createTransport, nil)
631 if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil {
632 t.Fatalf("CreateTag() error = %v", err)
633 }
634 var msg port.WebUIMessage
635 if err := json.Unmarshal(createTransport.last, &msg); err != nil {
636 t.Fatalf("unmarshal create envelope: %v", err)
637 }
638 var createPayload struct {
639 Name string `json:"name"`
640 Color *string `json:"color"`
641 }
642 if err := json.Unmarshal(msg.Payload, &createPayload); err != nil {
643 t.Fatalf("unmarshal create payload: %v", err)
644 }
645 if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" {
646 t.Fatalf("CreateTag payload = %#v", createPayload)
647 }
648
649 updateTransport := newTransportRecorder(t, true, []byte(`{"requestId":"req-22","success":true}`))
650 client = NewClient(updateTransport, nil)
651 if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil {
652 t.Fatalf("UpdateTag() error = %v", err)
653 }
654 if err := json.Unmarshal(updateTransport.last, &msg); err != nil {
655 t.Fatalf("unmarshal update envelope: %v", err)
656 }
657 var updatePayload struct {
658 Name *string `json:"name"`
659 Color *string `json:"color"`
660 }
661 if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil {
662 t.Fatalf("unmarshal update payload: %v", err)
663 }
664 if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil {
665 t.Fatalf("UpdateTag payload = %#v", updatePayload)
666 }
667}
668
669type transportRecorder struct {
670 *systemviewsbridgemocks.MockTransport
671
672 available bool
673 called bool
674 last []byte
675 response []byte
676}
677
678func newTransportRecorder(t *testing.T, available bool, response []byte) *transportRecorder {
679 t.Helper()
680
681 recorder := &transportRecorder{
682 MockTransport: systemviewsbridgemocks.NewMockTransport(t),
683 available: available,
684 response: response,
685 }
686 recorder.EXPECT().Available().RunAndReturn(func() bool { return recorder.available }).Maybe()
687 recorder.EXPECT().Send(mock.Anything, mock.Anything).RunAndReturn(recorder.send).Maybe()
688 return recorder
689}
690
691func (t *transportRecorder) send(_ context.Context, body []byte) ([]byte, error) {
692 t.called = true
693 t.last = append(t.last[:0], body...)
694 return t.response, nil
695}
696
697type blockingTransport struct {
698 available bool
699 called bool
700}
701
702func (t *blockingTransport) Available() bool { return t.available }
703
704func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
705 t.called = true
706 <-ctx.Done()
707 return nil, ctx.Err()
708}
709
710func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
711 t.Parallel()
712
713 transport := newTransportRecorder(t, true, []byte(`{"requestId":"req","success":true}`))
714 client := NewClient(transport, nil)
715
716 checks := []struct {
717 name string
718 call func() error
719 }{
720 {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
721 {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
722 {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
723 {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
724 {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
725 {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
726 {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
727 {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
728 {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
729 {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
730 {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
731 {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
732 {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
733 {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }},
734 {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }},
735 }
736 for _, check := range checks {
737 t.Run(check.name, func(t *testing.T) {
738 if err := check.call(); err == nil {
739 t.Fatal("error = nil, want validation error")
740 }
741 })
742 }
743 if transport.called {
744 t.Fatal("transport called for invalid favorite mutation IDs")
745 }
746}
747
748func TestClientRejectsNegativeHistoryLimits(t *testing.T) {
749 t.Parallel()
750
751 client := NewClient(newTransportRecorder(t, true, nil), nil)
752
753 if _, err := client.Timeline(context.Background(), -1, 0); err == nil {
754 t.Fatal("Timeline() error = nil, want error")
755 }
756 if _, err := client.Timeline(context.Background(), 1, -1); err == nil {
757 t.Fatal("Timeline() error = nil, want error")
758 }
759 if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil {
760 t.Fatal("TimelineByDomain() error = nil, want error")
761 }
762 if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil {
763 t.Fatal("TimelineByDomain() error = nil, want error")
764 }
765 if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil {
766 t.Fatal("TimelineByDomain() error = nil, want error")
767 }
768 if _, err := client.Search(context.Background(), "example", -1); err == nil {
769 t.Fatal("Search() error = nil, want error")
770 }
771}