package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // POST /api/scanner/cancel/:jobId is admin-only and must answer with // distinct codes for unknown (404) and already-terminal (409) jobs. func TestScanCancelEndpointSemantics(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} t.Run("cancel without admin role is forbidden", func(t *testing.T) { url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String()) req, _ := http.NewRequest("POST", url, nil) req.Header.Set("Authorization", "Bearer "+setup.RegularToken) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusForbidden, resp.StatusCode) }) t.Run("cancel unknown job is not found", func(t *testing.T) { url := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, uuid.New().String()) req, _ := http.NewRequest("POST", url, nil) req.Header.Set("Authorization", "Bearer "+setup.Token) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) } // Cancelling a manual scan mid-run must never corrupt library state: // the abort skips the archive pass, so unvisited (but on-disk) files // are not treated as missing. The canary item points at a file that is // lexically LAST in the folder, so a mid-walk abort that (incorrectly) // ran the archive pass would mark it missing and fail the assertion. // The assertions hold under every interleaving — whether the cancel // lands mid-scan or the scan already finished — so the test cannot // flap against timing. func TestScanCancelMidScanSafety(t *testing.T) { setup := setupTestServer(t) client := &http.Client{} token := setup.Token // Test library (helper registers API-delete cleanup so the run // leaves no residue in the dev database). libraryID := createTestLibraryWithFolder(t, setup.Server, token, "Test Scan Cancel Library", false) libUUID, err := uuid.Parse(libraryID) require.NoError(t, err) // Folder on disk: many junk .epub files (zero bytes cannot parse as // a zip/epub, so processMediaFile rejects each one and creates no // rows) plus the canary file, lexically last. dir := t.TempDir() const fileCount = 1200 junk := bytes.Repeat([]byte{0}, 1024) for i := 0; i < fileCount; i++ { name := filepath.Join(dir, fmt.Sprintf("book-%04d.epub", i)) require.NoError(t, os.WriteFile(name, junk, 0o644)) } canaryRel := "zzz-last.epub" require.NoError(t, os.WriteFile(filepath.Join(dir, canaryRel), junk, 0o644)) // Register the folder with the library. folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID) body, _ := json.Marshal(map[string]interface{}{"folder_path": dir}) req, _ := http.NewRequest("POST", folderURL, bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) require.NoError(t, err) _ = resp.Body.Close() require.Equal(t, http.StatusCreated, resp.StatusCode, "folder creation should succeed") // Pre-existing item whose file exists on disk. A normal completed // scan never marks it missing; neither may a cancelled one. _, err = setup.DBPool.Exec(context.Background(), "INSERT INTO media_items (library_id, title, file_path) VALUES ($1, $2, $3)", pgtype.UUID{Bytes: libUUID, Valid: true}, "Test Cancel Canary", canaryRel) require.NoError(t, err) // Start the scan and cancel it as soon as it is accepted. jobID := startScan(t, client, setup.Server.URL, token, libraryID) cancelURL := fmt.Sprintf("%s/api/scanner/cancel/%s", setup.Server.URL, jobID) cancelReq, _ := http.NewRequest("POST", cancelURL, nil) cancelReq.Header.Set("Authorization", "Bearer "+token) cancelResp, err := client.Do(cancelReq) require.NoError(t, err) _ = cancelResp.Body.Close() // 200 = cancelled mid-run; 409 = the scan already finished before // the cancel landed. Both are acceptable; the assertions below hold // either way. assert.Contains(t, []int{http.StatusOK, http.StatusConflict}, cancelResp.StatusCode) // Poll to a terminal state. final := pollJobToTerminal(t, client, setup.Server.URL, token, jobID) assert.Contains(t, []string{"completed", "cancelled"}, final["status"], "scan must finish cleanly or be cancelled, got %v (error: %v)", final["status"], final["error"]) // The safety invariant: no item was marked missing or archived, and // the junk files created no phantom rows. var items, missingSum, archived int err = setup.DBPool.QueryRow(context.Background(), `SELECT COUNT(*), COALESCE(SUM(missing_scan_count), 0), COUNT(*) FILTER (WHERE archived_at IS NOT NULL) FROM media_items WHERE library_id = $1`, pgtype.UUID{Bytes: libUUID, Valid: true}).Scan(&items, &missingSum, &archived) require.NoError(t, err) assert.Equal(t, 1, items, "junk files must not create media items") assert.Zero(t, missingSum, "cancelled/completed scan must not mark on-disk files missing") assert.Zero(t, archived, "no item may be archived by this scan") // Cancelling a terminal job is a conflict, whichever terminal state // the job reached. cancelReq2, _ := http.NewRequest("POST", cancelURL, nil) cancelReq2.Header.Set("Authorization", "Bearer "+token) cancelResp2, err := client.Do(cancelReq2) require.NoError(t, err) _ = cancelResp2.Body.Close() assert.Equal(t, http.StatusConflict, cancelResp2.StatusCode) } func startScan(t *testing.T, client *http.Client, baseURL, token, libraryID string) string { url := fmt.Sprintf("%s/api/libraries/%s/scan", baseURL, libraryID) req, _ := http.NewRequest("POST", url, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusAccepted, resp.StatusCode) var decoded map[string]interface{} require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded)) jobID, ok := decoded["job_id"].(string) require.True(t, ok, "job_id should be a string") require.NotEmpty(t, jobID) return jobID } func pollJobToTerminal(t *testing.T, client *http.Client, baseURL, token, jobID string) map[string]interface{} { url := fmt.Sprintf("%s/api/scanner/status/%s", baseURL, jobID) for i := 0; i < 30; i++ { req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) require.NoError(t, err) var status map[string]interface{} err = json.NewDecoder(resp.Body).Decode(&status) _ = resp.Body.Close() require.NoError(t, err) switch status["status"] { case "completed", "failed", "cancelled": return status } time.Sleep(500 * time.Millisecond) } t.Fatalf("job %s did not reach a terminal state within 15s", jobID) return nil }