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

420 lines
11 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")
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")
}
}