feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -0,0 +1,667 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// qBittorrent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// qbitStub is a fake qBittorrent Web API.
|
||||
type qbitStub struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// loginOK controls whether auth succeeds.
|
||||
loginOK bool
|
||||
|
||||
// torrentStates is what the info endpoint returns, in order; the
|
||||
// last entry repeats.
|
||||
torrentStates [][]qbitTorrent
|
||||
pollCount int
|
||||
|
||||
// addedForm records the add-torrent parameters.
|
||||
addedForm url.Values
|
||||
}
|
||||
|
||||
func newQbitStub(t *testing.T) *qbitStub {
|
||||
t.Helper()
|
||||
|
||||
s := &qbitStub{loginOK: true}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/api/v2/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
ok := s.loginOK
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// qBittorrent answers a bad login with 200 and "Fails.".
|
||||
_, _ = w.Write([]byte("Fails."))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v2/app/version", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("v4.6.0"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v2/torrents/add", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("parse add form: %v", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.addedForm = r.PostForm
|
||||
s.mu.Unlock()
|
||||
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/v2/torrents/info", func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.Lock()
|
||||
|
||||
idx := s.pollCount
|
||||
if idx >= len(s.torrentStates) {
|
||||
idx = len(s.torrentStates) - 1
|
||||
} else {
|
||||
s.pollCount++
|
||||
}
|
||||
|
||||
var batch []qbitTorrent
|
||||
if idx >= 0 && len(s.torrentStates) > 0 {
|
||||
batch = s.torrentStates[idx]
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(t, w, batch)
|
||||
})
|
||||
|
||||
s.server = httptest.NewServer(mux)
|
||||
t.Cleanup(s.server.Close)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func newStubQbit(t *testing.T, stub *qbitStub) *qbittorrent {
|
||||
t.Helper()
|
||||
|
||||
p, err := newQBittorrent(
|
||||
Config{
|
||||
ID: 1,
|
||||
Kind: KindQBittorrent,
|
||||
Name: "qbittorrent",
|
||||
Settings: map[string]string{
|
||||
"url": stub.server.URL,
|
||||
"username": "admin",
|
||||
},
|
||||
},
|
||||
func(string) (string, error) { return "secret", nil },
|
||||
slogDiscard(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newQBittorrent: %v", err)
|
||||
}
|
||||
|
||||
q, ok := p.(*qbittorrent)
|
||||
if !ok {
|
||||
t.Fatalf("provider is %T, want *qbittorrent", p)
|
||||
}
|
||||
|
||||
q.pollInterval = time.Millisecond
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func TestQbitCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newQbitStub(t)
|
||||
q := newStubQbit(t, stub)
|
||||
|
||||
if err := q.Check(context.Background()); err != nil {
|
||||
t.Errorf("Check: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// qBittorrent returns HTTP 200 with "Fails." for a bad password, so a
|
||||
// status-code-only check would report success.
|
||||
func TestQbitRejectsBadPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newQbitStub(t)
|
||||
stub.loginOK = false
|
||||
|
||||
q := newStubQbit(t, stub)
|
||||
|
||||
if err := q.Check(context.Background()); !errors.Is(err, ErrQbitAuth) {
|
||||
t.Errorf("error = %v, want ErrQbitAuth", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The transport must be a pure transport: no search role.
|
||||
func TestQbitDeclaresTransportOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newQbitStub(t)
|
||||
q := newStubQbit(t, stub)
|
||||
|
||||
caps := q.Info().Caps
|
||||
|
||||
if caps.CanSearch || caps.CanDelegate {
|
||||
t.Errorf("qBittorrent should transport only, got %+v", caps)
|
||||
}
|
||||
|
||||
if !caps.Handles(ProtocolTorrent) {
|
||||
t.Error("qBittorrent should handle the torrent protocol")
|
||||
}
|
||||
|
||||
if caps.Handles(ProtocolUsenet) {
|
||||
t.Error("qBittorrent should not claim usenet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQbitGrabCollectsCompletedTorrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newQbitStub(t)
|
||||
|
||||
// The torrent's content lands in a directory qBittorrent owns.
|
||||
content := t.TempDir()
|
||||
|
||||
for _, name := range []string{"01 Airbag.flac", "02 Paranoid Android.flac"} {
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(content, name), []byte("audio"), 0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
stub.torrentStates = [][]qbitTorrent{
|
||||
{{
|
||||
Hash: "abc123", State: "downloading",
|
||||
Progress: 0.4, Size: 1000, Completed: 400,
|
||||
}},
|
||||
{{
|
||||
Hash: "abc123", State: "uploading",
|
||||
Progress: 1.0, Size: 1000, Completed: 1000,
|
||||
ContentPath: content,
|
||||
}},
|
||||
}
|
||||
|
||||
q := newStubQbit(t, stub)
|
||||
dst := t.TempDir()
|
||||
|
||||
c := Candidate{
|
||||
ID: "prowlarr:x",
|
||||
Protocol: ProtocolTorrent,
|
||||
Title: "Radiohead - OK Computer",
|
||||
Payload: map[string]string{
|
||||
"link": "magnet:?xt=urn:btih:abc123",
|
||||
"infoHash": "abc123",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := q.Grab(context.Background(), c, dst, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Grab: %v", err)
|
||||
}
|
||||
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||
}
|
||||
|
||||
for _, f := range got.Files {
|
||||
if !strings.HasPrefix(f, dst) {
|
||||
t.Errorf("file %s is outside the staging dir", f)
|
||||
}
|
||||
}
|
||||
|
||||
// The torrent was saved into our staging directory and kept out of
|
||||
// qBittorrent's own move-on-completion rules.
|
||||
stub.mu.Lock()
|
||||
form := stub.addedForm
|
||||
stub.mu.Unlock()
|
||||
|
||||
if form.Get("savepath") != dst {
|
||||
t.Errorf("savepath = %q, want the staging dir %q", form.Get("savepath"), dst)
|
||||
}
|
||||
|
||||
if form.Get("autoTMM") != "false" {
|
||||
t.Errorf("autoTMM = %q, want false", form.Get("autoTMM"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQbitGrabFailsOnErrorState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newQbitStub(t)
|
||||
stub.torrentStates = [][]qbitTorrent{
|
||||
{{Hash: "abc123", State: "error"}},
|
||||
}
|
||||
|
||||
q := newStubQbit(t, stub)
|
||||
|
||||
c := Candidate{
|
||||
Protocol: ProtocolTorrent,
|
||||
Payload: map[string]string{
|
||||
"link": "magnet:?xt=urn:btih:abc123", "infoHash": "abc123",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := q.Grab(context.Background(), c, t.TempDir(), nil)
|
||||
if !errors.Is(err, ErrQbitTransferFailed) {
|
||||
t.Errorf("error = %v, want ErrQbitTransferFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoHashFromMagnet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"magnet:?xt=urn:btih:ABC123&dn=x", "abc123"},
|
||||
{"magnet:?dn=x&xt=urn:btih:def456", "def456"},
|
||||
{"magnet:?dn=no-hash", ""},
|
||||
{"https://example.com/x.torrent", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := infoHashFromMagnet(tt.in); got != tt.want {
|
||||
t.Errorf("infoHashFromMagnet(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SABnzbd
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sabStub is a fake SABnzbd API.
|
||||
type sabStub struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// queueSlots and historySlots are returned in order per mode; the
|
||||
// last entry repeats.
|
||||
queueSlots [][]sabQueueSlot
|
||||
queuePolls int
|
||||
historySlots []sabHistorySlot
|
||||
|
||||
addStatus bool
|
||||
addError string
|
||||
|
||||
badKey bool
|
||||
}
|
||||
|
||||
func newSabStub(t *testing.T) *sabStub {
|
||||
t.Helper()
|
||||
|
||||
s := &sabStub{addStatus: true}
|
||||
|
||||
s.server = httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
badKey := s.badKey
|
||||
s.mu.Unlock()
|
||||
|
||||
if badKey {
|
||||
// SABnzbd reports a bad key with HTTP 200 and a body.
|
||||
writeJSON(t, w, map[string]any{
|
||||
"status": false, "error": "API Key Incorrect",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("mode") {
|
||||
case "version":
|
||||
writeJSON(t, w, map[string]any{"version": "4.1.0"})
|
||||
case "addurl":
|
||||
s.mu.Lock()
|
||||
status, errMsg := s.addStatus, s.addError
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(t, w, map[string]any{
|
||||
"status": status, "error": errMsg,
|
||||
"nzo_ids": []string{"SABnzbd_nzo_1"},
|
||||
})
|
||||
case "queue":
|
||||
s.mu.Lock()
|
||||
|
||||
idx := s.queuePolls
|
||||
if idx >= len(s.queueSlots) {
|
||||
idx = len(s.queueSlots) - 1
|
||||
} else {
|
||||
s.queuePolls++
|
||||
}
|
||||
|
||||
var slots []sabQueueSlot
|
||||
if idx >= 0 && len(s.queueSlots) > 0 {
|
||||
slots = s.queueSlots[idx]
|
||||
}
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(t, w, map[string]any{
|
||||
"queue": map[string]any{"slots": slots},
|
||||
})
|
||||
case "history":
|
||||
s.mu.Lock()
|
||||
slots := s.historySlots
|
||||
s.mu.Unlock()
|
||||
|
||||
writeJSON(t, w, map[string]any{
|
||||
"history": map[string]any{"slots": slots},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
},
|
||||
))
|
||||
|
||||
t.Cleanup(s.server.Close)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func newStubSab(t *testing.T, stub *sabStub) *sabnzbd {
|
||||
t.Helper()
|
||||
|
||||
p, err := newSABnzbd(
|
||||
Config{
|
||||
ID: 1,
|
||||
Kind: KindSABnzbd,
|
||||
Name: "sabnzbd",
|
||||
Settings: map[string]string{"url": stub.server.URL},
|
||||
},
|
||||
func(string) (string, error) { return "test-key", nil },
|
||||
slogDiscard(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("newSABnzbd: %v", err)
|
||||
}
|
||||
|
||||
s, ok := p.(*sabnzbd)
|
||||
if !ok {
|
||||
t.Fatalf("provider is %T, want *sabnzbd", p)
|
||||
}
|
||||
|
||||
s.pollInterval = time.Millisecond
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSabCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
s := newStubSab(t, stub)
|
||||
|
||||
if err := s.Check(context.Background()); err != nil {
|
||||
t.Errorf("Check: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSabDeclaresUsenetOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
s := newStubSab(t, stub)
|
||||
|
||||
caps := s.Info().Caps
|
||||
|
||||
if caps.CanSearch || caps.CanDelegate {
|
||||
t.Errorf("SABnzbd should transport only, got %+v", caps)
|
||||
}
|
||||
|
||||
if !caps.Handles(ProtocolUsenet) {
|
||||
t.Error("SABnzbd should handle the usenet protocol")
|
||||
}
|
||||
|
||||
if caps.Handles(ProtocolTorrent) {
|
||||
t.Error("SABnzbd should not claim torrents")
|
||||
}
|
||||
}
|
||||
|
||||
// Completion is read from history, not from the queue emptying: a job
|
||||
// leaves the queue before post-processing finishes.
|
||||
func TestSabGrabWaitsForHistory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
|
||||
storage := t.TempDir()
|
||||
|
||||
for _, name := range []string{"01 Airbag.flac", "02 Paranoid Android.flac"} {
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(storage, name), []byte("audio"), 0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
stub.queueSlots = [][]sabQueueSlot{
|
||||
{{
|
||||
NzoID: "SABnzbd_nzo_1", Status: "Downloading",
|
||||
MB: "100.0", MBLeft: "60.0",
|
||||
}},
|
||||
// Second poll: gone from the queue.
|
||||
{},
|
||||
}
|
||||
stub.historySlots = []sabHistorySlot{{
|
||||
NzoID: "SABnzbd_nzo_1", Status: "Completed", Storage: storage,
|
||||
}}
|
||||
|
||||
s := newStubSab(t, stub)
|
||||
dst := t.TempDir()
|
||||
|
||||
c := Candidate{
|
||||
Protocol: ProtocolUsenet,
|
||||
Title: "Radiohead - OK Computer",
|
||||
Payload: map[string]string{"link": "https://example.com/x.nzb"},
|
||||
}
|
||||
|
||||
got, err := s.Grab(context.Background(), c, dst, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Grab: %v", err)
|
||||
}
|
||||
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||
}
|
||||
|
||||
for _, f := range got.Files {
|
||||
if !strings.HasPrefix(f, dst) {
|
||||
t.Errorf("file %s is outside the staging dir", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSabGrabFailsOnFailedJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
stub.queueSlots = [][]sabQueueSlot{{}}
|
||||
stub.historySlots = []sabHistorySlot{{
|
||||
NzoID: "SABnzbd_nzo_1",
|
||||
Status: "Failed",
|
||||
FailMsg: "Unpacking failed",
|
||||
}}
|
||||
|
||||
s := newStubSab(t, stub)
|
||||
|
||||
c := Candidate{
|
||||
Payload: map[string]string{"link": "https://example.com/x.nzb"},
|
||||
}
|
||||
|
||||
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
||||
if !errors.Is(err, ErrSabTransferFailed) {
|
||||
t.Errorf("error = %v, want ErrSabTransferFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A job that vanishes from both queue and history was removed out from
|
||||
// under us, which must not look like success.
|
||||
func TestSabGrabDetectsVanishedJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
stub.queueSlots = [][]sabQueueSlot{{}}
|
||||
stub.historySlots = nil
|
||||
|
||||
s := newStubSab(t, stub)
|
||||
|
||||
c := Candidate{
|
||||
Payload: map[string]string{"link": "https://example.com/x.nzb"},
|
||||
}
|
||||
|
||||
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
||||
if !errors.Is(err, ErrSabNoJob) {
|
||||
t.Errorf("error = %v, want ErrSabNoJob", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An NZB URL is untrusted input from an indexer.
|
||||
func TestSabGrabRejectsNonHTTPURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stub := newSabStub(t)
|
||||
s := newStubSab(t, stub)
|
||||
|
||||
c := Candidate{Payload: map[string]string{"link": "file:///etc/passwd"}}
|
||||
|
||||
_, err := s.Grab(context.Background(), c, t.TempDir(), nil)
|
||||
if !errors.Is(err, ErrUnsafeURL) {
|
||||
t.Errorf("error = %v, want ErrUnsafeURL", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMB(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
in string
|
||||
want int64
|
||||
}{
|
||||
{"1.0", 1024 * 1024},
|
||||
{"0", 0},
|
||||
{" 2.5 ", int64(2.5 * 1024 * 1024)},
|
||||
{"garbage", 0},
|
||||
{"", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := parseMB(tt.in); got != tt.want {
|
||||
t.Errorf("parseMB(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// collectTree flattens whatever shape the transport produced, because
|
||||
// the importer wants a flat set of paths inside staging.
|
||||
func TestCollectTree(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("directory tree is flattened", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
nested := filepath.Join(root, "CD1")
|
||||
|
||||
if err := os.MkdirAll(nested, 0o750); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(root, "a.flac"), []byte("x"), 0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(nested, "b.flac"), []byte("y"), 0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
dst := t.TempDir()
|
||||
|
||||
got, err := collectTree(root, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("collectTree: %v", err)
|
||||
}
|
||||
|
||||
if len(got.Files) != 2 {
|
||||
t.Fatalf("collected %d files, want 2", len(got.Files))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "single.flac")
|
||||
|
||||
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
dst := t.TempDir()
|
||||
|
||||
got, err := collectTree(file, dst)
|
||||
if err != nil {
|
||||
t.Fatalf("collectTree: %v", err)
|
||||
}
|
||||
|
||||
if len(got.Files) != 1 {
|
||||
t.Fatalf("collected %d files, want 1", len(got.Files))
|
||||
}
|
||||
|
||||
if filepath.Dir(got.Files[0]) != dst {
|
||||
t.Errorf("file landed at %s, want inside %s", got.Files[0], dst)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing path errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := collectTree(
|
||||
filepath.Join(t.TempDir(), "nope"), t.TempDir(),
|
||||
); err == nil {
|
||||
t.Error("want an error for a missing content path")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user