Files
goget/internal/protocol/gopher/gopher_test.go
T

491 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build linux || freebsd
// +build linux freebsd
package gopher
import (
"bufio"
"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) {
tests := []struct {
line string
expected string
}{
{"0About\t/about\tgopher.floodgap.com\t70\r\n", "📄 About [/about]"},
{"1Fun\t/fun\tgopher.floodgap.com\t70\r\n", "📁 Fun [/fun]"},
{"iWelcome to Gopher\t(null)\t(null)\t0\r\n", "️ Welcome to Gopher"},
{"hNews\t/news\tgopher.floodgap.com\t70\r\n", "🌐 News [/news]"},
}
for _, tc := range tests {
result := formatGopherMenu(tc.line)
if result != tc.expected {
t.Errorf("formatGopherMenu(%q) = %q, want %q", tc.line, result, tc.expected)
}
}
}
func TestStripGopherMeta(t *testing.T) {
result := stripGopherMeta("0About\t/about\thost\t70")
if result != "About" {
t.Errorf("got %q, want %q", result, "About")
}
}
func TestGopherIcon(t *testing.T) {
if i := gopherIcon('0'); i != "📄" {
t.Errorf("got %q", i)
}
if i := gopherIcon('1'); i != "📁" {
t.Errorf("got %q", i)
}
if i := gopherIcon('9'); i != "💾" {
t.Errorf("got %q", i)
}
if i := gopherIcon('h'); i != "🌐" {
t.Errorf("got %q", i)
}
if i := gopherIcon('7'); i != "🔍" {
t.Errorf("got %q", i)
}
}
func TestIsTextGopherType(t *testing.T) {
if !isTextGopherType('0') {
t.Error("0 should be text")
}
if isTextGopherType('9') {
t.Error("9 should not be text")
}
}
type stringWriter struct{ sb *strings.Builder }
func (w *stringWriter) Write(p []byte) (int, error) { return w.sb.Write(p) }
func TestGopherDownloadMock(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') // selector
conn.Write([]byte("0Hello from gopher\t(null)\t(null)\t0\r\n"))
conn.Write([]byte(".\r\n"))
}()
proto := NewProtocol()
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/")
var buf strings.Builder
_, err = proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Writer: &stringWriter{&buf},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(buf.String(), "Hello from gopher") {
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")
// progress is signalled every time the download loop calls
// req.ProgressCallback — which happens *after* a successful
// writer.Write. Reading from it is therefore a deterministic
// barrier waiting for the first byte to land on disk.
progress := make(chan int64, 1)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, dlErr := proto.Download(ctx, &core.DownloadRequest{
URL: u,
Output: outPath,
ProgressCallback: func(current, total int64, speed float64) {
select {
case progress <- current:
default:
}
},
})
errCh <- dlErr
}()
// Wait until the first byte has been written to disk, then cancel.
// No fixed sleep — this is robust against slow CI schedulers.
<-progress
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.
// progress signals after the first writer.Write, so waiting on
// it replaces the previous fixed sleep with a deterministic barrier.
progress := make(chan int64, 1)
ctx1, cancel1 := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
_, dlErr := proto.Download(ctx1, &core.DownloadRequest{
URL: u,
Output: outPath,
ProgressCallback: func(current, total int64, speed float64) {
select {
case progress <- current:
default:
}
},
})
errCh <- dlErr
}()
<-progress
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")
}
}
// TestGopherResumeIgnoredWhenWriterSet verifies that --resume is silently
// disabled when the caller also supplies a Writer: the resume metadata
// sidecar would otherwise lie about bytes written to Output, since the
// real data ends up in the Writer.
func TestGopherResumeIgnoredWhenWriterSet(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()
r := bufio.NewReader(conn)
r.ReadString('\n')
conn.Write([]byte("0Line one\t/foo\thost\t70\r\n.\r\n"))
}()
proto := NewProtocol()
u, _ := url.Parse("gopher://127.0.0.1:" + strconv.Itoa(port) + "/foo")
outPath := t.TempDir() + "/ignored-output.txt"
var buf strings.Builder
res, dlErr := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: outPath,
Writer: &stringWriter{&buf},
Resume: true,
})
if dlErr != nil {
t.Fatalf("download: %v", dlErr)
}
if res.Resumed {
t.Error("expected Resumed=false when Writer is set (resume must be ignored)")
}
if !strings.Contains(buf.String(), "Line one") {
t.Errorf("writer should receive the body, got %q", buf.String())
}
// Output is supplied but a Writer takes precedence; the file must
// not be created or touched by resume machinery.
if _, statErr := os.Stat(outPath); statErr == nil {
t.Error("Output file should not be created when Writer is set")
}
}