diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index afe98e2..d0bcb14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,11 +2,18 @@ Purpose ------- This file gives concise, actionable guidance for AI coding agents working on the `webinfo` Go module. -**What this project does**: Extracts metadata (title, description, canonical, image, etc.) from web pages and provides utilities to fetch and save representative images. +What this project does +---------------------- +Extracts metadata (title, description, canonical, image, etc.) from web pages and provides utilities +to fetch and save representative images and create thumbnails. Quick entry points ------------------ -- **Primary package**: `webinfo` — key files: `fetch.go` (core `Fetch` function), `webinfo.go` (`Webinfo` struct and `DownloadImage`), `errs.go` (error sentinel values), `fetch_test.go` (behavioral tests). +- **Primary package**: `webinfo` — key files: + - `fetch.go` (core `Fetch` function and encoding handling) + - `webinfo.go` (`Webinfo` type, `DownloadImage`, and `DownloadThumbnail`) + - `errs.go` (error sentinel values) + - `fetch_test.go` (behavioral tests and examples) - **Go module**: `go 1.25` (see `go.mod`). Developer workflows @@ -25,10 +32,24 @@ Project-specific conventions and patterns - Default User-Agent: `getUserAgent("")` returns a dummy UA string. Functions accept a `userAgent` param but fall back to this default. - Encoding: `Fetch` peeks the first 1024 bytes and uses `charset.DetermineEncoding` and `encoding.GetEncoding(name)` to decode response bodies before HTML parsing — preserve this approach when touching parsing logic. - HTML parsing: `goquery` is used to select head elements and meta tags. Extraction precedence is explicit in `fetch.go` (title → `twitter:title`/`og:title`, description → `twitter:description`/`og:description`, image → `twitter:image`/`og:image`). Follow this precedence in code changes or tests. -- Image download (`DownloadImage` in `webinfo.go`): - - Determines extension from URL path, `Content-Type` header, sniffing (up to 512 bytes), then fallback to `.img`. - - If URL has no filename, `temporary` is forced true and `os.CreateTemp(destDir, "webinfo-image-*"+ext)` is used. - - When sniffing bytes, the code prepends the read bytes back into the stream with `io.MultiReader` so the full image is written. + +Image download and thumbnail notes +--------------------------------- +- `DownloadImage` (in `webinfo.go`) downloads `w.ImageURL` and saves it to disk. It determines the output file extension using this order: + 1) extension from the URL path, + 2) extensions inferred from the response `Content-Type` header, + 3) sniffing the first up to 512 bytes via `http.DetectContentType`, + 4) fallback to `.img` if none found. + When sniffing, the read bytes are prepended back into the response body with `io.MultiReader` so the full image is written. +- `DownloadThumbnail` (added to `webinfo.go`) downloads the original image (via `DownloadImage`), resizes it to a requested width (preserving aspect ratio) and writes a thumbnail. Implementation notes: + - The code currently uses a local nearest-neighbor scaler (no external `x/image/draw` dependency) to avoid adding module requirements. + - The method accepts `width` (default 150 when <= 0), `destDir`, and `temporary` flags. When `destDir` is empty the method forces creation of a temporary file. + - When `temporary` is false, the thumbnail filename is derived from the original image basename with `-thumb` appended before the extension. + +I/O and cleanup +---------------- +- Response bodies and files are closed; close errors are wrapped/joined with any existing error. +- Errors encountered while parsing the URL, fetching, reading, sniffing, creating directories/files, or copying data are wrapped with contextual information (e.g. `"url"`, `"path"`, `"dir"`, `"file"`) using the `errs` package. Tests and examples ------------------ @@ -39,24 +60,26 @@ Tests and examples - Example usage patterns to follow when adding code or tests: - Fetch: `info, err := Fetch(ctx, "https://example.com", "")` — empty UA uses the default. - Download image: `outPath, err := w.DownloadImage(ctx, "images", true)` + - Download thumbnail: `thumbPath, err := w.DownloadThumbnail(ctx, "thumbnails", 150, false)` External dependencies & integration points ---------------------------------------- - Key dependencies in `go.mod`: `github.com/goark/fetch`, `github.com/goark/errs`, `github.com/PuerkitoBio/goquery`, `golang.org/x/text` (encodings). +- The repository intentionally avoids adding `golang.org/x/image/draw` as a dependency; if you need higher-quality scaling consider adding it and updating `go.mod` and tests. - The `Taskfile.yml` runs additional tools: `govulncheck`, `golangci-lint-v2`, and (optionally) `nancy` via `depm` — keep CI tool invocations in sync when adding dependencies. When modifying public APIs ------------------------- - Maintain existing error-wrapping conventions (`errs.Wrap`, `errs.WithContext`). - Preserve encoding detection behavior and the 1024-byte peek in `Fetch` unless a clear, tested performance reason exists. -- Preserve `DownloadImage`'s extension-detection order and the behavior of `temporary` vs permanent files. +- Preserve `DownloadImage`'s extension-detection order and the behavior of `temporary` vs permanent files. When adding `DownloadThumbnail` behavior or changing file-naming semantics, update tests accordingly. Where to look next (high-value files) ------------------------------------- - `fetch.go` — how pages are fetched, decoded and parsed. -- `webinfo.go` — `Webinfo` type and `DownloadImage` implementation. +- `webinfo.go` — `Webinfo` type, `DownloadImage`, and `DownloadThumbnail` implementations. - `fetch_test.go` — canonical tests and examples you should mirror for new behaviors. - `errs.go` and `go.mod` — error constants and dependency hints. - `Taskfile.yml` — canonical developer/test/lint workflow. -If anything above is unclear or you want more examples (small patches, test templates, or a CI-safe refactor suggestion), tell me which area to expand and I will iterate. +If anything above is unclear or you want small patches, test templates, or a CI-safe refactor suggestion, tell me which area to expand and I will iterate. diff --git a/README.md b/README.md index e7c337e..74156dd 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# webinfo -- Extract metadata and structured information from web pages +# [webinfo] -- Extract metadata and structured information from web pages [![lint status](https://github.com/goark/webinfo/workflows/lint/badge.svg)](https://github.com/goark/webinfo/actions) [![GitHub license](https://img.shields.io/badge/license-Apache%202-blue.svg)](https://raw.githubusercontent.com/goark/webinfo/master/LICENSE) [![GitHub release](http://img.shields.io/github/release/goark/webinfo.svg)](https://github.com/goark/webinfo/releases/latest) -webinfo is a small Go module that extracts common metadata from web pages and provides utilities +[`webinfo`][webinfo] is a small Go module that extracts common metadata from web pages and provides utilities to download representative images and create thumbnails. -**Quick overview** +## Quick overview - **Package**: `webinfo` - **Repository**: `github.com/goark/webinfo` - **Purpose**: fetch page metadata (title, description, canonical, image, etc.) and download images -**Features** +## Features - Fetch page metadata with `Fetch` (handles encodings and meta tag precedence). - Download an image referenced by `Webinfo.ImageURL` using `(*Webinfo).DownloadImage`. - Create a thumbnail from the referenced image using `(*Webinfo).DownloadThumbnail`. -**Install** +## Install Use Go modules (Go 1.25+ as used by the project): @@ -27,7 +27,7 @@ Use Go modules (Go 1.25+ as used by the project): go get github.com/goark/webinfo@latest ``` -**Basic usage** +## Basic usage Example showing fetch and download thumbnail (error handling omitted for brevity): @@ -37,28 +37,30 @@ package main import ( "context" "fmt" + "github.com/goark/webinfo" ) func main() { ctx := context.Background() // Fetch metadata for a page (empty UA uses default) - info, err := webinfo.Fetch(ctx, "https://example.com", "") + info, err := webinfo.Fetch(ctx, "https://text.baldanders.info/", "") if err != nil { - fmt.Fprintln(os.Stderr, "error fetching webinfo:", err) - panic(err) + fmt.Printf("error detail:\n%+v\n", err) + return } // Download thumbnail: width 150, to directory "thumbnails", permanent file thumbPath, err := info.DownloadThumbnail(ctx, "thumbnails", 150, false) if err != nil { - panic(err) + fmt.Printf("error detail:\n%+v\n", err) + return } fmt.Println("thumbnail saved:", thumbPath) } ``` -**API notes** +### API notes - `Fetch(ctx, url, userAgent)` — Parse and extract metadata. Pass an empty userAgent to use the module default. - `(*Webinfo).DownloadImage(ctx, destDir, temporary)` — Download the image in `Webinfo.ImageURL` and save it. If @@ -66,14 +68,105 @@ func main() { - `(*Webinfo).DownloadThumbnail(ctx, destDir, width, temporary)` — Download the referenced image and produce a thumbnail resized to `width` pixels (height is preserved by aspect ratio). If `destDir` is empty the method creates a temporary file; when `temporary` is false the thumbnail file is named based on the original image - name with `-thums` appended before the extension. + name with `-thumb` appended before the extension. + +Note on defaults and test hooks: + +- **Default width**: If `width <= 0` is passed to `DownloadThumbnail`, the method uses a default width of 150 pixels. +- **Extension detection**: `DownloadImage` determines an output extension from the URL path, the response + `Content-Type` (via `mime.ExtensionsByType`), or by sniffing up to the first 512 bytes with `http.DetectContentType`. +- **Test hooks / injection points**: For easier testing the package exposes a few package-level variables that + tests can override: + - `createFile`: used to create temporary or permanent files (wraps `os.CreateTemp` / `os.Create`). Override to + simulate file-creation failures. + - `decodeImage`: wrapper around `image.Decode` used by `DownloadThumbnail` — override to simulate decode results + (for example, to return a zero-dimension image). + - `outputImage`: encoder that writes the thumbnail image to disk (wraps `jpeg.Encode`, `png.Encode`, etc.). + Override to simulate encoder failures. + +These hooks are intended for tests and let callers reproduce rare I/O or encoding failures without changing +production behavior. + +- **HTTP client timeout**: `DownloadImage` uses an HTTP client with a default 30-second `Timeout` for the whole + request; tests can override this by replacing the `newHTTPClient` package variable. + +## Test examples + +Below are short examples showing how to override the package-level hooks from a test to simulate failures. +These snippets are intended for `*_test.go` files and assume the usual `testing` and `net/http/httptest` helpers. + +1) Simulate thumbnail temporary-file creation failure (override `createFile`): + +```go +// in your test function +orig := createFile +defer func() { createFile = orig }() +createFile = func(temp bool, dir, pattern string) (*os.File, error) { + // fail only for thumbnail temp pattern + if temp && strings.Contains(pattern, "webinfo-thumb-") { + return nil, errors.New("simulated thumbnail temp create failure") + } + return orig(temp, dir, pattern) +} + +// then call the method under test +_, err := info.DownloadThumbnail(ctx, t.TempDir(), 50, true) +// assert err != nil +``` + +2) Simulate a zero-dimension decoded image (override `decodeImage`): + +```go +origDecode := decodeImage +defer func() { decodeImage = origDecode }() +decodeImage = func(r io.Reader) (image.Image, string, error) { + // return an image with zero width to hit the origW==0 error path + return image.NewRGBA(image.Rect(0, 0, 0, 10)), "png", nil +} + +_, err := info.DownloadThumbnail(ctx, t.TempDir(), 50, true) +// assert err != nil +``` + +3) Simulate encoder failure when writing thumbnails (override `outputImage`): + +```go +origOut := outputImage +defer func() { outputImage = origOut }() +outputImage = func(dst *os.File, src *image.RGBA, format string) error { + return errors.New("simulated encode failure") +} + +_, err := info.DownloadThumbnail(ctx, t.TempDir(), 50, true) +// assert err != nil +``` + +Notes: +- Ensure your test imports include `errors`, `io`, `image`, and `strings` as needed. +- Restore the original variables with `defer` to avoid cross-test interference. +- These examples are intentionally minimal — adapt them to your test fixtures (httptest servers, temp dirs, etc.). + +4) Simulate HTTP client timeout by overriding `newHTTPClient`: + +```go +origClient := newHTTPClient +defer func() { newHTTPClient = origClient }() +newHTTPClient = func() *http.Client { + // short timeout for test + return &http.Client{Timeout: 50 * time.Millisecond} +} + +// then call DownloadImage which uses newHTTPClient() +_, err := info.DownloadImage(ctx, t.TempDir(), true) +// assert err != nil (expect timeout) +``` -**Error handling** +### Error handling The package uses `github.com/goark/errs` for wrapping errors with contextual keys (e.g. `url`, `path`, `dir`). Callers should inspect returned errors accordingly. -**Tests & development** +### Tests & development - Run all tests: `go test ./...` - The repository includes `Taskfile.yml` tasks for common workflows; see that file for CI/test commands. diff --git a/Taskfile.yml b/Taskfile.yml index ce3ddb0..4731e6a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -17,7 +17,8 @@ tasks: desc: Test and lint. cmds: - go mod verify - - go test -shuffle on ./... + - go test -shuffle on ./... -coverprofile=coverage.out -cover + - go tool cover -func=coverage.out - govulncheck ./... - golangci-lint-v2 run --enable gosec --timeout 10m0s ./... sources: diff --git a/fetch_test.go b/fetch_test.go index bb730b7..2e061b5 100644 --- a/fetch_test.go +++ b/fetch_test.go @@ -2,7 +2,9 @@ package webinfo import ( "context" + "errors" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -87,6 +89,66 @@ func TestFetch_DefaultUserAgent(t *testing.T) { } } +func TestFetch_CustomUserAgent(t *testing.T) { + uaCh := make(chan string, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uaCh <- r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte("X")) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + ctx := context.Background() + customUA := "MyCustomAgent/1.0" + info, err := Fetch(ctx, srv.URL, customUA) + if err != nil { + t.Fatalf("Fetch returned error: %v", err) + } + var gotUA string + select { + case gotUA = <-uaCh: + default: + t.Fatalf("server did not receive request") + } + if gotUA != customUA { + t.Errorf("User-Agent: want %q, got %q", customUA, gotUA) + } + if info == nil { + t.Fatalf("expected non-nil info") + } + if info.UserAgent != customUA { + t.Errorf("info.UserAgent: want %q, got %q", customUA, info.UserAgent) + } +} + +func TestFetch_BodyCloseReturnsError(t *testing.T) { + orig := http.DefaultTransport + defer func() { http.DefaultTransport = orig }() + + rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + b := &failingBody{ + firstData: []byte("X"), + firstErr: io.ErrUnexpectedEOF, + closeErr: errors.New("simulated close error"), + } + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Header: make(http.Header), + Body: b, + Request: req, + }, nil + }) + http.DefaultTransport = rt + + ctx := context.Background() + _, err := Fetch(ctx, "http://example.invalid/", "") + if err == nil { + t.Fatalf("expected error when response body Close returns error, got nil") + } +} + func TestFetch_BadURL(t *testing.T) { ctx := context.Background() _, err := Fetch(ctx, "://bad-url", "") diff --git a/sample/sample1.go b/sample/sample1.go new file mode 100644 index 0000000..c152641 --- /dev/null +++ b/sample/sample1.go @@ -0,0 +1,23 @@ +//go:build run + +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/goark/webinfo" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + info, err := webinfo.Fetch(ctx, "https://example.com", "") + if err != nil { + log.Fatalf("Fetch failed: %v", err) + } + fmt.Printf("Title: %s\nDescription: %s\nImage: %s\n", info.Title, info.Description, info.ImageURL) +} diff --git a/sample/sample2.go b/sample/sample2.go new file mode 100644 index 0000000..e89b1d9 --- /dev/null +++ b/sample/sample2.go @@ -0,0 +1,28 @@ +//go:build run + +package main + +import ( + "context" + "fmt" + + "github.com/goark/webinfo" +) + +func main() { + ctx := context.Background() + // Fetch metadata for a page (empty UA uses default) + info, err := webinfo.Fetch(ctx, "https://text.baldanders.info/", "") + if err != nil { + fmt.Printf("%+v\n", err) + return + } + + // Download thumbnail: width 150, to directory "thumbnails", permanent file + thumbPath, err := info.DownloadThumbnail(ctx, "thumbnails", 150, false) + if err != nil { + fmt.Printf("%+v\n", err) + return + } + fmt.Println("thumbnail saved:", thumbPath) +} diff --git a/webinfo.go b/webinfo.go index 1f5c05e..8efecf9 100644 --- a/webinfo.go +++ b/webinfo.go @@ -44,36 +44,51 @@ type Webinfo struct { UserAgent string `json:"user_agent,omitempty"` // User-Agent used to fetch the page } -// DownloadImage downloads the image referenced by w.ImageURL and saves it to the filesystem. -// It returns the full path of the saved file or an error. +// DownloadImage downloads the image pointed to by w.ImageURL and saves it to destDir, +// returning the path of the saved file (outPath) or an error. // -// Behavior -// - Validates receiver and ImageURL and returns wrapped errors (ErrNullPointer, ErrNoImageURL, etc.) on failure. -// - Creates destDir if non-empty using os.MkdirAll(destDir, 0o750). If destDir is empty, no directory is created -// (files are created relative to the current working directory; temporary files use the OS temp directory). -// - Performs an HTTP GET using a 15s timeout and the provided context (supports cancellation/timeout). -// The request sets a User-Agent via getUserAgent(w.UserAgent). -// - Determines the output file extension in this order: -// 1) extension from the URL path, -// 2) extension(s) inferred from the response Content-Type header, -// 3) sniffing the first up to 512 bytes via http.DetectContentType, -// 4) fallback to ".img" if none found. -// When sniffing, the read bytes are prepended back to the response body stream so the full image is written. +// Behavior: +// - The method is a receiver on *Webinfo and will return an error if w is nil or if +// ImageURL is empty. +// - ctx is used to control/cancel the underlying HTTP request. +// - destDir is cleaned with filepath.Clean. If it is non-empty, the directory (and any +// required parents) will be created with mode 0750. If destDir is empty, file +// creation uses the system/default behavior for temporary or current directories. +// - If `temporary` is true, the image is written to a temporary file (created via +// the package-level `createFile` helper which wraps `os.CreateTemp`) and the +// temporary file path is returned. If the URL path does not contain a filename, +// `temporary` is forced true. +// - If `temporary` is false, the image is written to `destDir` with the filename +// taken from the URL path. If the URL filename has no extension, an extension is +// appended (see extension resolution below). Existing files will be truncated by +// the underlying `createFile`/`os.Create` behavior. // -// Temporary vs permanent files -// - If temporary is true (or the URL path contains no usable filename), a temporary file is created with -// os.CreateTemp(destDir, "webinfo-image-*"+ext) and the image is written to it. The path to the temp file is returned. -// - If temporary is false, the filename from the URL is used; if that filename had no extension, the determined -// extension is appended. The file is created with os.Create(filepath.Join(destDir, srcFname)) and written. +// HTTP download and content-type/extension resolution: +// - The image is fetched using an HTTP GET performed with the provided context; the +// request User-Agent is set via getUserAgent(w.UserAgent). +// - Extension resolution order: +// 1) Extension from the URL path (if present). +// 2) Extension(s) derived from the Content-Type response header via mime.ExtensionsByType. +// 3) If still unknown, the first up-to-512 bytes of the body are read and +// http.DetectContentType is used to guess the content type, then mime.ExtensionsByType. +// 4) If no extension can be determined, ".img" is used as a fallback. +// - When bytes are sniffed from the body, they are prepended back to the reader so the +// full image is written to disk. When multiple extensions are returned by +// mime.ExtensionsByType the implementation picks the last returned extension. +// - File creation is performed via the package-level `createFile` variable which tests +// may override to simulate create failures. // -// I/O and cleanup -// - The response body and created files are closed; close errors are wrapped/joined with any existing error. -// - Errors encountered while parsing the URL, fetching, reading, sniffing, creating directories/files, or copying -// data are wrapped with contextual information (e.g. "url", "path", "dir", "file") using the errs package. +// Resource management and errors: +// - The response body and any created file are closed using deferred cleanup; any close +// errors are joined into the returned error. +// - I/O, network and OS errors are returned (wrapped with contextual information). +// - On success, outPath contains the absolute/relative path to the saved image file; +// on error, outPath will be empty and err will describe the failure. // -// Return values -// - outPath: the filesystem path of the saved image (temporary or permanent file). -// - err: nil on success or a wrapped error describing what went wrong. +// Notes: +// - The function may truncate an existing destination file with the same name. +// - The exact behavior of temporary file placement when destDir is empty follows the +// semantics of os.CreateTemp. func (w *Webinfo) DownloadImage(ctx context.Context, destDir string, temporary bool) (outPath string, err error) { if w == nil { err = errs.Wrap(ErrNullPointer) @@ -95,7 +110,7 @@ func (w *Webinfo) DownloadImage(ctx context.Context, destDir string, temporary b return } parsed, uerr := fetch.URL(strings.TrimSpace(w.ImageURL)) - if err != nil { + if uerr != nil { err = errs.Wrap(uerr, errs.WithContext("url", w.ImageURL)) return } @@ -108,7 +123,7 @@ func (w *Webinfo) DownloadImage(ctx context.Context, destDir string, temporary b srcExt := path.Ext(srcFname) // fetch image - resp, ferr := fetch.New(fetch.WithHTTPClient(&http.Client{Timeout: 15 * time.Second})).GetWithContext( + resp, ferr := fetch.New(fetch.WithHTTPClient(newHTTPClient())).GetWithContext( ctx, parsed, fetch.WithRequestHeaderSet("User-Agent", getUserAgent(w.UserAgent)), @@ -161,46 +176,39 @@ func (w *Webinfo) DownloadImage(ctx context.Context, destDir string, temporary b ext = ".img" } - // Create a temporary file + var outF *os.File if temporary { - tmp, cerr := os.CreateTemp(destDir, "webinfo-image-*"+ext) + // Create a temporary file + var cerr error + outF, cerr = createFile(true, destDir, "webinfo-image-*"+ext) if cerr != nil { err = errs.Wrap(cerr, errs.WithContext("dir", destDir), errs.WithContext("file", "temporary file")) return } - defer func() { // ensure temp file closed - if cerr := tmp.Close(); cerr != nil && cerr != os.ErrClosed { - err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", tmp.Name())), err) - } - }() - if _, cerr := io.Copy(tmp, bodyReader); cerr != nil { - err = errs.Wrap(cerr, errs.WithContext("path", tmp.Name())) + } else { + // Create a permanent file + destPath := filepath.Join(destDir, srcFname) + if len(srcExt) == 0 { + destPath += ext + } + var cerr error + outF, cerr = createFile(false, "", destPath) + if cerr != nil { + err = errs.Wrap(cerr, errs.WithContext("path", destPath)) return } - outPath = tmp.Name() - return - } - // Create a permanent file - destPath := filepath.Join(destDir, srcFname) - if len(srcExt) == 0 { - destPath += ext - } - destPath = filepath.Clean(destPath) - f, cerr := os.Create(destPath) - if err != nil { - err = errs.Wrap(cerr, errs.WithContext("path", destPath)) - return } defer func() { - if cerr := f.Close(); cerr != nil && cerr != os.ErrClosed { - err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", f.Name())), err) + if cerr := outF.Close(); cerr != nil && cerr != os.ErrClosed { + err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", outF.Name())), err) } }() - if _, cerr := io.Copy(f, bodyReader); err != nil { - err = errs.Wrap(cerr, errs.WithContext("path", f.Name())) + + if _, cerr := io.Copy(outF, bodyReader); cerr != nil { + err = errs.Wrap(cerr, errs.WithContext("path", outF.Name())) return } - outPath = f.Name() + outPath = outF.Name() return } @@ -221,10 +229,16 @@ func (w *Webinfo) DownloadImage(ctx context.Context, destDir string, temporary b // width x newH. // - The output format/extension is chosen from the decoded format: jpeg/jpg → .jpg, png → .png, // gif → .gif. Unknown formats fall back to PNG. -// - If temporary is true, the thumbnail file is created with os.CreateTemp in destDir using -// the pattern "webinfo-thumb-*" and the temporary file path is returned. -// - If temporary is false, the output filename is derived from the original image URL basename -// (falling back to "webinfo-image") and named "-thums" in destDir. +// - If `temporary` is true, the thumbnail file is created via the package-level +// `createFile` helper (which wraps `os.CreateTemp`) in `destDir` using the +// pattern "webinfo-thumb-*"; the temporary file path is returned. +// - If `temporary` is false, the output filename is derived from the original image +// URL basename (falling back to "webinfo-image") and named "-thumb" in +// `destDir`. +// - The encoder used to write the thumbnail is the package-level `outputImage` function +// variable; tests may replace this variable to simulate encoder failures. The image +// decoding step uses the package-level `decodeImage` wrapper around `image.Decode`, +// which tests may also override. // - Files are properly closed with deferred cleanup; any close/remove errors are joined into // the returned error using the errs package. // - All filesystem, download, and image-processing errors are wrapped with contextual @@ -285,7 +299,7 @@ func (w *Webinfo) DownloadThumbnail(ctx context.Context, destDir string, width i } }() - img, format, derr := image.Decode(f) + img, format, derr := decodeImage(f) if derr != nil { err = errs.Wrap(derr, errs.WithContext("path", origPath)) return @@ -304,8 +318,8 @@ func (w *Webinfo) DownloadThumbnail(ctx context.Context, destDir string, width i newH = 1 } - thums := image.NewRGBA(image.Rect(0, 0, width, newH)) - draw.CatmullRom.Scale(thums, thums.Bounds(), img, bounds, draw.Over, nil) // scale by Catmull-Rom + thumb := image.NewRGBA(image.Rect(0, 0, width, newH)) + draw.CatmullRom.Scale(thumb, thumb.Bounds(), img, bounds, draw.Over, nil) // scale by Catmull-Rom // determine extension/format for output var ext string @@ -325,16 +339,11 @@ func (w *Webinfo) DownloadThumbnail(ctx context.Context, destDir string, width i if temporary { // temporary: create temp file var cerr error - outF, cerr = os.CreateTemp(destDir, "webinfo-thumb-*"+ext) + outF, cerr = createFile(true, destDir, "webinfo-thumb-*"+ext) if cerr != nil { err = errs.Wrap(cerr, errs.WithContext("dir", destDir), errs.WithContext("file", "temporary thumbnail")) return } - defer func() { - if cerr := outF.Close(); cerr != nil && cerr != os.ErrClosed { - err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", outF.Name())), err) - } - }() } else { // not temporary: build filename based on original URL basename base := "webinfo-image" @@ -344,23 +353,23 @@ func (w *Webinfo) DownloadThumbnail(ctx context.Context, destDir string, width i base = strings.TrimSuffix(bn, path.Ext(bn)) } } - destName := base + "-thums" + ext - destPath := filepath.Clean(filepath.Join(destDir, destName)) + destName := base + "-thumb" + ext + destPath := filepath.Join(destDir, destName) var cerr error - outF, cerr = os.Create(destPath) + outF, cerr = createFile(false, "", destPath) if cerr != nil { err = errs.Wrap(cerr, errs.WithContext("path", destPath)) return } - defer func() { - if cerr := outF.Close(); cerr != nil && cerr != os.ErrClosed { - err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", outF.Name())), err) - } - }() } + defer func() { + if cerr := outF.Close(); cerr != nil && cerr != os.ErrClosed { + err = errs.Join(errs.Wrap(cerr, errs.WithContext("path", outF.Name())), err) + } + }() - if oerr := outputImage(outF, thums, format); oerr != nil { + if oerr := outputImage(outF, thumb, format); oerr != nil { err = errs.Wrap(oerr, errs.WithContext("path", outF.Name())) return } @@ -369,12 +378,9 @@ func (w *Webinfo) DownloadThumbnail(ctx context.Context, destDir string, width i } // outputImage encodes the provided *image.RGBA src and writes it to dst using -// the encoder corresponding to the given format string. Supported format values -// (case-sensitive) are "jpeg" or "jpg", "png", and "gif". JPEG output is written -// with jpeg.Options{Quality: 90}. If the format value is not recognized, the -// function falls back to PNG. The function returns any error produced by the -// chosen encoder. -func outputImage(dst *os.File, src *image.RGBA, format string) error { +// the encoder corresponding to the given format string. It is a variable so +// tests can replace it to simulate encoder failures. +var outputImage = func(dst *os.File, src *image.RGBA, format string) error { switch format { case "jpeg", "jpg": return jpeg.Encode(dst, src, &jpeg.Options{Quality: 90}) @@ -386,6 +392,30 @@ func outputImage(dst *os.File, src *image.RGBA, format string) error { return png.Encode(dst, src) // default to PNG } +// createFile is a package-level helper used to create files. It abstracts +// the creation of temporary and permanent files so tests can replace it to +// simulate failures during os.Create/os.CreateTemp. +var createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if temp { + return os.CreateTemp(dir, pathOrPattern) + } + return os.Create(filepath.Clean(pathOrPattern)) +} + +// decodeImage is a package-level wrapper around image.Decode so tests can +// replace it to simulate decoding behaviors (e.g., returning zero-dimension +// images) without modifying stdlib functions. +var decodeImage = func(r io.Reader) (image.Image, string, error) { + return image.Decode(r) +} + +// newHTTPClient returns the http.Client used for web requests. It is a package-level +// variable so tests can override it. By default it sets a 30-second timeout for +// the whole request (connect+read+write). +var newHTTPClient = func() *http.Client { + return &http.Client{Timeout: 30 * time.Second} +} + /* Copyright 2025 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/webinfo_test.go b/webinfo_test.go index 85258cb..20f1b1c 100644 --- a/webinfo_test.go +++ b/webinfo_test.go @@ -3,18 +3,123 @@ package webinfo import ( "bytes" "context" + "errors" "image" "image/color" + "image/gif" "image/jpeg" "image/png" + "io" + "mime" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" ) +// makeImageBytes creates a solid-color image and encodes it as PNG or JPEG. +func makeImageBytes(w, h int, format string, r, g, b uint8) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + fill := color.RGBA{R: r, G: g, B: b, A: 0xff} + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetRGBA(x, y, fill) + } + } + var buf bytes.Buffer + switch format { + case "jpeg": + _ = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}) + case "png": + _ = png.Encode(&buf, img) + } + return buf.Bytes() +} + +func TestDownloadThumbnail_Temporary(t *testing.T) { + pngData := makeImageBytes(200, 100, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/img.png" { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + return + } + http.NotFound(w, r) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + info := &Webinfo{ImageURL: srv.URL + "/img.png"} + ctx := context.Background() + + out, err := info.DownloadThumbnail(ctx, "", 100, true) + if err != nil { + t.Fatalf("DownloadThumbnail returned error: %v", err) + } + defer func() { _ = os.Remove(out) }() + + f, ferr := os.Open(filepath.Clean(out)) + if ferr != nil { + t.Fatalf("failed to open thumbnail: %v", ferr) + } + defer func() { _ = f.Close() }() + img, _, derr := image.Decode(f) + if derr != nil { + t.Fatalf("failed to decode thumbnail: %v", derr) + } + if img.Bounds().Dx() != 100 { + t.Fatalf("thumbnail width: want %d, got %d", 100, img.Bounds().Dx()) + } +} + +func TestDownloadThumbnail_Permanent(t *testing.T) { + jpgData := makeImageBytes(300, 150, "jpeg", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/images/pic.jpg" { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(jpgData) + return + } + http.NotFound(w, r) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + destDir := t.TempDir() + info := &Webinfo{ImageURL: srv.URL + "/images/pic.jpg"} + ctx := context.Background() + + out, err := info.DownloadThumbnail(ctx, destDir, 50, false) + if err != nil { + t.Fatalf("DownloadThumbnail returned error: %v", err) + } + defer func() { _ = os.Remove(out) }() + + // ensure file is in destDir and contains -thumb + if filepath.Dir(out) != filepath.Clean(destDir) { + t.Fatalf("thumbnail path dir: want %q, got %q", destDir, filepath.Dir(out)) + } + if !strings.Contains(filepath.Base(out), "-thumb") { + t.Fatalf("thumbnail filename should contain -thumb: %q", out) + } + + f, ferr := os.Open(filepath.Clean(out)) + if ferr != nil { + t.Fatalf("failed to open thumbnail: %v", ferr) + } + defer func() { _ = f.Close() }() + img, _, derr := image.Decode(f) + if derr != nil { + t.Fatalf("failed to decode thumbnail: %v", derr) + } + if img.Bounds().Dx() != 50 { + t.Fatalf("thumbnail width: want %d, got %d", 50, img.Bounds().Dx()) + } +} + // minimal PNG/JPEG signatures for content-type detection var pngSig = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0} var jpgSig = []byte{0xff, 0xd8, 0xff, 0xe0, 0, 0, 'J', 'F', 'I', 'F'} @@ -121,35 +226,495 @@ func TestDownloadImage_SniffingDeterminesExtension(t *testing.T) { } } -// helper to create a simple filled PNG -func makePNGBytes(w, h int) []byte { - img := image.NewRGBA(image.Rect(0, 0, w, h)) - // fill with a solid color - fill := color.RGBA{R: 0x11, G: 0x22, B: 0x33, A: 0xff} - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - img.SetRGBA(x, y, fill) +func TestDownloadImage_MkdirAllFails(t *testing.T) { + // create a file where a directory is expected so MkdirAll fails + base := t.TempDir() + blocker := filepath.Join(base, "blocked") + if err := os.WriteFile(filepath.Clean(blocker), []byte("notadir"), 0o600); err != nil { + t.Fatalf("create blocker file: %v", err) + } + + // dest path includes the blocker as a path component; MkdirAll should fail + dest := filepath.Join(blocker, "nested") + + w := &Webinfo{ImageURL: "http://example.invalid/img.png"} + _, err := w.DownloadImage(context.Background(), dest, true) + if err == nil { + t.Fatalf("expected error when MkdirAll cannot create directories, got nil") + } +} + +func TestDownloadImage_ReadFullReturnsError(t *testing.T) { + // override the default transport so the HTTP client used in DownloadImage + // receives a response whose Body returns a non-EOF error on Read. + orig := http.DefaultTransport + defer func() { http.DefaultTransport = orig }() + + // errReader is defined at package scope below. + + rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + // return a response with no Content-Type header and a Body that errors + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Header: make(http.Header), + Body: errReader{}, + Request: req, + }, nil + }) + http.DefaultTransport = rt + + dest := t.TempDir() + w := &Webinfo{ImageURL: "http://example.invalid/"} + _, err := w.DownloadImage(context.Background(), dest, false) + if err == nil { + t.Fatalf("expected error when ReadFull returns non-EOF error, got nil") + } +} + +func TestDownloadImage_CopyReadFails(t *testing.T) { + // RoundTripper that returns a response whose Body returns a non-EOF error after sniffing + orig := http.DefaultTransport + defer func() { http.DefaultTransport = orig }() + + rt := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + b := &failingBody{ + firstData: pngSig, // allow sniffing to see PNG signature + firstErr: io.ErrUnexpectedEOF, + subsequentErr: errors.New("simulated read error"), } + return &http.Response{ + StatusCode: 200, + Status: "200 OK", + Header: make(http.Header), + Body: b, + Request: req, + }, nil + }) + http.DefaultTransport = rt + + dest := t.TempDir() + w := &Webinfo{ImageURL: "http://example.invalid/"} + _, err := w.DownloadImage(context.Background(), dest, false) + if err == nil { + t.Fatalf("expected error when io.Copy encounters read error, got nil") } - var buf bytes.Buffer - _ = png.Encode(&buf, img) - return buf.Bytes() } -// helper to create a simple filled JPEG -func makeJPEGBytes(w, h int) []byte { - img := image.NewRGBA(image.Rect(0, 0, w, h)) - fill := color.RGBA{R: 0xaa, G: 0xbb, B: 0xcc, A: 0xff} +// helper types used by tests +type errReader struct{} + +func (errReader) Read(p []byte) (int, error) { return 0, errors.New("simulated read error") } + +func (errReader) Close() error { return nil } + +// helper type to allow inline RoundTripper function +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +// failingBody allows simulating read and close failures in response bodies. +type failingBody struct { + // firstRead returns data and an optional error for the initial sniffing ReadFull call + firstData []byte + firstErr error + // subsequentErr is returned on subsequent Read calls (to simulate io.Copy failing) + subsequentErr error + closeErr error + closed bool +} + +func (b *failingBody) Read(p []byte) (int, error) { + if len(b.firstData) > 0 { + n := copy(p, b.firstData) + // consume firstData + b.firstData = b.firstData[n:] + if len(b.firstData) == 0 { + return n, b.firstErr + } + return n, nil + } + if b.subsequentErr != nil { + return 0, b.subsequentErr + } + return 0, io.EOF +} + +func (b *failingBody) Close() error { + b.closed = true + if b.closeErr != nil { + return b.closeErr + } + return nil +} + +// zeroImg implements image.Image but reports a zero width to exercise +// the zero-dimension handling in DownloadThumbnail. +type zeroImg struct{} + +func (zeroImg) ColorModel() color.Model { return color.RGBAModel } +func (zeroImg) Bounds() image.Rectangle { return image.Rect(0, 0, 0, 10) } +func (zeroImg) At(x, y int) color.Color { return color.RGBA{0, 0, 0, 0} } + +func TestOutputImage_EncodersAndFallback(t *testing.T) { + // create a simple source image + src := image.NewRGBA(image.Rect(0, 0, 20, 10)) + // fill to avoid zero-content + for y := 0; y < 10; y++ { + for x := 0; x < 20; x++ { + src.SetRGBA(x, y, color.RGBA{R: 0x12, G: 0x34, B: 0x56, A: 0xff}) + } + } + + formats := []string{"jpeg", "png", "gif", "unknown"} + for _, fmtName := range formats { + tmp := t.TempDir() + fpath := filepath.Join(tmp, "out") + f, err := os.Create(filepath.Clean(fpath)) + if err != nil { + t.Fatalf("create file: %v", err) + } + // ensure close before decode + if err := outputImage(f, src, fmtName); err != nil { + // for unknown format we still expect PNG encoding + t.Fatalf("outputImage(%s) error: %v", fmtName, err) + } + if err := f.Close(); err != nil { + t.Fatalf("close file: %v", err) + } + // reopen and decode + rb, rerr := os.ReadFile(filepath.Clean(fpath)) + if rerr != nil { + t.Fatalf("read file: %v", rerr) + } + if _, _, derr := image.Decode(bytes.NewReader(rb)); derr != nil { + t.Fatalf("decoded output (%s) failed: %v", fmtName, derr) + } + } +} + +func TestOutputImage_ClosedDstReturnsError(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 4, 4)) + f, err := os.CreateTemp("", "closed-*") + if err != nil { + t.Fatalf("create temp: %v", err) + } + name := f.Name() + if err := f.Close(); err != nil { + t.Fatalf("close temp: %v", err) + } + // reopen read-only to ensure writes fail + ro, _ := os.Open(filepath.Clean(name)) + defer func() { _ = ro.Close(); _ = os.Remove(name) }() + if err := outputImage(ro, src, "png"); err == nil { + t.Fatalf("expected error when writing to non-writable file") + } +} + +func TestDownloadImage_ContentTypeWithCharset(t *testing.T) { + pngBytes := makeImageBytes(16, 8, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png; charset=utf-8") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/"} + out, err := w.DownloadImage(context.Background(), dest, false) + if err != nil { + t.Fatalf("DownloadImage failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + ext := filepath.Ext(out) + if ext != ".png" && ext != ".img" { + t.Fatalf("unexpected extension %q", ext) + } +} + +func TestDownloadImage_BadURL(t *testing.T) { + w := &Webinfo{ImageURL: "://bad-url"} + _, err := w.DownloadImage(context.Background(), "", true) + if err == nil { + t.Fatalf("expected error for bad URL, got nil") + } +} + +func TestDownloadImage_AppendExtWhenNoSrcExt(t *testing.T) { + // serve PNG at /images/pic (no extension in URL) + pngBytes := makeImageBytes(12, 6, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/images/pic"} + out, err := w.DownloadImage(context.Background(), dest, false) + if err != nil { + t.Fatalf("DownloadImage failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + want := filepath.Join(dest, "pic.png") + if out != want { + t.Fatalf("unexpected path: got %q want %q", out, want) + } + b, rerr := os.ReadFile(filepath.Clean(out)) + if rerr != nil { + t.Fatalf("read out file: %v", rerr) + } + if !bytes.Equal(b, pngBytes) { + t.Fatalf("content mismatch") + } +} + +func TestDownloadImage_TemporaryWithoutDestDirUsesOSTemp(t *testing.T) { + pngBytes := makeImageBytes(6, 3, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + out, err := w.DownloadImage(context.Background(), "", true) + if err != nil { + t.Fatalf("DownloadImage failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + // check that filename matches the expected temporary pattern + base := filepath.Base(out) + if !strings.HasPrefix(base, "webinfo-image-") { + t.Fatalf("temporary file name does not match pattern: %s", base) + } + if filepath.Ext(base) == "" { + t.Fatalf("temporary file missing extension: %s", base) + } +} + +func makeGIFBytes(w, h int) []byte { + pal := []color.Color{color.RGBA{R: 0xff, G: 0x00, B: 0x00, A: 0xff}, color.RGBA{R: 0x00, G: 0xff, B: 0x00, A: 0xff}} + img := image.NewPaletted(image.Rect(0, 0, w, h), pal) for y := 0; y < h; y++ { for x := 0; x < w; x++ { - img.SetRGBA(x, y, fill) + if (x+y)%2 == 0 { + img.SetColorIndex(x, y, 0) + } else { + img.SetColorIndex(x, y, 1) + } } } var buf bytes.Buffer - _ = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}) + _ = gif.Encode(&buf, img, nil) return buf.Bytes() } +func TestDownloadImage_GIF_SaveAndThumbnail(t *testing.T) { + gifBytes := makeGIFBytes(40, 20) + handler := func(wr http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/images/pic.gif" { + wr.Header().Set("Content-Type", "image/gif") + _, _ = wr.Write(gifBytes) + return + } + http.NotFound(wr, r) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/images/pic.gif"} + + // Test DownloadImage saves with .gif + out, err := w.DownloadImage(context.Background(), dest, false) + if err != nil { + t.Fatalf("DownloadImage failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + if filepath.Ext(out) != ".gif" { + t.Fatalf("expected .gif extension, got %q", filepath.Ext(out)) + } + + // Test DownloadThumbnail produces a GIF thumbnail when format is gif + thumb, err := w.DownloadThumbnail(context.Background(), dest, 20, false) + if err != nil { + t.Fatalf("DownloadThumbnail failed: %v", err) + } + defer func() { _ = os.Remove(thumb) }() + if filepath.Ext(thumb) != ".gif" { + t.Fatalf("expected thumbnail .gif extension, got %q", filepath.Ext(thumb)) + } + // open and decode + fb, ferr := os.ReadFile(filepath.Clean(thumb)) + if ferr != nil { + t.Fatalf("read thumb: %v", ferr) + } + if _, _, derr := image.Decode(bytes.NewReader(fb)); derr != nil { + t.Fatalf("decode thumb failed: %v", derr) + } +} + +func TestDownloadImage_MultipleExtensionsByType(t *testing.T) { + // ensure mime package returns multiple extensions for a custom type + // register two synthetic extensions for a custom content type; the code under test picks the last one + _ = mime.AddExtensionType(".ex1my", "image/x-mytest") + _ = mime.AddExtensionType(".ex2my", "image/x-mytest") + + pngBytes := makeImageBytes(10, 5, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + // omit filename extension; rely on Content-Type header + wr.Header().Set("Content-Type", "image/x-mytest") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + base := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/"} + out, err := w.DownloadImage(context.Background(), base, false) + if err != nil { + t.Fatalf("DownloadImage failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + + // expect the last extension added (".ex2my") to be chosen + if filepath.Ext(out) != ".ex2my" { + t.Fatalf("expected extension .ex2my, got %q", filepath.Ext(out)) + } +} + +func TestOutputImage_WriteFails(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 8, 8)) + // create a temp file and open it read-only to force write failure + tmp, err := os.CreateTemp("", "rofile-*") + if err != nil { + t.Fatalf("create temp: %v", err) + } + name := tmp.Name() + if err := tmp.Close(); err != nil { + t.Fatalf("close tmp: %v", err) + } + ro, err := os.Open(filepath.Clean(name)) + if err != nil { + t.Fatalf("open read-only: %v", err) + } + defer func() { _ = ro.Close(); _ = os.Remove(name) }() + if err := outputImage(ro, src, "png"); err == nil { + t.Fatalf("expected error when writing to read-only file") + } +} + +func TestDownloadImage_CreateFileFails(t *testing.T) { + pngBytes := makeImageBytes(6, 6, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + dest := t.TempDir() + // override createFile to simulate failure when creating permanent files + orig := createFile + defer func() { createFile = orig }() + createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if !temp { + return nil, errors.New("simulated permanent create failure") + } + return orig(temp, dir, pathOrPattern) + } + + w := &Webinfo{ImageURL: srv.URL + "/images/pic.png"} + _, err := w.DownloadImage(context.Background(), dest, false) + if err == nil { + t.Fatalf("expected error when permanent file creation fails, got nil") + } +} + +func TestDownloadImage_TemporaryCreateFails(t *testing.T) { + pngBytes := makeImageBytes(8, 8, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngBytes) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + // override createFile to simulate failure when creating temporary files + orig := createFile + defer func() { createFile = orig }() + createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if temp { + return nil, errors.New("simulated temp create failure") + } + return orig(temp, dir, pathOrPattern) + } + + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + _, err := w.DownloadImage(context.Background(), t.TempDir(), true) + if err == nil { + t.Fatalf("expected error when temporary file creation fails, got nil") + } +} + +func TestDownloadThumbnail_TemporaryCreateFails(t *testing.T) { + pngData := makeImageBytes(80, 40, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // override createFile to simulate failure when creating any temporary file + orig := createFile + defer func() { createFile = orig }() + createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if temp { + return nil, errors.New("simulated temp create failure") + } + return orig(temp, dir, pathOrPattern) + } + + w := &Webinfo{ImageURL: srv.URL + "/images/pic.png"} + _, err := w.DownloadThumbnail(context.Background(), t.TempDir(), 50, true) + if err == nil { + t.Fatalf("expected error when temporary thumbnail creation fails, got nil") + } +} + +func TestDownloadThumbnail_OutputCreateFails(t *testing.T) { + // failure only for thumbnail output file, not for the intermediate original image download + pngData := makeImageBytes(80, 40, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // override createFile to simulate failure only for thumbnail temporary file + orig := createFile + defer func() { createFile = orig }() + createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if temp && strings.Contains(pathOrPattern, "webinfo-thumb-") { + return nil, errors.New("simulated thumbnail temp create failure") + } + return orig(temp, dir, pathOrPattern) + } + + // Use a dest dir for the thumbnail so createFile is called for the thumb + w := &Webinfo{ImageURL: srv.URL + "/images/pic.png"} + _, err := w.DownloadThumbnail(context.Background(), t.TempDir(), 50, true) + if err == nil { + t.Fatalf("expected error when thumbnail temporary creation fails, got nil") + } +} + func TestDownloadThumbnail_NilReceiver(t *testing.T) { var w *Webinfo _, err := w.DownloadThumbnail(context.Background(), "", 100, true) @@ -158,9 +723,31 @@ func TestDownloadThumbnail_NilReceiver(t *testing.T) { } } +func TestDownloadImage_HTTPClientTimeout(t *testing.T) { + // server delays longer than client timeout + handler := func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngSig) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + // override newHTTPClient to return a short timeout + orig := newHTTPClient + defer func() { newHTTPClient = orig }() + newHTTPClient = func() *http.Client { return &http.Client{Timeout: 50 * time.Millisecond} } + + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + _, err := w.DownloadImage(context.Background(), t.TempDir(), true) + if err == nil { + t.Fatalf("expected timeout error, got nil") + } +} + func TestDownloadThumbnail_TemporaryPNG_DefaultWidth(t *testing.T) { // original image 200x100 -> expected thumbnail width 150 (default), height 75 - pngBytes := makePNGBytes(200, 100) + pngBytes := makeImageBytes(200, 100, "png", 0x11, 0x88, 0x22) handler := func(wr http.ResponseWriter, r *http.Request) { wr.Header().Set("Content-Type", "image/png") _, _ = wr.Write(pngBytes) @@ -200,7 +787,7 @@ func TestDownloadThumbnail_TemporaryPNG_DefaultWidth(t *testing.T) { func TestDownloadThumbnail_NonTemporaryJPEG_FilenameDerived(t *testing.T) { // original 100x100 -> thumbnail 50x50 - jpgBytes := makeJPEGBytes(100, 100) + jpgBytes := makeImageBytes(100, 100, "jpeg", 0x11, 0x88, 0x22) handler := func(wr http.ResponseWriter, r *http.Request) { wr.Header().Set("Content-Type", "image/jpeg") _, _ = wr.Write(jpgBytes) @@ -214,7 +801,7 @@ func TestDownloadThumbnail_NonTemporaryJPEG_FilenameDerived(t *testing.T) { if err != nil { t.Fatalf("DownloadThumbnail failed: %v", err) } - want := filepath.Join(dest, "pic-thums.jpg") + want := filepath.Join(dest, "pic-thumb.jpg") if out != want { t.Fatalf("unexpected path: got %q want %q", out, want) } @@ -232,6 +819,255 @@ func TestDownloadThumbnail_NonTemporaryJPEG_FilenameDerived(t *testing.T) { } } +func TestDownloadThumbnail_MkdirAllFails(t *testing.T) { + // create a file where a directory is expected so MkdirAll fails + base := t.TempDir() + blocker := filepath.Join(base, "blocked") + if err := os.WriteFile(filepath.Clean(blocker), []byte("notadir"), 0o600); err != nil { + t.Fatalf("create blocker file: %v", err) + } + + // dest path includes the blocker as a path component; MkdirAll should fail + dest := filepath.Join(blocker, "nested") + + w := &Webinfo{ImageURL: "http://example.invalid/img.png"} + _, err := w.DownloadThumbnail(context.Background(), dest, 100, true) + if err == nil { + t.Fatalf("expected error when MkdirAll cannot create directories, got nil") + } +} + +func TestDownloadThumbnail_BaseFallbackWhenURLHasNoBasename(t *testing.T) { + pngData := makeImageBytes(120, 60, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + dest := t.TempDir() + // URL ends with '/', so basename logic should fall back to "webinfo-image" + w := &Webinfo{ImageURL: srv.URL + "/"} + out, err := w.DownloadThumbnail(context.Background(), dest, 30, false) + if err != nil { + t.Fatalf("DownloadThumbnail failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + want := filepath.Join(dest, "webinfo-image-thumb.png") + if out != want { + t.Fatalf("unexpected thumbnail path: got %q want %q", out, want) + } +} + +func TestDownloadImage_NoImageURL(t *testing.T) { + w := &Webinfo{ImageURL: ""} + _, err := w.DownloadImage(context.Background(), "", true) + if err == nil { + t.Fatalf("expected error for empty ImageURL, got nil") + } +} + +func TestDownloadThumbnail_ZeroOrigDimensions(t *testing.T) { + // server returns a small PNG but we override decodeImage to return a + // zero-width image to exercise the origW==0 || origH==0 error path. + pngData := makeImageBytes(4, 4, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngData) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + // override decodeImage + origDecode := decodeImage + defer func() { decodeImage = origDecode }() + decodeImage = func(r io.Reader) (image.Image, string, error) { + return zeroImg{}, "png", nil + } + + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + _, err := w.DownloadThumbnail(context.Background(), t.TempDir(), 50, true) + if err == nil { + t.Fatalf("expected error when decoded image has zero dimension, got nil") + } +} + +func TestDownloadThumbnail_HeightClampedToOne(t *testing.T) { + // original image very wide but 1px tall -> newH may round to 0 and should be clamped to 1 + pngData := makeImageBytes(1000, 1, "png", 0x11, 0x88, 0x22) + handler := func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngData) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/images/wide.png"} + out, err := w.DownloadThumbnail(context.Background(), dest, 1, true) + if err != nil { + t.Fatalf("DownloadThumbnail failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + + fb, err := os.ReadFile(filepath.Clean(out)) + if err != nil { + t.Fatalf("read thumbnail: %v", err) + } + img, _, derr := image.Decode(bytes.NewReader(fb)) + if derr != nil { + t.Fatalf("decode thumbnail: %v", derr) + } + if img.Bounds().Dy() != 1 { + t.Fatalf("thumbnail height clamped to 1: got %d", img.Bounds().Dy()) + } +} + +func TestDownloadThumbnail_CreateFileFailsWhenDestFileReadOnly(t *testing.T) { + pngData := makeImageBytes(80, 40, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/images/pic.png" { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + return + } + http.NotFound(w, r) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + dest := t.TempDir() + // override createFile to simulate failure when creating permanent thumbnail file + orig := createFile + defer func() { createFile = orig }() + createFile = func(temp bool, dir, pathOrPattern string) (*os.File, error) { + if !temp { + return nil, errors.New("simulated permanent thumbnail create failure") + } + return orig(temp, dir, pathOrPattern) + } + + w := &Webinfo{ImageURL: srv.URL + "/images/pic.png"} + _, err := w.DownloadThumbnail(context.Background(), dest, 20, false) + if err == nil { + t.Fatalf("expected error when permanent thumbnail creation fails, got nil") + } +} + +func TestDownloadThumbnail_SmallDimensions(t *testing.T) { + // small original image -> request very small width + pngData := makeImageBytes(2, 1, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/small.png"} + // request width 1 (small) and non-temporary output + out, err := w.DownloadThumbnail(context.Background(), dest, 1, false) + if err != nil { + t.Fatalf("DownloadThumbnail failed for small dimensions: %v", err) + } + defer func() { _ = os.Remove(out) }() + f, err := os.Open(filepath.Clean(out)) + if err != nil { + t.Fatalf("open thumb: %v", err) + } + defer func() { _ = f.Close() }() + img, _, derr := image.Decode(f) + if derr != nil { + t.Fatalf("decode thumb: %v", derr) + } + if img.Bounds().Dx() != 1 { + t.Fatalf("unexpected thumb width: got %d want %d", img.Bounds().Dx(), 1) + } + if img.Bounds().Dy() < 1 { + t.Fatalf("unexpected thumb height: got %d want >=%d", img.Bounds().Dy(), 1) + } +} + +func TestDownloadThumbnail_OutputImageFails(t *testing.T) { + // serve a PNG and then override outputImage to simulate encoder failure + pngData := makeImageBytes(40, 20, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + + // swap out outputImage and restore after test + orig := outputImage + outputImage = func(dst *os.File, src *image.RGBA, format string) error { + return errors.New("simulated encoder failure") + } + defer func() { outputImage = orig }() + + _, err := w.DownloadThumbnail(context.Background(), dest, 10, false) + if err == nil { + t.Fatalf("expected error when outputImage fails, got nil") + } +} + +func TestDownloadThumbnail_DownloadImageFails(t *testing.T) { + // Use an invalid ImageURL so DownloadImage fails early + w := &Webinfo{ImageURL: "://bad-url"} + _, err := w.DownloadThumbnail(context.Background(), "", 100, true) + if err == nil { + t.Fatalf("expected error when DownloadImage fails, got nil") + } +} + +func TestDownloadThumbnail_DecodeFails(t *testing.T) { + // server will return non-image content so decoding fails + handler := http.HandlerFunc(func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "text/plain") + _, _ = wr.Write([]byte("not an image")) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + dest := t.TempDir() + w := &Webinfo{ImageURL: srv.URL + "/not-image"} + _, err := w.DownloadThumbnail(context.Background(), dest, 50, false) + if err == nil { + t.Fatalf("expected error when decoding original image fails, got nil") + } +} + +func TestDownloadThumbnail_TemporaryDefaultDest(t *testing.T) { + // ensure when destDir is empty and temporary=true, thumbnail is created in OS temp + pngData := makeImageBytes(40, 20, "png", 0x11, 0x88, 0x22) + handler := http.HandlerFunc(func(wr http.ResponseWriter, r *http.Request) { + wr.Header().Set("Content-Type", "image/png") + _, _ = wr.Write(pngData) + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + w := &Webinfo{ImageURL: srv.URL + "/img.png"} + out, err := w.DownloadThumbnail(context.Background(), "", 30, true) + if err != nil { + t.Fatalf("DownloadThumbnail failed: %v", err) + } + defer func() { _ = os.Remove(out) }() + base := filepath.Base(out) + if !strings.HasPrefix(base, "webinfo-thumb-") { + t.Fatalf("temporary thumbnail filename does not match pattern: %s", base) + } + ext := filepath.Ext(base) + if ext != ".png" && ext != ".img" { + t.Fatalf("unexpected temporary thumbnail extension: %s", ext) + } +} + /* Copyright 2025 Spiegel * * Licensed under the Apache License, Version 2.0 (the "License");