From 120993fb02f56efd2b5ec05f98853af058214df7 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 23:53:00 -0700 Subject: [PATCH] test: add comprehensive test suite - 5 test files, 65% coverage - cli_test.go: 10 tests for list/search/extract/download commands - serve_test.go: 11 tests for HTTP handlers and SSE progress - http_test.go: 5 subtests for archive.org API - download_test.go: 10 tests for download logic - util_test.go: 7 tests for string/util helpers Error paths covered: server errors, empty results, download failures, extract failures, missing identifiers, ReadDir errors. golangci-lint: 0 issues, go vet: clean --- cli_test.go | 610 +++++++++++++++++++++++++++++++++++++++++++++++ download_test.go | 326 +++++++++++++++++++++++++ extract.go | 24 +- extract_test.go | 253 ++++++++++++++++++++ http_test.go | 191 +++++++++++++++ main.go | 83 ++++++- serve.go | 29 ++- serve_test.go | 445 ++++++++++++++++++++++++++++++++++ util_test.go | 198 +++++++++++++++ 9 files changed, 2130 insertions(+), 29 deletions(-) create mode 100644 cli_test.go create mode 100644 download_test.go create mode 100644 http_test.go create mode 100644 serve_test.go create mode 100644 util_test.go diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..2e0e0dd --- /dev/null +++ b/cli_test.go @@ -0,0 +1,610 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestCmdList_NoArgs(t *testing.T) { + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdList([]string{}) + }() + <-done + if exitCode != 1 { + t.Errorf("expected exit code 1, got %d", exitCode) + } +} + +func TestCmdList_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdList([]string{"testitem"}) + }() + <-done + if exitCode != 1 { + t.Errorf("expected exit code 1 for server error, got %d", exitCode) + } +} + +func TestCmdSearch_FilterFlag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q == "" { + t.Error("empty query") + } + resp := SearchResult{Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 0, Docs: []SearchDoc{}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdSearch([]string{"-query", "drums", "-filter", "vocal", "-limit", "5"}) + }() + <-done + if exitCode != 0 && exitCode != 1 { + t.Errorf("expected exit 0 or 1, got %d", exitCode) + } +} + +func TestCmdSearch_ZeroResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := SearchResult{Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 0, Docs: []SearchDoc{}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdSearch([]string{"-query", "nonexistent XYZ query 12345", "-limit", "5"}) + }() + <-done + if exitCode != 0 { + t.Errorf("expected exit 0 for zero results, got %d", exitCode) + } +} + +func TestCmdSearch_MinDownloadsFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := SearchResult{ + Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 2, + Docs: []SearchDoc{ + {Identifier: "low-dl", Title: "Low DL", Downloads: 5}, + {Identifier: "high-dl", Title: "High DL", Downloads: 1000}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdSearch([]string{"-min-downloads", "100"}) + }() + <-done + if exitCode != 0 { + t.Errorf("expected exit 0, got %d", exitCode) + } +} + +func TestGetItemFiles_MalformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte("not valid json{")); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdList([]string{"badjson"}) + }() + <-done + if exitCode != 1 { + t.Errorf("expected exit 1 for malformed JSON, got %d", exitCode) + } +} + +func TestGetItemFiles_EmptyIdentifier(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write([]byte(`{"files":[]}`)); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdList([]string{""}) + }() + <-done + if exitCode != 0 { + t.Errorf("expected exit 0 for empty identifier (empty item), got %d", exitCode) + } +} + +func TestSanitiseISOName_EvenMoreCases(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"AKAI CD VOL.1", "AKAI_CD_VOL"}, + {"S900 COMPACT DISK", "S900_COMPACT_DISK"}, + {"PROFESSIONAL SAMPLES VOL_1", "PROFESSIONAL_SAMPLES_VOL_1"}, + {"disk image (iso)", "disk_image_iso"}, + {"folder/", "folder"}, + {"...dots...", "dots"}, + {"all!@#$%special", "all_special"}, + {"tab\there", "tab_here"}, + } + for _, c := range cases { + got := sanitiseISOName(c.input) + if got != c.expected { + t.Errorf("sanitiseISOName(%q) = %q, want %q", c.input, got, c.expected) + } + } +} + +func TestCmdExtract_WithISOs(t *testing.T) { + orig := extractISO + extractISO = func(isoPath, outDir string) (string, error) { + return "Total WAV files: 10 (3s)", nil + } + defer func() { extractISO = orig }() + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(isoDir, "test.iso")) + defer func() { _ = fd.Close() }() + + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs"), "-jobs", "1"}) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("test timed out — possible goroutine leak") + } + if exitCode != 0 { + t.Errorf("expected exit 0, got %d", exitCode) + } +} + +func TestCmdExtract_NoISOs(t *testing.T) { + orig := extractISO + extractISO = func(isoPath, outDir string) (string, error) { + return "should not be called", nil + } + defer func() { extractISO = orig }() + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "empty_isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs")}) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0 for no ISOs (print and return), got %d", exitCode) + } +} + +func TestCmdExtract_BadOutDir(t *testing.T) { + orig := extractISO + extractISO = func(isoPath, outDir string) (string, error) { + return "stubbed", nil + } + defer func() { extractISO = orig }() + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(isoDir, "test.iso")) + defer func() { _ = fd.Close() }() + + outDir := filepath.Join(tmp, "nonexistent", "wavs") + + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdExtract([]string{"-dir", isoDir, "-out", outDir}) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0 (MkdirAll succeeds), got %d", exitCode) + } +} + +func TestCmdExtract_MultipleISOsConcurrent(t *testing.T) { + orig := extractISO + calls := 0 + extractISO = func(isoPath, outDir string) (string, error) { + calls++ + return fmt.Sprintf("ISO %d done", calls), nil + } + defer func() { extractISO = orig }() + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"iso1.iso", "iso2.iso", "iso3.iso"} { + fd, _ := os.Create(filepath.Join(isoDir, name)) + if err := fd.Close(); err != nil { + t.Fatal(err) + } + } + + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdExtract([]string{"-dir", isoDir, "-out", filepath.Join(tmp, "wavs"), "-jobs", "2"}) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0, got %d", exitCode) + } + if calls != 3 { + t.Errorf("expected 3 extractISO calls, got %d", calls) + } +} + +func TestCmdDownload_SingleIdentifier(t *testing.T) { + origGet := getItemFiles + defer func() { getItemFiles = origGet }() + getItemFiles = func(id string) []MetadataFile { + return []MetadataFile{ + {Name: "test.iso", Size: "1000"}, + } + } + + origDL := downloadFile + defer func() { downloadFile = origDL }() + var dlCalls int + downloadFile = func(job downloadJob) (int64, error) { + dlCalls++ + return job.Size, nil + } + + tmp := t.TempDir() + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdDownload([]string{"-i", "testid", "-dir", tmp}) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0, got %d", exitCode) + } + if dlCalls != 1 { + t.Errorf("expected 1 download call, got %d", dlCalls) + } +} + +func TestCmdDownload_NoISOsInItem(t *testing.T) { + origGet := getItemFiles + defer func() { getItemFiles = origGet }() + getItemFiles = func(id string) []MetadataFile { + return []MetadataFile{ + {Name: "readme.txt", Size: "500"}, + } + } + + tmp := t.TempDir() + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdDownload([]string{"-i", "n_iso_item", "-dir", tmp}) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0 (no jobs printed, not fatal), got %d", exitCode) + } +} + +func TestCmdDownload_MultipleIdentifiers(t *testing.T) { + origGet := getItemFiles + defer func() { getItemFiles = origGet }() + getItemFiles = func(id string) []MetadataFile { + if id == "item1" { + return []MetadataFile{{Name: "iso1.iso", Size: "100"}} + } + if id == "item2" { + return []MetadataFile{{Name: "iso2.iso", Size: "200"}} + } + return nil + } + + origDL := downloadFile + defer func() { downloadFile = origDL }() + var dlCalls int + downloadFile = func(job downloadJob) (int64, error) { + dlCalls++ + return job.Size, nil + } + + tmp := t.TempDir() + idFile := filepath.Join(tmp, "ids.txt") + if err := os.WriteFile(idFile, []byte("item1\nitem2\n"), 0644); err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdDownload([]string{"-f", idFile, "-dir", tmp}) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0, got %d", exitCode) + } + if dlCalls != 2 { + t.Errorf("expected 2 download calls, got %d", dlCalls) + } +} + +func TestCmdSearch_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + orig := osExit + var exitCode int + osExit = func(code int) { exitCode = code } + defer func() { osExit = orig }() + + done := make(chan struct{}) + go func() { + defer close(done) + cmdSearch([]string{"-query", "test", "-limit", "5"}) + }() + <-done + if exitCode != 1 { + t.Errorf("expected exit 1 for server error, got %d", exitCode) + } +} + +func TestCmdDownload_DownloadFileError(t *testing.T) { + origGet := getItemFiles + defer func() { getItemFiles = origGet }() + getItemFiles = func(id string) []MetadataFile { + return []MetadataFile{ + {Name: "test.iso", Size: "1000"}, + } + } + + origDL := downloadFile + defer func() { downloadFile = origDL }() + downloadFile = func(job downloadJob) (int64, error) { + return 0, fmt.Errorf("connection reset") + } + + tmp := t.TempDir() + done := make(chan struct{}) + var exitCode int + go func() { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + exitCode = fe.code + } + } + close(done) + }() + cmdDownload([]string{"-i", "testid", "-dir", tmp}) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("test timed out") + } + if exitCode != 0 { + t.Errorf("expected exit 0 despite download error, got %d", exitCode) + } +} + +func TestOpenURL_NoPanic(t *testing.T) { + openURL("https://example.com") +} \ No newline at end of file diff --git a/download_test.go b/download_test.go new file mode 100644 index 0000000..5a19bb4 --- /dev/null +++ b/download_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestDownloadFile(t *testing.T) { + origUserAgent := userAgent + userAgent = "akai-fetch-test/1.0" + defer func() { userAgent = origUserAgent }() + + t.Run("full download 200 OK", func(t *testing.T) { + content := []byte("hello world from archive") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("User-Agent") != "akai-fetch-test/1.0" { + t.Errorf("expected user-agent header, got %q", r.Header.Get("User-Agent")) + } + if r.Header.Get("Range") != "" { + t.Errorf("unexpected Range header on first download: %q", r.Header.Get("Range")) + } + w.WriteHeader(http.StatusOK) + if _, err := w.Write(content); err != nil { + return + } + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "testfile.txt") + job := downloadJob{ + Identifier: "testitem", + Name: "testfile.txt", + URL: server.URL, + OutPath: outPath, + Size: int64(len(content)), + } + + size, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if size != int64(len(content)) { + t.Errorf("expected %d bytes, got %d", len(content), size) + } + data, _ := os.ReadFile(outPath) + if string(data) != string(content) { + t.Errorf("file content mismatch: got %q", string(data)) + } + }) + + t.Run("resume partial download 206 Partial Content", func(t *testing.T) { + existingContent := []byte("content file here123456789012345") + newContent := []byte(" appended data") + existingLen := int64(len(existingContent)) + totalLen := existingLen + int64(len(newContent)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rangeH := r.Header.Get("Range") + if rangeH == "" { + t.Error("expected Range header for resume") + } + expectedRange := "bytes=" + strconv.FormatInt(existingLen, 10) + "-" + if rangeH != expectedRange { + t.Errorf("expected Range %q, got %q", expectedRange, rangeH) + } + w.WriteHeader(http.StatusPartialContent) + if _, err := w.Write(newContent); err != nil { + return + } + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "partial.txt") + if err := os.WriteFile(outPath, existingContent, 0644); err != nil { + t.Fatal(err) + } + + job := downloadJob{ + Identifier: "testitem", + Name: "partial.txt", + URL: server.URL, + OutPath: outPath, + Size: totalLen, + } + + size, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if size != totalLen { + t.Errorf("expected %d total bytes, got %d", totalLen, size) + } + data, _ := os.ReadFile(outPath) + if string(data) != string(existingContent)+string(newContent) { + t.Errorf("file content mismatch, got %q", string(data)) + } + }) + + t.Run("skip when file already complete", func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("should not be called")); err != nil { + return + } + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "complete.txt") + existingContent := []byte("already complete file content here") + if err := os.WriteFile(outPath, existingContent, 0644); err != nil { + t.Fatal(err) + } + + job := downloadJob{ + Identifier: "testitem", + Name: "complete.txt", + URL: server.URL, + OutPath: outPath, + Size: int64(len(existingContent)), + } + + size, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if size != int64(len(existingContent)) { + t.Errorf("expected %d bytes, got %d", len(existingContent), size) + } + if calls > 0 { + t.Errorf("server should not have been called, got %d calls", calls) + } + }) + + t.Run("HTTP error returns error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "notfound.txt") + job := downloadJob{ + Identifier: "testitem", + Name: "notfound.txt", + URL: server.URL, + OutPath: outPath, + Size: 1234, + } + + _, err := downloadFile(job) + if err == nil { + t.Fatal("expected error for 404 response") + } + }) + + t.Run("HTTP 500 returns error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "error.txt") + job := downloadJob{ + Identifier: "testitem", + Name: "error.txt", + URL: server.URL, + OutPath: outPath, + Size: 999, + } + + _, err := downloadFile(job) + if err == nil { + t.Fatal("expected error for 500 response") + } + }) + + t.Run("download creates parent directories", func(t *testing.T) { + content := []byte("content") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write(content); err != nil { + return + } + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "nested", "dirs", "file.txt") + job := downloadJob{ + Identifier: "testitem", + Name: "file.txt", + URL: server.URL, + OutPath: outPath, + Size: int64(len(content)), + } + + size, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if size != int64(len(content)) { + t.Errorf("expected %d bytes, got %d", len(content), size) + } + if _, err := os.Stat(outPath); err != nil { + t.Errorf("file should exist at nested path: %v", err) + } + }) + + t.Run("zero size file downloads without size check", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "zerofile") + job := downloadJob{ + Identifier: "testitem", + Name: "zerofile", + URL: server.URL, + OutPath: outPath, + Size: 0, + } + + size, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if size != 0 { + t.Errorf("expected 0 bytes, got %d", size) + } + data, _ := os.ReadFile(outPath) + if len(data) != 0 { + t.Errorf("expected empty file, got %d bytes", len(data)) + } + }) + + t.Run("partial file without size match triggers full resume", func(t *testing.T) { + existingContent := []byte("partial but wrong size") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rangeH := r.Header.Get("Range") + if rangeH == "" { + t.Error("expected Range header") + } + w.WriteHeader(http.StatusPartialContent) + if _, err := w.Write([]byte(" more data")); err != nil { + return + } + })) + defer server.Close() + + tmp := t.TempDir() + outPath := filepath.Join(tmp, "partial.txt") + if err := os.WriteFile(outPath, existingContent, 0644); err != nil { + t.Fatal(err) + } + + job := downloadJob{ + Identifier: "testitem", + Name: "partial.txt", + URL: server.URL, + OutPath: outPath, + Size: int64(len(existingContent) + 100), // wrong size, triggers resume + } + + _, err := downloadFile(job) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) +} + +func TestDownloadFile_ClientErrors(t *testing.T) { + t.Run("connection refused returns error", func(t *testing.T) { + job := downloadJob{ + URL: "http://127.0.0.1:1/notexist", + OutPath: "/tmp/out.txt", + Size: 100, + } + _, err := downloadFile(job) + if err == nil { + t.Fatal("expected error for connection refused") + } + }) + + t.Run("invalid URL returns error", func(t *testing.T) { + tmp := t.TempDir() + job := downloadJob{ + URL: "://invalid", + OutPath: filepath.Join(tmp, "out.txt"), + Size: 100, + } + _, err := downloadFile(job) + if err == nil { + t.Fatal("expected error for invalid URL") + } + }) +} + +func TestHumanSize_EdgeCases(t *testing.T) { + cases := []struct { + bytes int64 + expect string + }{ + {1, "1B"}, + {1023, "1023B"}, + {1048577, "1.0 MB"}, // just over 1 MiB + {1073741824 * 4, "4.0 GB"}, + {1099511627776, "1.0 TB"}, // 1 TiB + } + for _, c := range cases { + got := humanSize(c.bytes) + if got != c.expect { + t.Errorf("humanSize(%d) = %q, want %q", c.bytes, got, c.expect) + } + } +} \ No newline at end of file diff --git a/extract.go b/extract.go index ad89edc..d75a26a 100644 --- a/extract.go +++ b/extract.go @@ -55,11 +55,17 @@ var runAkaiutil = func(isoPath, commands string) (string, error) { return "", fmt.Errorf("start akaiutil: %w", err) } - stdin.Write([]byte(commands)) - stdin.Close() + if _, err := stdin.Write([]byte(commands)); err != nil { + return "", fmt.Errorf("write stdin: %w", err) + } + if err := stdin.Close(); err != nil { + return "", fmt.Errorf("close stdin: %w", err) + } output, _ := io.ReadAll(stdout) - cmd.Wait() + if err := cmd.Wait(); err != nil { + return "", fmt.Errorf("wait akaiutil: %w", err) + } return string(output), nil } @@ -130,7 +136,9 @@ func runExtraction(isoPath, baseDir string, vols []volInfo) (int, error) { safe = "volume" } volDir := filepath.Join(baseDir, safe) - os.MkdirAll(volDir, 0755) + if err := os.MkdirAll(volDir, 0755); err != nil { + return 0, fmt.Errorf("mkdir %s: %w", volDir, err) + } nav := strings.ReplaceAll(v.Name, " ", "_") fmt.Fprintf(&cmds, "lcd %s\n", volDir) @@ -171,17 +179,19 @@ func extractWAVs(isoPath, outDir string) (int, error) { isoBase := sanitiseISOName(filepath.Base(isoPath)) baseDir := filepath.Join(outDir, isoBase) - os.MkdirAll(baseDir, 0755) + if err := os.MkdirAll(baseDir, 0755); err != nil { + return 0, fmt.Errorf("mkdir %s: %w", baseDir, err) + } return runExtraction(isoPath, baseDir, vols) } -func extractISO(isoPath, outDir string) (string, error) { +var extractISO = func(isoPath, outDir string) (string, error) { isoOutDir := filepath.Join(outDir, sanitiseISOName(filepath.Base(isoPath))) if fi, err := os.Stat(isoOutDir); err == nil && fi.IsDir() { var wavCount int - filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error { + _ = filepath.WalkDir(isoOutDir, func(p string, d os.DirEntry, err error) error { if err == nil && !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") { wavCount++ } diff --git a/extract_test.go b/extract_test.go index f0d34f9..3144ce1 100644 --- a/extract_test.go +++ b/extract_test.go @@ -1,9 +1,78 @@ package main import ( + "fmt" + "os" + "path/filepath" + "strings" "testing" ) +func TestExtractISO_SkipsExistingWAVs(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + called := false + runAkaiutil = func(isoPath, commands string) (string, error) { + called = true + return "", nil + } + + tmp := t.TempDir() + isoOutDir := filepath.Join(tmp, sanitiseISOName(filepath.Base("/fake/test.iso"))) + if err := os.MkdirAll(isoOutDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} { + fd, err := os.Create(filepath.Join(isoOutDir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = fd.Close() }() + } + + result, err := extractISO("/fake/test.iso", tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if called { + t.Fatal("runAkaiutil should not have been called for existing WAVs") + } + if result == "" { + t.Fatal("expected non-empty result") + } +} + +func TestExtractISO_ProceedsWhenNoWAVs(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + calls := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + calls++ + if commands == "df\nq\n" { + return ` +Disk /disk0 + A - S1000 HD (3840 blocks) + `, nil + } + return ` exported 3 file(s) +`, nil + } + + tmp := t.TempDir() + result, err := extractISO("/fake/test.iso", tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if calls == 0 { + t.Fatal("runAkaiutil should have been called") + } + if result == "" { + t.Fatal("expected non-empty result") + } +} + func TestListPartitions(t *testing.T) { orig := runAkaiutil defer func() { runAkaiutil = orig }() @@ -115,3 +184,187 @@ func TestRunExtractionCount(t *testing.T) { t.Fatalf("expected 1 akaiutil call, got %d", calls) } } + +func TestExtractWAVs_Success(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + callCount := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + callCount++ + if callCount == 1 { + if commands != "df\nq\n" { + t.Fatalf("first call: expected df\\q\\n, got %q", commands) + } + return ` +Disk /disk0 + A - S1000 HD (3840 blocks) + `, nil + } + if callCount == 2 { + return ` +/disk0/A > cd /disk0/A +/disk0/A > dir + 1 DRUMS - VOLUME (10 files) +/disk0/A > q + `, nil + } + if callCount == 3 { + return ` exported 10 file(s) +`, nil + } + t.Fatalf("unexpected call #%d with commands: %q", callCount, commands) + return "", nil + } + + tmp := t.TempDir() + total, err := extractWAVs("/fake/test.iso", tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 10 { + t.Fatalf("expected 10 WAVs, got %d", total) + } + if callCount != 3 { + t.Fatalf("expected 3 akaiutil calls, got %d", callCount) + } +} + +func TestExtractWAVs_MultipleVolumes(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + calls := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + calls++ + if calls == 1 { + return ` +Disk /disk0 + A - S1000 HD (3840 blocks) + B - S1000 HD (5120 blocks) + `, nil + } + if calls == 2 { + return ` +/disk0/A > cd /disk0/A + 1 VOL_A - VOLUME (5 files) +/disk0/B > cd /disk0/B + 2 VOL_B - VOLUME (8 files) + `, nil + } + return ` exported 5 file(s) + exported 8 file(s) +`, nil + } + + tmp := t.TempDir() + total, err := extractWAVs("/fake/multivol.iso", tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 13 { + t.Fatalf("expected 13 WAVs, got %d", total) + } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } +} + +func TestExtractWAVs_NoVolumes(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + calls := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + calls++ + if calls == 1 { + return `Disk /disk0 + A - S1000 HD (3840 blocks) + `, nil + } + if calls == 2 { + return `/disk0/A > cd /disk0/A +/disk0/A > q + `, nil + } + return ` exported 0 file(s) +`, nil + } + + tmp := t.TempDir() + total, err := extractWAVs("/fake/empty.iso", tmp) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 0 { + t.Fatalf("expected 0 WAVs, got %d", total) + } +} + +func TestExtractWAVs_ErrorInPartitionList(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + runAkaiutil = func(isoPath, commands string) (string, error) { + return "", fmt.Errorf("simulated partition error") + } + + tmp := t.TempDir() + _, err := extractWAVs("/fake/error.iso", tmp) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestExtractWAVs_ErrorInVolumeList(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + calls := 0 + runAkaiutil = func(isoPath, commands string) (string, error) { + calls++ + if calls == 1 { + return ` +Disk /disk0 + A - S1000 HD (3840 blocks) + `, nil + } + return "", fmt.Errorf("simulated volume list error") + } + + tmp := t.TempDir() + _, err := extractWAVs("/fake/error.iso", tmp) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestRunExtraction_CreatesVolumeDirs(t *testing.T) { + orig := runAkaiutil + defer func() { runAkaiutil = orig }() + + created := map[string]bool{} + runAkaiutil = func(isoPath, commands string) (string, error) { + for _, line := range strings.Split(commands, "\n") { + if strings.HasPrefix(line, "lcd ") { + dir := strings.TrimPrefix(line, "lcd ") + created[dir] = true + } + } + return ` exported 3 file(s) +`, nil + } + + tmp := t.TempDir() + vols := []volInfo{ + {Part: "A", Name: "Test Volume One"}, + } + baseDir := filepath.Join(tmp, "output") + _, err := runExtraction("/fake/test.iso", baseDir, vols) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !created["/"] { + t.Error("expected lcd / to be in commands") + } +} diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..6eaf988 --- /dev/null +++ b/http_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestGetItemFiles(t *testing.T) { + t.Run("returns ISO files with sizes", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/metadata/testitem" + if r.URL.Path != expectedPath { + t.Errorf("expected path %q, got %q", expectedPath, r.URL.Path) + } + resp := MetadataResult{ + Files: []MetadataFile{ + {Name: "folder/something.txt", Size: "1234", Format: "text"}, + {Name: "synth.iso", Size: "524288000", Format: "CD image"}, + {Name: "drums.iso", Size: "1048576", Format: "CD image"}, + {Name: "README.txt", Size: "500", Format: "text"}, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + files := getItemFiles("testitem") + if len(files) != 2 { + t.Fatalf("expected 2 ISO files, got %d: %+v", len(files), files) + } + if files[0].Name != "synth.iso" || files[0].Size != "524288000" { + t.Errorf("unexpected first file: %+v", files[0]) + } + if files[1].Name != "drums.iso" || files[1].Size != "1048576" { + t.Errorf("unexpected second file: %+v", files[1]) + } + }) + + t.Run("no ISO files returns empty", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := MetadataResult{Files: []MetadataFile{ + {Name: "readme.txt", Size: "100", Format: "text"}, + {Name: "cover.jpg", Size: "50000", Format: "image"}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + files := getItemFiles("noisos") + if len(files) != 0 { + t.Fatalf("expected 0 files, got %d", len(files)) + } + }) + + t.Run("server returns 500", func(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + // Verify server is called and returns 500 + if calls != 0 { + t.Errorf("expected 0 calls before getItemFiles, got %d", calls) + } + }) +} + +func TestSearchArchive(t *testing.T) { + t.Run("parses search results correctly", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q != "subject:akai AND subject:sampler" { + t.Errorf("unexpected query: %q", q) + } + resp := SearchResult{ + Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 2, + Docs: []SearchDoc{ + {Identifier: "akai-drums-vol1", Title: "AKAI Drums Vol 1", Downloads: 500, Format: []string{"CD image"}}, + {Identifier: "akai-synth-pads", Title: "AKAI Synth Pads", Downloads: 200, Format: []string{"CD image"}}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + results := searchArchive("subject:akai AND subject:sampler", 30) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0].Identifier != "akai-drums-vol1" || results[0].Downloads != 500 { + t.Errorf("unexpected first result: %+v", results[0]) + } + }) + + t.Run("empty results returns empty slice", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := SearchResult{Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 0, + Docs: []SearchDoc{}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + results := searchArchive("nonexistent query", 30) + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } + }) + + t.Run("URL encoding of query", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q, _ := url.QueryUnescape(r.URL.RawQuery) + if !containsQueryParam(q, "vocal") { + t.Errorf("query should contain 'vocal': %s", q) + } + resp := SearchResult{Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 0, Docs: []SearchDoc{}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + searchArchive("vocal", 10) + }) +} + +func containsQueryParam(query string, substr string) bool { + for _, pair := range strings.Split(query, "&") { + if strings.Contains(pair, substr) { + return true + } + } + return false +} \ No newline at end of file diff --git a/main.go b/main.go index d69a956..ddcd484 100644 --- a/main.go +++ b/main.go @@ -16,10 +16,10 @@ import ( "time" ) -const ( +var ( + userAgent = "akai-fetch/1.0" iaSearchURL = "https://archive.org/advancedsearch.php" iaBaseURL = "https://archive.org" - userAgent = "akai-fetch/1.0" ) type SearchResult struct { @@ -121,6 +121,15 @@ Serve flags: // ─── Search ──────────────────────────────────────────────────────────── func cmdSearch(args []string) { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + osExit(fe.code) + return + } + panic(e) + } + }() fs := flag.NewFlagSet("search", flag.ExitOnError) query := fs.String("query", "subject:akai AND subject:sampler", "search query") filter := fs.String("filter", "", "extra term AND-ed onto the query") @@ -167,7 +176,7 @@ func searchArchive(query string, limit int) []SearchDoc { if err != nil { fatal("search request: %v", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -184,6 +193,15 @@ func searchArchive(query string, limit int) []SearchDoc { // ─── List ────────────────────────────────────────────────────────────── func cmdList(args []string) { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + osExit(fe.code) + return + } + panic(e) + } + }() if len(args) < 1 { fatal("usage: akai-fetch list ") } @@ -200,7 +218,7 @@ func cmdList(args []string) { } } -func getItemFiles(identifier string) []MetadataFile { +var getItemFiles = func(identifier string) []MetadataFile { u := fmt.Sprintf("%s/metadata/%s", iaBaseURL, url.PathEscape(identifier)) client := &http.Client{Timeout: 30 * time.Second} req, _ := http.NewRequest("GET", u, nil) @@ -209,7 +227,7 @@ func getItemFiles(identifier string) []MetadataFile { if err != nil { fatal("metadata request: %v", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { @@ -243,6 +261,15 @@ type downloadJob struct { } func cmdDownload(args []string) { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + osExit(fe.code) + return + } + panic(e) + } + }() fs := flag.NewFlagSet("download", flag.ExitOnError) identifier := fs.String("i", "", "archive.org item identifier") idFile := fs.String("f", "", "file with identifiers (one per line)") @@ -329,8 +356,10 @@ func cmdDownload(args []string) { fmt.Printf("\nDownloaded %s total\n", humanSize(totalDownloaded)) } -func downloadFile(job downloadJob) (int64, error) { - os.MkdirAll(filepath.Dir(job.OutPath), 0755) +var downloadFile = func(job downloadJob) (int64, error) { + if err := os.MkdirAll(filepath.Dir(job.OutPath), 0755); err != nil { + return 0, fmt.Errorf("mkdir: %w", err) + } // Check if file already exists and has the expected size if fi, err := os.Stat(job.OutPath); err == nil { @@ -359,7 +388,7 @@ func downloadFile(job downloadJob) (int64, error) { if err != nil { return 0, fmt.Errorf("get: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { return 0, fmt.Errorf("HTTP %d", resp.StatusCode) @@ -369,7 +398,7 @@ func downloadFile(job downloadJob) (int64, error) { if err != nil { return 0, fmt.Errorf("open: %w", err) } - defer outFile.Close() + defer func() { _ = outFile.Close() }() written, err := io.Copy(outFile, resp.Body) if err != nil { @@ -381,6 +410,15 @@ func downloadFile(job downloadJob) (int64, error) { // ─── Extract ─────────────────────────────────────────────────────────── func cmdExtract(args []string) { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + osExit(fe.code) + return + } + panic(e) + } + }() fs := flag.NewFlagSet("extract", flag.ExitOnError) isoDir := fs.String("dir", ".", "directory with ISO files") outDir := fs.String("out", "./wavs", "output directory for WAVs") @@ -462,6 +500,15 @@ func sanitiseISOName(path string) string { // ─── Pipeline ────────────────────────────────────────────────────────── func cmdPipeline(args []string) { + defer func() { + if e := recover(); e != nil { + if fe, ok := e.(fatalErr); ok { + osExit(fe.code) + return + } + panic(e) + } + }() fs := flag.NewFlagSet("pipeline", flag.ExitOnError) query := fs.String("query", "subject:akai AND subject:sampler", "search query") filter := fs.String("filter", "", "extra term AND-ed onto the query") @@ -527,9 +574,17 @@ func cmdPipeline(args []string) { // ─── Helpers ─────────────────────────────────────────────────────────── +var osExit = os.Exit + +type fatalErr struct { + code int + message string +} + func fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) - os.Exit(1) + msg := fmt.Sprintf("error: "+format+"\n", args...) + fmt.Fprintf(os.Stderr, msg, args...) + panic(fatalErr{code: 1, message: msg}) } func truncate(s string, n int) string { @@ -556,6 +611,8 @@ func humanSize(bytes int64) string { } func init() { - // Ensure we can parse flags even with -count or other test flags - flag.CommandLine.Parse([]string{}) + if err := flag.CommandLine.Parse([]string{}); err != nil { + // In init context, best we can do is note the issue + fmt.Fprintf(os.Stderr, "flag init: %v\n", err) + } } diff --git a/serve.go b/serve.go index 0430acc..8672db2 100644 --- a/serve.go +++ b/serve.go @@ -189,7 +189,10 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) { jobIDs = append(jobIDs, jobID) go func(j downloadJob, s *downloadState) { - os.MkdirAll(filepath.Dir(j.OutPath), 0755) + if err := os.MkdirAll(filepath.Dir(j.OutPath), 0755); err != nil { + s.Error = err.Error() + return + } client := &http.Client{Timeout: 0} req, _ := http.NewRequest("GET", j.URL, nil) @@ -206,20 +209,23 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) { s.Error = err.Error() return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() outFile, err := os.Create(j.OutPath) if err != nil { s.Error = err.Error() return } - defer outFile.Close() + defer func() { _ = outFile.Close() }() buf := make([]byte, 32*1024) for { n, readErr := resp.Body.Read(buf) if n > 0 { - outFile.Write(buf[:n]) + if _, err := outFile.Write(buf[:n]); err != nil { + s.Error = err.Error() + return + } s.Downloaded += int64(n) now := time.Now() @@ -267,7 +273,9 @@ func handleProgress(w http.ResponseWriter, r *http.Request) { data, _ := json.Marshal(downloadJobs) downloadJobsMu.Unlock() - fmt.Fprintf(w, "data: %s\n\n", data) + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return + } flusher.Flush() allDone := true @@ -331,13 +339,16 @@ func handleAPIExtract(w http.ResponseWriter, r *http.Request) { func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + // client disconnected, nothing we can do + return + } } func openURL(rawURL string) { - exec.Command("xdg-open", rawURL).Start() - exec.Command("open", rawURL).Start() - exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() + _ = exec.Command("xdg-open", rawURL).Start() + _ = exec.Command("open", rawURL).Start() + _ = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() } func init() { diff --git a/serve_test.go b/serve_test.go new file mode 100644 index 0000000..eed25e2 --- /dev/null +++ b/serve_test.go @@ -0,0 +1,445 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriteJSON(t *testing.T) { + t.Run("sets correct headers", func(t *testing.T) { + rr := httptest.NewRecorder() + writeJSON(rr, map[string]string{"key": "value"}) + if ct := rr.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json, got %q", ct) + } + if ac := rr.Header().Get("Access-Control-Allow-Origin"); ac != "*" { + t.Errorf("expected *, got %q", ac) + } + }) + + t.Run("writes valid JSON", func(t *testing.T) { + rr := httptest.NewRecorder() + data := map[string]int{"count": 42, "total": 100} + writeJSON(rr, data) + var result map[string]int + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if result["count"] != 42 { + t.Errorf("expected count=42, got %d", result["count"]) + } + }) + + t.Run("writes nested structs", func(t *testing.T) { + rr := httptest.NewRecorder() + type inner struct { + Name string `json:"name"` + } + writeJSON(rr, map[string]any{"item": inner{Name: "test"}}) + var result map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + item := result["item"].(map[string]any) + if item["name"] != "test" { + t.Errorf("expected name=test, got %v", item["name"]) + } + }) +} + +func TestHandleProgress_SSEHeaders(t *testing.T) { + downloadJobsMu.Lock() + downloadJobs = make(map[string]*downloadState) + downloadJobs["test-job"] = &downloadState{ + ID: "test-job", + Identifier: "testid", + FileName: "test.iso", + TotalSize: 1000, + Downloaded: 500, + Done: false, + } + downloadJobsMu.Unlock() + + req := httptest.NewRequest("GET", "/api/progress", nil) + rr := httptest.NewRecorder() + + done := make(chan string) + go func() { + handleProgress(rr, req) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + } + + if ct := rr.Header().Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("expected text/event-stream, got %q", ct) + } + if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { + t.Errorf("expected no-cache, got %q", cc) + } + if conn := rr.Header().Get("Connection"); conn != "keep-alive" { + t.Errorf("expected keep-alive, got %q", conn) + } +} + +func TestHandleProgress_SSEContent(t *testing.T) { + downloadJobsMu.Lock() + downloadJobs = make(map[string]*downloadState) + downloadJobs["job1"] = &downloadState{ + ID: "job1", + Identifier: "item1", + FileName: "file1.iso", + TotalSize: 1000, + Downloaded: 1000, + Done: true, + } + downloadJobsMu.Unlock() + + req := httptest.NewRequest("GET", "/api/progress", nil) + rr := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + handleProgress(rr, req) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + } + + body := rr.Body.String() + if body == "" { + t.Error("expected non-empty SSE body") + } + body = strings.TrimPrefix(body, "data: ") + body = strings.TrimSuffix(strings.TrimSpace(body), "\n\n") + var states map[string]*downloadState + if err := json.Unmarshal([]byte(body), &states); err != nil { + t.Fatalf("invalid JSON in SSE data: %v\nbody: %q", err, body) + } + if len(states) != 1 { + t.Errorf("expected 1 state, got %d", len(states)) + } +} + +func TestHandleProgress_EmptyJobs(t *testing.T) { + downloadJobsMu.Lock() + downloadJobs = make(map[string]*downloadState) + downloadJobsMu.Unlock() + + req := httptest.NewRequest("GET", "/api/progress", nil) + rr := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + handleProgress(rr, req) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + } + + body := rr.Body.String() + if body == "" { + t.Error("expected non-empty SSE body even with no jobs") + } +} + +func TestHandleSearch_DelegatesToSearchArchive(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + if q != "(drums) AND (vocal)" { + t.Errorf("unexpected query: %q", q) + } + resp := SearchResult{Response: struct { + NumFound int `json:"numFound"` + Docs []SearchDoc `json:"docs"` + }{ + NumFound: 1, Docs: []SearchDoc{ + {Identifier: "test-id", Title: "Test", Downloads: 50}, + }, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origSearch := iaSearchURL + iaSearchURL = server.URL + "/advancedsearch.php" + defer func() { iaSearchURL = origSearch }() + + req := httptest.NewRequest("GET", "/api/search?q=drums&filter=vocal", nil) + rr := httptest.NewRecorder() + handleSearch(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestHandleList_DelegatesToGetItemFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata/testitem" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + resp := MetadataResult{Files: []MetadataFile{ + {Name: "test.iso", Size: "1000000"}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + req := httptest.NewRequest("GET", "/api/list?identifier=testitem", nil) + rr := httptest.NewRecorder() + handleList(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestHandleList_MissingIdentifier(t *testing.T) { + req := httptest.NewRequest("GET", "/api/list", nil) + rr := httptest.NewRecorder() + handleList(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestHandleAPIDownload_CreatesJobsAndReturnsJSON(t *testing.T) { + origDownloadFile := downloadFile + defer func() { downloadFile = origDownloadFile }() + + downloadFile = func(job downloadJob) (int64, error) { + downloadJobsMu.Lock() + if s, ok := downloadJobs[job.Identifier+"/"+job.Name]; ok { + s.Done = true + s.Downloaded = job.Size + } + downloadJobsMu.Unlock() + return job.Size, nil + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := MetadataResult{Files: []MetadataFile{ + {Name: "test1.iso", Size: "1000"}, + {Name: "test2.iso", Size: "2000"}, + }} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } + })) + defer server.Close() + + origBase := iaBaseURL + iaBaseURL = server.URL + defer func() { iaBaseURL = origBase }() + + tmp := t.TempDir() + req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil) + rr := httptest.NewRecorder() + + handleAPIDownload(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + jobs, ok := resp["jobs"].([]any) + if !ok || len(jobs) != 2 { + t.Errorf("expected 2 jobs, got %v", resp["jobs"]) + } + + downloadJobsMu.Lock() + defer downloadJobsMu.Unlock() + if len(downloadJobs) != 2 { + t.Errorf("expected 2 jobs in state map, got %d", len(downloadJobs)) + } +} + +func TestHandleAPIDownload_MissingIdentifier(t *testing.T) { + req := httptest.NewRequest("GET", "/api/download", nil) + rr := httptest.NewRecorder() + handleAPIDownload(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestHandleAPIExtract_NoISOs(t *testing.T) { + origExtractISO := extractISO + defer func() { extractISO = origExtractISO }() + + extractISO = func(isoPath, outDir string) (string, error) { + return "skipped (0 WAVs)", nil + } + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(isoDir, "empty.iso")) + defer func() { _ = fd.Close() }() + + req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil) + rr := httptest.NewRecorder() + + handleAPIExtract(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := resp["error"]; ok { + t.Errorf("unexpected error: %v", resp["error"]) + } +} + +func TestHandleAPIExtract_WithISOs(t *testing.T) { + origExtractISO := extractISO + defer func() { extractISO = origExtractISO }() + + extractISO = func(isoPath, outDir string) (string, error) { + return "OK test.iso: Total WAV files: 10 (5s)", nil + } + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(isoDir, "test.iso")) + defer func() { _ = fd.Close() }() + + outDir := filepath.Join(tmp, "wavs") + req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+outDir, nil) + rr := httptest.NewRecorder() + + handleAPIExtract(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +func TestHandleAPIDownload_GetItemFilesReturnsEmpty(t *testing.T) { + origGet := getItemFiles + defer func() { getItemFiles = origGet }() + + getItemFiles = func(id string) []MetadataFile { + return []MetadataFile{} + } + + tmp := t.TempDir() + req := httptest.NewRequest("GET", "/api/download?identifier=testitem&dir="+tmp, nil) + rr := httptest.NewRecorder() + + handleAPIDownload(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := resp["error"]; !ok { + t.Error("expected error for empty files") + } + if resp["error"] != "no ISO files found" { + t.Errorf("expected 'no ISO files found', got %v", resp["error"]) + } +} + +func TestHandleAPIExtract_ReadDirFails(t *testing.T) { + tmp := t.TempDir() + badDir := filepath.Join(tmp, "nonexistent") + req := httptest.NewRequest("GET", "/api/extract?dir="+badDir+"&out="+filepath.Join(tmp, "wavs"), nil) + rr := httptest.NewRecorder() + + handleAPIExtract(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := resp["error"]; !ok { + t.Error("expected error for failed ReadDir") + } +} + +func TestHandleAPIExtract_ExtractISOError(t *testing.T) { + origExtractISO := extractISO + defer func() { extractISO = origExtractISO }() + + extractISO = func(isoPath, outDir string) (string, error) { + return "", fmt.Errorf("partition read failed") + } + + tmp := t.TempDir() + isoDir := filepath.Join(tmp, "isos") + if err := os.MkdirAll(isoDir, 0755); err != nil { + t.Fatal(err) + } + fd, _ := os.Create(filepath.Join(isoDir, "bad.iso")) + defer func() { _ = fd.Close() }() + + req := httptest.NewRequest("GET", "/api/extract?dir="+isoDir+"&out="+filepath.Join(tmp, "wavs"), nil) + rr := httptest.NewRecorder() + + handleAPIExtract(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + msg, ok := resp["message"].(string) + if !ok { + t.Fatal("expected message string") + } + if !strings.Contains(msg, "FAIL bad.iso") { + t.Errorf("expected FAIL message, got: %s", msg) + } +} \ No newline at end of file diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..44a36f1 --- /dev/null +++ b/util_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSanitiseISOName(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"ZERO-G - Deepest India", "ZERO-G_-_Deepest_India"}, + {"SYNTH PAD COLLECTION.iso", "SYNTH_PAD_COLLECTION"}, + {"vocal.loops", "vocal"}, + {"DRUM KIT #1", "DRUM_KIT_1"}, + {"simple", "simple"}, + {"no-extension", "no-extension"}, + {"multiple spaces", "multiple_spaces"}, + {"UPPERCASE", "UPPERCASE"}, + {"MiXeD CaSe", "MiXeD_CaSe"}, + {"with-dash", "with-dash"}, + {"with_underscore", "with_underscore"}, + {"with.dots.in.name", "with.dots.in"}, + {".hidden", ""}, + {"trailing_", "trailing"}, + {"__leading__", "leading"}, + {"spaces everywhere", "spaces_everywhere"}, + {"", ""}, + {" ", ""}, + {".iso", ""}, + {"a", "a"}, + } + for _, c := range cases { + got := sanitiseISOName(c.input) + if got != c.expected { + t.Errorf("sanitiseISOName(%q) = %q, want %q", c.input, got, c.expected) + } + } +} + +func TestTruncate(t *testing.T) { + cases := []struct { + s string + n int + expect string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "h..."}, + {"hello", 3, "..."}, + {"hello", 2, "he"}, + {"hello", 1, "h"}, + {"hello", 0, ""}, + {"", 5, ""}, + {"abcdef", 4, "a..."}, + {"ab", 4, "ab"}, + {"abc", 3, "abc"}, + {"abcd", 2, "ab"}, + } + for _, c := range cases { + got := truncate(c.s, c.n) + if got != c.expect { + t.Errorf("truncate(%q, %d) = %q, want %q", c.s, c.n, got, c.expect) + } + } +} + +func TestHumanSize(t *testing.T) { + cases := []struct { + bytes int64 + expect string + }{ + {0, "0B"}, + {500, "500B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {10240, "10.0 KB"}, + {1048576, "1.0 MB"}, + {1572864, "1.5 MB"}, + {1073741824, "1.0 GB"}, + {2147483648, "2.0 GB"}, + } + for _, c := range cases { + got := humanSize(c.bytes) + if got != c.expect { + t.Errorf("humanSize(%d) = %q, want %q", c.bytes, got, c.expect) + } + } +} + +func TestFindAkaiutil(t *testing.T) { + origEnv := os.Getenv("AKAIUTIL") + origArg0 := os.Args[0] + defer func() { + _ = os.Setenv("AKAIUTIL", origEnv) + os.Args[0] = origArg0 + }() + + t.Run("env var set to missing file", func(t *testing.T) { + _ = os.Setenv("AKAIUTIL", "/nonexistent/akaiutil") + os.Args[0] = "/some/binary" + _, err := findAkaiutil() + if err == nil { + t.Fatal("expected error for missing env var path") + } + }) + + t.Run("env var set to existing file", func(t *testing.T) { + tmp := t.TempDir() + f := tmp + "/myutil" + fd, _ := os.Create(f) + defer func() { _ = fd.Close() }() + _ = os.Setenv("AKAIUTIL", f) + got, err := findAkaiutil() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != f { + t.Errorf("got %q, want %q", got, f) + } + }) + + t.Run("finds binary next to argv[0]", func(t *testing.T) { + tmp := t.TempDir() + exe := tmp + "/mybinary" + fd, _ := os.Create(exe) + defer func() { _ = fd.Close() }() + sideBySide := tmp + "/akaiutil" + fd2, _ := os.Create(sideBySide) + defer func() { _ = fd2.Close() }() + _ = os.Setenv("AKAIUTIL", "") + os.Args[0] = exe + got, err := findAkaiutil() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != sideBySide { + t.Errorf("got %q, want %q", got, sideBySide) + } + }) + + t.Run("finds binary in third_party relative to argv[0]", func(t *testing.T) { + tmp := t.TempDir() + exe := tmp + "/somebin" + fd, _ := os.Create(exe) + defer func() { _ = fd.Close() }() + thirdParty := filepath.Join(tmp, "third_party", "akaiutil") + if err := os.MkdirAll(thirdParty, 0755); err != nil { + t.Fatal(err) + } + akaiutilInThirdParty := filepath.Join(thirdParty, "akaiutil") + fd2, _ := os.Create(akaiutilInThirdParty) + defer func() { _ = fd2.Close() }() + _ = os.Setenv("AKAIUTIL", "") + os.Args[0] = exe + got, err := findAkaiutil() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != akaiutilInThirdParty { + t.Errorf("got %q, want %q", got, akaiutilInThirdParty) + } + }) + + t.Run("env var takes priority over path candidates", func(t *testing.T) { + tmp := t.TempDir() + envFile := tmp + "/envutil" + pathFile := tmp + "/pathutil" + fd1, _ := os.Create(envFile) + defer func() { _ = fd1.Close() }() + fd2, _ := os.Create(pathFile) + defer func() { _ = fd2.Close() }() + _ = os.Setenv("AKAIUTIL", envFile) + os.Args[0] = pathFile + got, err := findAkaiutil() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if got != envFile { + t.Errorf("got %q, want env var path %q", got, envFile) + } + }) + + t.Run("no akaiutil found returns error", func(t *testing.T) { + tmp := t.TempDir() + exe := tmp + "/nobinaryhere" + fd, _ := os.Create(exe) + defer func() { _ = fd.Close() }() + _ = os.Setenv("AKAIUTIL", "") + os.Args[0] = exe + _, err := findAkaiutil() + if err == nil { + t.Fatal("expected error when no akaiutil found") + } + }) +} \ No newline at end of file