Files
kage/asset/download_test.go
T
Duc-Tam Nguyen 249761f29f Leave bulk and off-domain assets remote, skip over-cap downloads
A developer.apple.com crawl came back at 19 GB, 18 of it assets, most of
that the site's own videos, .dmg/.pkg installers, and PDF manuals pulled
from a couple dozen hosts including unrelated third parties. None of it
helps read the docs offline. This changes what kage localizes by default.

Bulk media, installers, archives, and PDFs are left pointing at their
live URL instead of downloaded. The decision is made from the URL alone,
so the rewritten HTML simply keeps the remote link. --keep-media restores
the old behavior and --skip-ext adds more extensions to leave remote.

Assets are localized only from the seed's registrable domain by default,
so www.apple.com and images.apple.com still come along but a separate
brand domain or an off-topic third party does not. --all-asset-hosts goes
back to downloading from any host.

The size cap was also truncating instead of skipping: it wrapped the body
in a LimitReader, so an over-cap file was saved as exactly the first N MB
of itself, a corrupt fragment that would never play or run. On the apple
crawl that was around a gigabyte of half-downloaded WWDC videos. kage now
checks the response size and leaves an over-cap asset out of the mirror.
2026-06-15 20:27:47 +07:00

176 lines
4.8 KiB
Go

package asset
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"
)
func TestStatusErrorMessage(t *testing.T) {
cases := map[int]string{
403: "HTTP 403 Forbidden",
404: "HTTP 404 Not Found",
999: "HTTP 999",
}
for code, want := range cases {
if got := (&StatusError{Code: code}).Error(); got != want {
t.Errorf("StatusError{%d} = %q; want %q", code, got, want)
}
}
}
func TestGetRetriesTransientThenSucceeds(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 403 on the first try (like bot-protection), then serve the file.
if atomic.AddInt32(&hits, 1) == 1 {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/css")
_, _ = w.Write([]byte("body{}"))
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 0)
u, _ := url.Parse(srv.URL + "/style.css")
res, err := d.Get(context.Background(), u, "")
if err != nil {
t.Fatalf("Get after retry: %v", err)
}
if !res.IsCSS || string(res.Body) != "body{}" {
t.Errorf("unexpected result: css=%v body=%q", res.IsCSS, res.Body)
}
if hits < 2 {
t.Errorf("expected a retry; server saw %d hits", hits)
}
}
func TestGetDoesNotRetryPermanent(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 0)
u, _ := url.Parse(srv.URL + "/missing.png")
_, err := d.Get(context.Background(), u, "")
var se *StatusError
if !errors.As(err, &se) || se.Code != 404 {
t.Fatalf("got %v; want StatusError 404", err)
}
if hits != 1 {
t.Errorf("404 should not be retried; server saw %d hits", hits)
}
}
func TestGetGivesUpAfterRetries(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 0)
d.Retries = 2
u, _ := url.Parse(srv.URL + "/rate.css")
_, err := d.Get(context.Background(), u, "")
var se *StatusError
if !errors.As(err, &se) || se.Code != 429 {
t.Fatalf("got %v; want StatusError 429", err)
}
if hits != 3 { // 1 try + 2 retries
t.Errorf("expected 3 attempts, server saw %d", hits)
}
}
func TestGetSkipsOverCapByContentLength(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
body := make([]byte, 4096) // declared via Content-Length
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(body)
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 1024) // cap below the body
u, _ := url.Parse(srv.URL + "/clip.mp4")
_, err := d.Get(context.Background(), u, "")
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("got %v; want ErrTooLarge", err)
}
if hits != 1 {
t.Errorf("an over-cap asset should not be retried; server saw %d hits", hits)
}
}
func TestGetSkipsOverCapWithoutContentLength(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A chunked response carries no Content-Length, so the cap is only known
// after reading past it.
w.Header().Set("Content-Type", "application/octet-stream")
fl, _ := w.(http.Flusher)
chunk := make([]byte, 512)
for i := 0; i < 8; i++ {
_, _ = w.Write(chunk)
if fl != nil {
fl.Flush()
}
}
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 1024)
u, _ := url.Parse(srv.URL + "/stream.bin")
_, err := d.Get(context.Background(), u, "")
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("got %v; want ErrTooLarge", err)
}
}
func TestGetKeepsUnderCap(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("small"))
}))
defer srv.Close()
d := NewDownloader("kage-test", 5*time.Second, 1024)
u, _ := url.Parse(srv.URL + "/logo.png")
res, err := d.Get(context.Background(), u, "")
if err != nil {
t.Fatalf("under-cap asset: %v", err)
}
if string(res.Body) != "small" {
t.Errorf("body = %q; want %q", res.Body, "small")
}
}
func TestTransientClassification(t *testing.T) {
transientCodes := []int{403, 408, 425, 429, 500, 502, 503}
for _, c := range transientCodes {
if !transient(&StatusError{Code: c}) {
t.Errorf("status %d should be transient", c)
}
}
for _, c := range []int{400, 401, 404, 410} {
if transient(&StatusError{Code: c}) {
t.Errorf("status %d should be permanent", c)
}
}
if transient(context.Canceled) {
t.Error("context.Canceled should not be transient")
}
}