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
[](https://github.com/goark/webinfo/actions)
[](https://raw.githubusercontent.com/goark/webinfo/master/LICENSE)
[](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");