summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-04 00:17:29 +0300
committerPaul Buetow <paul@buetow.org>2026-05-04 00:17:29 +0300
commit71e004fb4e53ee4ac5c8fb202bad5f5c00dd8dac (patch)
tree5b5c817f8418e2777db376999cdffd3ef018239c /internal/service
parentc68c54b0212ebc8d111b11c6edff82942c3a4463 (diff)
task b: inject app context into AdminService and propagate cancellation to background scans
Diffstat (limited to 'internal/service')
-rw-r--r--internal/service/admin.go8
-rw-r--r--internal/service/admin_test.go34
-rw-r--r--internal/service/scan.go8
-rw-r--r--internal/service/scan_test.go40
4 files changed, 66 insertions, 24 deletions
diff --git a/internal/service/admin.go b/internal/service/admin.go
index 1354005..11406b8 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -21,15 +21,15 @@ type adminService struct {
}
// NewAdminService creates a concrete AdminService.
-func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string) AdminService {
- return NewAdminServiceWithLogger(store, clk, hasher, sc, mediaRoot, slog.Default())
+func NewAdminService(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, appCtx context.Context) AdminService {
+ return NewAdminServiceWithLogger(store, clk, hasher, sc, mediaRoot, appCtx, slog.Default())
}
// NewAdminServiceWithLogger creates a concrete AdminService with an injected logger.
-func NewAdminServiceWithLogger(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, logger *slog.Logger) AdminService {
+func NewAdminServiceWithLogger(store repository.AdminServiceStore, clk clock.Clock, hasher auth.Hasher, sc scanner.Scanner, mediaRoot string, appCtx context.Context, logger *slog.Logger) AdminService {
return &adminService{
trashService: NewTrashService(store),
- scanService: NewScanService(sc, mediaRoot, clk, logger),
+ scanService: NewScanService(appCtx, sc, mediaRoot, clk, logger),
userAdminService: NewUserAdminService(store, clk, hasher),
permissionAdminService: NewPermissionAdminService(store, clk),
}
diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go
index 4117c58..17f5373 100644
--- a/internal/service/admin_test.go
+++ b/internal/service/admin_test.go
@@ -46,7 +46,7 @@ func TestAdminService_ListTrash(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
items, err := svc.ListTrash(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -67,7 +67,7 @@ func TestAdminService_TriggerRescan(t *testing.T) {
return nil
},
}
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx)
if err := svc.TriggerRescan(ctx); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -87,7 +87,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) {
return errors.New("scan failed")
},
}
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx)
err := svc.TriggerRescan(ctx)
// TriggerRescan now always returns nil immediately; failure is logged in background.
if err != nil {
@@ -98,7 +98,7 @@ func TestAdminService_TriggerRescan_Error(t *testing.T) {
func TestAdminService_TriggerRescan_NilScanner(t *testing.T) {
ctx := context.Background()
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
err := svc.TriggerRescan(ctx)
if err == nil {
t.Fatal("expected error when scanner is nil")
@@ -114,7 +114,7 @@ func TestAdminService_ListUsers(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
users, err := svc.ListUsers(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -158,7 +158,7 @@ func TestAdminService_CreateUser(t *testing.T) {
},
}
hasher := &fakeHasher{fixed: "hashed", err: tt.hashErr}
- svc := NewAdminService(store, newMockClock(), hasher, nil, "")
+ svc := NewAdminService(store, newMockClock(), hasher, nil, "", ctx)
user, err := svc.CreateUser(ctx, "alice", "secret", false)
if tt.wantErr {
if err == nil {
@@ -187,7 +187,7 @@ func TestAdminService_DeleteUser(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
if err := svc.DeleteUser(ctx, 1); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -210,7 +210,7 @@ func TestAdminService_ListPermissions(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
perms, err := svc.ListPermissions(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -234,7 +234,7 @@ func TestAdminService_GrantPermission(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
if err := svc.GrantPermission(ctx, 1, 2, model.RoleViewer); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -257,7 +257,7 @@ func TestAdminService_RevokePermission(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
if err := svc.RevokePermission(ctx, 1, 2); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -277,7 +277,7 @@ func TestAdminService_TriggerRescan_CancelsPrevious(t *testing.T) {
return scanCtx.Err()
},
}
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx)
// Start first scan.
if err := svc.TriggerRescan(ctx); err != nil {
@@ -308,7 +308,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) {
return nil
},
}
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx)
if err := svc.TriggerRescan(ctx); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -347,7 +347,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) {
}
// We replace the scanner field via reflection? No, easier: just create new service.
// Actually, the test verifies per-service fresh progress, so new service is fine.
- svc2 := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc2, "/media")
+ svc2 := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc2, "/media", ctx)
if err := svc2.TriggerRescan(ctx); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -380,7 +380,7 @@ func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) {
func TestAdminService_ScanProgress_ReturnsEmptyWhenNotStarted(t *testing.T) {
ctx := context.Background()
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
p := svc.ScanProgress(ctx)
if p.Running {
t.Fatal("expected not running when no scan started")
@@ -402,7 +402,7 @@ func TestAdminService_TriggerRescan_ConcurrentCalls(t *testing.T) {
return scanCtx.Err()
},
}
- svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media")
+ svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx)
for i := 0; i < 5; i++ {
wg.Add(1)
@@ -446,7 +446,7 @@ func TestAdminService_ListPermissions_Error(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
_, err := svc.ListPermissions(ctx)
if err == nil {
t.Fatal("expected error")
@@ -466,7 +466,7 @@ func TestAdminService_ListPermissions_Error(t *testing.T) {
},
},
}
- svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "")
+ svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx)
_, err := svc.ListPermissions(ctx)
if err == nil {
t.Fatal("expected error")
diff --git a/internal/service/scan.go b/internal/service/scan.go
index af17f95..ca4aca8 100644
--- a/internal/service/scan.go
+++ b/internal/service/scan.go
@@ -21,10 +21,11 @@ type scanService struct {
mu sync.Mutex
scanCancel context.CancelFunc
progress *model.ScanProgress
+ appCtx context.Context // application-level context used to propagate shutdown cancellation
}
// NewScanService creates a ScanService.
-func NewScanService(sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService {
+func NewScanService(appCtx context.Context, sc scanner.Scanner, mediaRoot string, clk clock.Clock, logger *slog.Logger) *scanService {
if logger == nil {
logger = slog.Default()
}
@@ -33,6 +34,7 @@ func NewScanService(sc scanner.Scanner, mediaRoot string, clk clock.Clock, logge
mediaRoot: mediaRoot,
clock: clk,
logger: logger,
+ appCtx: appCtx,
}
}
@@ -45,7 +47,9 @@ func (s *scanService) TriggerRescan(ctx context.Context) error {
if s.scanCancel != nil {
s.scanCancel()
}
- scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ // Derive the scan context from the application-level context so that
+ // cancellation propagates on server exit, while still applying a 30-minute timeout.
+ scanCtx, cancel := context.WithTimeout(s.appCtx, 30*time.Minute)
s.scanCancel = cancel
progress := &model.ScanProgress{}
s.progress = progress
diff --git a/internal/service/scan_test.go b/internal/service/scan_test.go
index 687776d..aba647f 100644
--- a/internal/service/scan_test.go
+++ b/internal/service/scan_test.go
@@ -37,7 +37,7 @@ func TestScanService_ScanLibrary(t *testing.T) {
return tt.scanErr
},
}
- svc := NewScanService(sc, "/media", clock.RealClock{}, nil)
+ svc := NewScanService(ctx, sc, "/media", clock.RealClock{}, nil)
err := svc.TriggerRescan(ctx)
if tt.wantErr {
if err == nil {
@@ -57,3 +57,41 @@ func TestScanService_ScanLibrary(t *testing.T) {
})
}
}
+
+func TestScanService_CancelledByAppContext(t *testing.T) {
+ appCtx, appCancel := context.WithCancel(context.Background())
+ defer appCancel()
+
+ done := make(chan struct{})
+ started := make(chan struct{})
+ sc := &fakeScanner{
+ scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error {
+ close(started)
+ <-scanCtx.Done()
+ close(done)
+ return scanCtx.Err()
+ },
+ }
+
+ svc := NewScanService(appCtx, sc, "/media", clock.RealClock{}, nil)
+ if err := svc.TriggerRescan(context.Background()); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ <-started
+ appCancel()
+
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timeout waiting for scan goroutine to exit after app context cancellation")
+ }
+
+ p := svc.ScanProgress(context.Background())
+ if p.Running {
+ t.Fatal("expected scan to be stopped after app context cancellation")
+ }
+ if p.LastError == "" {
+ t.Fatal("expected a last error after cancellation")
+ }
+}