test(scan): cancellation endpoint semantics and mid-run safety
- endpoint semantics: non-admin cancel is 403, unknown job is 404 - mid-scan safety: start a scan of 1200 unparseable .epub files and cancel it, then pin the invariant that no media item is marked missing or archived. The canary item's file is lexically LAST in the folder, so an abort that (incorrectly) ran the archive pass would treat it as unvisited and trip the assertion. Assertions hold under every interleaving - whether the cancel lands mid-walk or the scan already finished - so the test cannot flap against timing. - re-cancelling a terminal job is a 409 The test library is created through the shared createTestLibraryWithFolder helper, which registers API-delete cleanup so runs leave no residue in the dev database. Worker-level unit tests live in internal/services/worker_cancel_test.go (previous commit).
This commit is contained in:
1 parent
e1153b2c4b
commit
24228e7147
1 file changed
+182
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user