feat(gopher): add resume and metadata support
This commit is contained in:
@@ -8,11 +8,14 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/petrbalvin/goget/internal/core"
|
||||
"codeberg.org/petrbalvin/goget/internal/output"
|
||||
)
|
||||
|
||||
func TestFormatGopherMenu(t *testing.T) {
|
||||
@@ -108,3 +111,309 @@ func TestGopherDownloadMock(t *testing.T) {
|
||||
t.Errorf("output missing text: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeSaveMetadata verifies that interrupting a Gopher text
|
||||
// download persists a .goget.meta sidecar describing the partial state.
|
||||
func TestGopherResumeSaveMetadata(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
reader.ReadString('\n')
|
||||
// Send the first display line, sleep so the client has a chance
|
||||
// to land in the read loop and receive the cancellation, then
|
||||
// send the second line and the terminator.
|
||||
conn.Write([]byte("hFirst\t/first\thost\t70\r\n"))
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
conn.Write([]byte("hSecond\t/second\thost\t70\r\n"))
|
||||
conn.Write([]byte(".\r\n"))
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/page.html"
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/page")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
|
||||
// Cancel mid-download. The slow server keeps the connection open
|
||||
// long enough for the cancellation to fire between reads.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
dlErr := <-errCh
|
||||
if dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
metaPath := outPath + output.ResumeMetaFileSuffix
|
||||
if _, statErr := os.Stat(metaPath); statErr != nil {
|
||||
t.Fatalf("expected .goget.meta sidecar to exist: %v", statErr)
|
||||
}
|
||||
|
||||
meta, metaErr := output.LoadResumeMetadata(outPath)
|
||||
if metaErr != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", metaErr)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded <= 0 {
|
||||
t.Errorf("Downloaded = %d, want > 0", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != u.String() {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, u.String())
|
||||
}
|
||||
// Gopher does not expose Content-Length, so Total stays 0.
|
||||
if meta.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", meta.Total)
|
||||
}
|
||||
// Sanity-check: the saved Downloaded should match what's on disk.
|
||||
info, statErr := os.Stat(outPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("partial file missing: %v", statErr)
|
||||
}
|
||||
if info.Size() != meta.Downloaded {
|
||||
t.Errorf("file size = %d, metadata.Downloaded = %d (must match)",
|
||||
info.Size(), meta.Downloaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGopherResumeDownload verifies end-to-end resume: an interrupted
|
||||
// download completes on the second attempt with --resume=true, and the
|
||||
// metadata sidecar is removed after the successful completion.
|
||||
func TestGopherResumeDownload(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
cancelReady := make(chan struct{})
|
||||
connReady := make(chan struct{})
|
||||
go func() {
|
||||
for i := 0; i < 2; i++ {
|
||||
conn, lerr := listener.Accept()
|
||||
if lerr != nil {
|
||||
return
|
||||
}
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
if i == 0 {
|
||||
// First attempt: send the first line, then wait
|
||||
// for the client to cancel before closing.
|
||||
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
||||
<-cancelReady
|
||||
conn.Close()
|
||||
connReady <- struct{}{}
|
||||
} else {
|
||||
// Second attempt: send the full body.
|
||||
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n"))
|
||||
conn.Write([]byte("0Line two\t/bar\thost\t70\r\n"))
|
||||
conn.Write([]byte(".\r\n"))
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/resume.txt"
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
|
||||
|
||||
// First attempt: send only the first line, then cancel.
|
||||
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
})
|
||||
errCh <- dlErr
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel1()
|
||||
cancelReady <- struct{}{}
|
||||
<-connReady
|
||||
dlErr := <-errCh
|
||||
if dlErr == nil {
|
||||
t.Fatal("expected error from cancelled context")
|
||||
}
|
||||
|
||||
// Sanity-check: partial file + metadata sidecar present.
|
||||
info, statErr := os.Stat(outPath)
|
||||
if statErr != nil {
|
||||
t.Fatalf("partial file missing: %v", statErr)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("partial file is empty")
|
||||
}
|
||||
meta, metaErr := output.LoadResumeMetadata(outPath)
|
||||
if metaErr != nil || meta == nil {
|
||||
t.Fatalf("expected resume metadata: err=%v meta=%v", metaErr, meta)
|
||||
}
|
||||
|
||||
// Second attempt: resume should pick up where we left off.
|
||||
result, resErr := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if resErr != nil {
|
||||
t.Fatalf("resume download failed: %v", resErr)
|
||||
}
|
||||
if !result.Resumed {
|
||||
t.Error("expected Resumed=true")
|
||||
}
|
||||
|
||||
// The file should contain the full body, with the resume logic
|
||||
// skipping already-written bytes via the post-transform offset.
|
||||
data, readErr := os.ReadFile(outPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
want := "Line one\nLine two\n"
|
||||
if string(data) != want {
|
||||
t.Errorf("file content = %q, want %q", string(data), want)
|
||||
}
|
||||
|
||||
// Metadata sidecar should be deleted after a successful completion.
|
||||
if output.FileExists(outPath + output.ResumeMetaFileSuffix) {
|
||||
t.Error("expected .goget.meta to be deleted after successful resume")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGopherResumeMetadata exercises the helper directly.
|
||||
func TestSaveGopherResumeMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
out := tmp + "/file.txt"
|
||||
|
||||
u, err := url.Parse("gopher://example.com/foo.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL: %v", err)
|
||||
}
|
||||
req := &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: out,
|
||||
}
|
||||
|
||||
saveGopherResume(req, 4096)
|
||||
|
||||
meta, err := output.LoadResumeMetadata(out)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadResumeMetadata: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatal("expected resume metadata, got nil")
|
||||
}
|
||||
if meta.Downloaded != 4096 {
|
||||
t.Errorf("Downloaded = %d, want 4096", meta.Downloaded)
|
||||
}
|
||||
if meta.URL != "gopher://example.com/foo.txt" {
|
||||
t.Errorf("URL = %q, want %q", meta.URL, "gopher://example.com/foo.txt")
|
||||
}
|
||||
if meta.Total != 0 {
|
||||
t.Errorf("Total = %d, want 0", meta.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveGopherResumeNoop verifies that the helper is a no-op for
|
||||
// stdout writes, empty output paths, zero-byte transfers, and nil
|
||||
// requests.
|
||||
func TestSaveGopherResumeNoop(t *testing.T) {
|
||||
u, _ := url.Parse("gopher://example.com/")
|
||||
|
||||
t.Run("stdout", func(t *testing.T) {
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: "-"}, 100)
|
||||
})
|
||||
|
||||
t.Run("empty output", func(t *testing.T) {
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: ""}, 100)
|
||||
})
|
||||
|
||||
t.Run("zero bytes", func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
saveGopherResume(&core.DownloadRequest{URL: u, Output: tmp + "/f"}, 0)
|
||||
if output.FileExists(tmp + "/f" + output.ResumeMetaFileSuffix) {
|
||||
t.Error("metadata created for zero-byte transfer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil request", func(t *testing.T) {
|
||||
saveGopherResume(nil, 100)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGopherResumeStaleMetadata exercises the path where the .goget.meta
|
||||
// sidecar exists but the partial file doesn't (or has been truncated),
|
||||
// forcing the resume code to clean up and start fresh.
|
||||
func TestGopherResumeStaleMetadata(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
outPath := tmp + "/stale.txt"
|
||||
|
||||
// Pre-create an orphan .goget.meta for a URL we will not use.
|
||||
orphanMeta := output.NewResumeMetadata("gopher://other.example/foo", "", "", 1234, 0)
|
||||
if err := orphanMeta.Save(outPath); err != nil {
|
||||
t.Fatalf("seed meta: %v", err)
|
||||
}
|
||||
// Do not pre-create the partial file; CanResume requires file size
|
||||
// to match metadata Downloaded.
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
go func() {
|
||||
conn, _ := listener.Accept()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
r.ReadString('\n')
|
||||
conn.Write([]byte("0Fresh\t/fresh\thost\t70\r\n.\r\n"))
|
||||
}()
|
||||
|
||||
proto := NewProtocol()
|
||||
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/fresh")
|
||||
|
||||
res, err := proto.Download(context.Background(), &core.DownloadRequest{
|
||||
URL: u,
|
||||
Output: outPath,
|
||||
Resume: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
if res.Resumed {
|
||||
t.Error("expected Resumed=false after stale-metadata cleanup")
|
||||
}
|
||||
data, _ := os.ReadFile(outPath)
|
||||
if string(data) != "Fresh\n" {
|
||||
t.Errorf("content = %q, want %q", string(data), "Fresh\n")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user