package latest import ( "context" "net/http" "os" "strings" "testing" "time" "bookmarkmanager/backend/internal/store" ) // TestSmokeKaganeImage is the live proof that the acquisition path's browser // fetch actually clears Cloudflare and returns image bytes. It needs the real // browser unit with outbound network, so it runs only when SMOKE_BROWSER_WS_URL // is set: // // cd chrome && BROWSER_BIND_ADDR=127.0.0.1 docker compose up -d --build // SMOKE_BROWSER_WS_URL=ws://127.0.0.1:9222 go test -run TestSmokeKaganeImage ./internal/latest // // Not chromedp/headless-shell: its challenge never clears (see chrome/Dockerfile), // so a red run there proves nothing about kagane. func TestSmokeKaganeImage(t *testing.T) { ws := os.Getenv("SMOKE_BROWSER_WS_URL") if ws == "" { t.Skip("SMOKE_BROWSER_WS_URL unset") } const imageURL = "https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed" // SP Baby's cover // The same URL through a plain client is what any other fetcher would get. // Asserting on it keeps the test honest about why the browser is needed. req, err := http.NewRequest(http.MethodGet, imageURL, nil) if err != nil { t.Fatal(err) } if res, err := (&http.Client{Timeout: 15 * time.Second}).Do(req); err == nil { res.Body.Close() if res.StatusCode == http.StatusOK { t.Log("note: kagane answered a plain request 200 — the challenge is not up right now") } } f, err := NewBrowserFetcher(ws) if err != nil { t.Fatalf("NewBrowserFetcher: %v", err) } defer f.Close() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() body, contentType, err := f.Image(ctx, imageURL) if err != nil { t.Fatalf("Image: %v", err) } if len(body) < 1000 { t.Fatalf("body is %d bytes, want a real image", len(body)) } if contentType != "image/webp" { t.Fatalf("content type = %q, want image/webp", contentType) } // WebP files start with "RIFF....WEBP". if string(body[:4]) != "RIFF" || string(body[8:12]) != "WEBP" { t.Fatalf("body is not a WebP: % x", body[:12]) } t.Logf("fetched %d bytes of %s", len(body), contentType) // The browser module claims only the cover URL shape it can clear a // challenge for; anything else must be refused before any navigation. if _, _, err := f.Image(ctx, "https://cdn.example/cover.jpg"); err == nil { t.Fatal("Image accepted a cover URL the browser module does not claim") } } // Control for the test above: the poller's own kagane path, same sidecar. If // this fails too, the sidecar is not clearing the challenge at all and the // image result says nothing about Image itself. func TestSmokeKaganeGet(t *testing.T) { ws := os.Getenv("SMOKE_BROWSER_WS_URL") if ws == "" { t.Skip("SMOKE_BROWSER_WS_URL unset") } f, err := NewBrowserFetcher(ws) if err != nil { t.Fatalf("NewBrowserFetcher: %v", err) } defer f.Close() ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() body, status, err := f.Get(ctx, "https://kagane.to/series/019fe11a-8670-7cf3-8343-0b02057d3787") if err != nil { t.Fatalf("Get: %v", err) } t.Logf("status=%d bytes=%d head=%.80q", status, len(body), body) if status != 200 { t.Fatalf("status = %d, want 200 — the sidecar is not clearing the challenge", status) } } // TestSmokeAcquireKaganeCover proves the #62 acquisition path end to end // against the real browser: a kagane Series bookmarked at creation gets its // Cover, bytes fetched through the sidecar into the content-addressed store. // Same SMOKE_BROWSER_WS_URL gate as the tests above; a red run means the // challenge is not clearing from this IP (a live fact to re-check), not // necessarily a defect in the pipeline. func TestSmokeAcquireKaganeCover(t *testing.T) { ws := os.Getenv("SMOKE_BROWSER_WS_URL") if ws == "" { t.Skip("SMOKE_BROWSER_WS_URL unset") } const seriesID = "019fe11a-8670-7cf3-8343-0b02057d3787" s, _ := newTestStore(t) bf, err := NewBrowserFetcher(ws) if err != nil { t.Fatalf("NewBrowserFetcher: %v", err) } defer bf.Close() tlsF, err := NewTLSFetcher() if err != nil { t.Fatalf("NewTLSFetcher: %v", err) } acq := &Acquirer{ Store: s, Fetch: tlsF, BrowserFetch: bf, BrowserCoverFetch: bf, Covers: NewCoverFetcher(), } s.OnSeriesCreated = acq.Acquire if _, err := s.Upsert(s.OwnerID(), store.Bookmark{ Key: "kagane:" + seriesID, Site: "kagane", SeriesID: seriesID, Title: "smoke", SeriesURL: "https://kagane.to/series/" + seriesID, UpdatedAt: 1000, }); err != nil { t.Fatalf("Upsert: %v", err) } acq.Wait() got, found, err := s.Get(s.OwnerID(), "kagane:"+seriesID) if err != nil || !found { t.Fatalf("Get: %v found=%v", err, found) } addr, ok := strings.CutPrefix(got.Cover, testCoverBaseURL+"/covers/") if !ok { t.Fatalf("Cover = %q, want an address on %q — the acquire path did not store the browser-fetched bytes", got.Cover, testCoverBaseURL+"/covers/") } body, contentType, ok, err := s.CoverByAddress(addr) if err != nil || !ok { t.Fatalf("CoverByAddress(%q): %v found=%v", addr, err, ok) } if len(body) < 1000 { t.Fatalf("stored cover is %d bytes, want a real image", len(body)) } if contentType != "image/webp" { t.Fatalf("content type = %q, want image/webp", contentType) } // The address the row carries is the bytes' own SHA-256: a re-art behind // the same URL would be a different address, which is the whole point. if want := testCoverBaseURL + "/covers/" + store.CoverAddressForBytes(body); got.Cover != want { t.Fatalf("Cover = %q, want %q", got.Cover, want) } t.Logf("stored %d bytes of %s", len(body), contentType) }