Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
3.2 KiB
Go
97 lines
3.2 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
|
)
|
|
|
|
const removeTestPath = "/music/Moe Shop/WWW (2020)/01 - WWW.mp3"
|
|
|
|
// removeFailure builds the error a delete service returns when the file would
|
|
// not go, wrapped the way quarantine.DeleteFile and tracks.RemoveTrack
|
|
// wrap it — the mapping has to see through that.
|
|
func removeFailure(errno syscall.Errno) error {
|
|
return fmt.Errorf("delete file: %w", &library.FileRemoveError{
|
|
Path: removeTestPath, UID: 1000, GID: 1000,
|
|
Err: &fs.PathError{Op: "remove", Path: removeTestPath, Err: errno},
|
|
})
|
|
}
|
|
|
|
func TestFileRemoveAPIError(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
errno syscall.Errno
|
|
wantStatus int
|
|
wantCode string
|
|
wantIn []string
|
|
}{
|
|
{
|
|
name: "read-only mount", errno: syscall.EROFS,
|
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
|
wantIn: []string{"uid 1000, gid 1000", "/music/Moe Shop/WWW (2020)", "read-only file system", "Nothing was deleted"},
|
|
},
|
|
{
|
|
name: "permission denied", errno: syscall.EACCES,
|
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
|
wantIn: []string{"permission denied", "Nothing was deleted"},
|
|
},
|
|
{
|
|
name: "operation not permitted", errno: syscall.EPERM,
|
|
wantStatus: http.StatusConflict, wantCode: "library_not_writable",
|
|
wantIn: []string{"operation not permitted"},
|
|
},
|
|
{
|
|
name: "i/o error", errno: syscall.EIO,
|
|
wantStatus: http.StatusInternalServerError, wantCode: "file_delete_failed",
|
|
wantIn: []string{removeTestPath, "input/output error", "Nothing was deleted"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
apiErr, ok := fileRemoveAPIError(removeFailure(tc.errno))
|
|
if !ok {
|
|
t.Fatal("a wrapped *library.FileRemoveError was not recognised")
|
|
}
|
|
if apiErr.Status != tc.wantStatus || apiErr.Code != tc.wantCode {
|
|
t.Fatalf("got %d %s, want %d %s", apiErr.Status, apiErr.Code, tc.wantStatus, tc.wantCode)
|
|
}
|
|
for _, want := range tc.wantIn {
|
|
if !strings.Contains(apiErr.Message, want) {
|
|
t.Errorf("message %q lacks %q", apiErr.Message, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The unwritable answer must name the DIRECTORY. Removal needs write access to
|
|
// the parent, so a message naming the file would send an admin to fix the
|
|
// wrong permissions. The directory is a prefix of the file path, which is why a
|
|
// plain "contains the directory" check could never catch that regression.
|
|
func TestFileRemoveAPIError_NotWritableNamesTheDirectoryNotTheFile(t *testing.T) {
|
|
apiErr, _ := fileRemoveAPIError(removeFailure(syscall.EROFS))
|
|
if strings.Contains(apiErr.Message, "01 - WWW.mp3") {
|
|
t.Fatalf("message names the file rather than its directory: %q", apiErr.Message)
|
|
}
|
|
}
|
|
|
|
func TestFileRemoveAPIError_IgnoresOtherErrors(t *testing.T) {
|
|
for name, err := range map[string]error{
|
|
"nil": nil,
|
|
"plain error": errors.New("delete track: connection reset"),
|
|
"path error": &fs.PathError{Op: "remove", Path: removeTestPath, Err: syscall.EROFS},
|
|
"not found": library.ErrTrackNotFound,
|
|
} {
|
|
if _, ok := fileRemoveAPIError(err); ok {
|
|
t.Errorf("%s: mapped as a file-remove failure", name)
|
|
}
|
|
}
|
|
}
|