//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") } }