From 5e5fa5ee4996ee48fd4202df3375a9e7fd85a80a 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 ++++++++++++++++++++++++++++++++++++++++++++++++ extract.go | 24 +- extract_test.go | 253 ++++++++++++++++++++ main.go | 83 +++++-- serve.go | 29 ++- serve_test.go | 445 +++++++++++++++++++++++++++++++++++ 6 files changed, 1415 insertions(+), 29 deletions(-) create mode 100644 cli_test.go create mode 100644 serve_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/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/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