diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-17 15:25:52 +0300 |
| commit | 914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch) | |
| tree | 02b11537237a204048589e14ed24938b805e42f7 /player-server/internal/service/admin_test.go | |
| parent | 3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff) | |
Restructure repo: move Go server into player-server/
Diffstat (limited to 'player-server/internal/service/admin_test.go')
| -rw-r--r-- | player-server/internal/service/admin_test.go | 506 |
1 files changed, 506 insertions, 0 deletions
diff --git a/player-server/internal/service/admin_test.go b/player-server/internal/service/admin_test.go new file mode 100644 index 0000000..0e4d309 --- /dev/null +++ b/player-server/internal/service/admin_test.go @@ -0,0 +1,506 @@ +package service + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "codeberg.org/snonux/player/internal/model" + "codeberg.org/snonux/player/internal/repository" +) + +type fakeScanner struct { + scanFunc func(ctx context.Context, root string, progress *model.ScanProgress) error +} + +func (f *fakeScanner) Scan(ctx context.Context, root string, progress *model.ScanProgress) error { + if f.scanFunc != nil { + return f.scanFunc(ctx, root, progress) + } + return nil +} + +func setScanDoneCh(t *testing.T, svc AdminService, doneCh chan<- struct{}) { + t.Helper() + adminSvc, ok := svc.(*adminService) + if !ok { + t.Fatalf("expected *adminService, got %T", svc) + } + adminSvc.scanService.doneCh = doneCh +} + +type fakeHasher struct { + fixed string + err error +} + +func (f *fakeHasher) Hash(password string) (string, error) { + if f.err != nil { + return "", f.err + } + return f.fixed, nil +} +func (f *fakeHasher) Compare(hash, password string) error { + return nil +} + +func TestAdminService_ListTrash(t *testing.T) { + ctx := context.Background() + store := &repository.MockStore{ + MediaRepo: repository.MockMediaRepo{ + ListDeletedMediaFunc: func(ctx context.Context) ([]model.Media, error) { + return []model.Media{{ID: 1, FileName: "a.mp4"}}, nil + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + items, err := svc.ListTrash(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } +} + +func TestAdminService_TriggerRescan(t *testing.T) { + ctx := context.Background() + var scannedRoot string + done := make(chan struct{}) + sc := &fakeScanner{ + scanFunc: func(_ context.Context, root string, _ *model.ScanProgress) error { + scannedRoot = root + close(done) + return nil + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-done + // Give the background goroutine a moment to write scannedRoot. + if scannedRoot != "/media" { + t.Fatalf("expected root %q, got %q", "/media", scannedRoot) + } +} + +func TestAdminService_TriggerRescan_Error(t *testing.T) { + ctx := context.Background() + done := make(chan struct{}) + sc := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, _ *model.ScanProgress) error { + close(done) + return errors.New("scan failed") + }, + } + 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 { + t.Fatalf("unexpected error: %v", err) + } + <-done +} + +func TestAdminService_TriggerRescan_PanicRecovered(t *testing.T) { + ctx := context.Background() + sc := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, _ *model.ScanProgress) error { + panic("scan boom") + }, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + svc := NewAdminServiceWithLogger(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx, logger) + scanDone := make(chan struct{}, 1) + setScanDoneCh(t, svc, scanDone) + + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + select { + case <-scanDone: + case <-time.After(time.Second): + t.Fatal("timeout waiting for scan panic recovery") + } + + progress := svc.ScanProgress(ctx) + if progress.Running { + t.Fatal("expected scan to be marked done after panic") + } + if progress.LastError != "rescan panic: scan boom" { + t.Fatalf("unexpected panic error: %q", progress.LastError) + } +} + +func TestAdminService_TriggerRescan_NilScanner(t *testing.T) { + ctx := context.Background() + 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") + } +} + +func TestAdminService_ListUsers(t *testing.T) { + ctx := context.Background() + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + ListUsersFunc: func(ctx context.Context) ([]model.User, error) { + return []model.User{{ID: 1, Username: "alice"}}, nil + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + users, err := svc.ListUsers(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } +} + +func TestAdminService_CreateUser(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + hashErr error + createErr error + wantErr bool + }{ + { + name: "ok", + }, + { + name: "hash error", + hashErr: errors.New("boom"), + wantErr: true, + }, + { + name: "create error", + createErr: errors.New("boom"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + CreateUserFunc: func(ctx context.Context, user *model.User) (int64, error) { + return 1, tt.createErr + }, + }, + } + hasher := &fakeHasher{fixed: "hashed", err: tt.hashErr} + svc := NewAdminService(store, newMockClock(), hasher, nil, "", ctx) + user, err := svc.CreateUser(ctx, "alice", "secret", false) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.Username != "alice" { + t.Fatalf("unexpected username %q", user.Username) + } + }) + } +} + +func TestAdminService_DeleteUser(t *testing.T) { + ctx := context.Background() + var called bool + store := &repository.MockStore{ + UserRepo: repository.MockUserRepo{ + DeleteUserFunc: func(ctx context.Context, id int64) error { + called = true + return nil + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + if err := svc.DeleteUser(ctx, 2, 1); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !called { + t.Fatal("expected delete called") + } +} + +func TestAdminService_ListPermissions(t *testing.T) { + ctx := context.Background() + store := &repository.MockStore{ + SetRepo: repository.MockSetRepo{ + ListSetsFunc: func(ctx context.Context) ([]model.Set, error) { + return []model.Set{{ID: 1}, {ID: 2}}, nil + }, + }, + SetPermissionRepo: repository.MockSetPermissionRepo{ + ListPermissionsBySetFunc: func(ctx context.Context, setID int64) ([]model.SetPermission, error) { + return []model.SetPermission{{SetID: setID, UserID: int64(setID) + 10}}, nil + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + perms, err := svc.ListPermissions(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if perms == nil || len(perms.Permissions) != 2 { + t.Fatalf("expected 2 permissions, got %+v", perms) + } + if len(perms.Sets) != 2 { + t.Fatalf("expected 2 sets, got %d", len(perms.Sets)) + } +} + +func TestAdminService_GrantPermission(t *testing.T) { + ctx := context.Background() + var granted *model.SetPermission + store := &repository.MockStore{ + SetPermissionRepo: repository.MockSetPermissionRepo{ + GrantPermissionFunc: func(ctx context.Context, perm *model.SetPermission) error { + granted = perm + return 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) + } + if granted == nil { + t.Fatal("expected permission granted") + } + if granted.Role != model.RoleViewer { + t.Fatalf("unexpected role %q", granted.Role) + } +} + +func TestAdminService_RevokePermission(t *testing.T) { + ctx := context.Background() + var revoked bool + store := &repository.MockStore{ + SetPermissionRepo: repository.MockSetPermissionRepo{ + RevokePermissionFunc: func(ctx context.Context, setID, userID int64) error { + revoked = true + return 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) + } + if !revoked { + t.Fatal("expected revoke called") + } +} + +func TestAdminService_TriggerRescan_CancelsPrevious(t *testing.T) { + ctx := context.Background() + started := make(chan struct{}, 2) + sc := &fakeScanner{ + scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(1) + started <- struct{}{} + <-scanCtx.Done() + return scanCtx.Err() + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) + + // Start first scan. + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started + + // Start second scan — should cancel the first. + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started + + // Verify final progress is from the second (still running) scan. + progress := svc.ScanProgress(ctx) + if !progress.Running { + t.Fatal("expected second scan to be running") + } +} + +func TestAdminService_TriggerRescan_FreshProgressPerScan(t *testing.T) { + ctx := context.Background() + started := make(chan struct{}) + done := make(chan struct{}) + sc := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(5) + close(started) + <-done + return nil + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) + scanDone := make(chan struct{}, 1) + setScanDoneCh(t, svc, scanDone) + + if err := svc.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started + close(done) + select { + case <-scanDone: + case <-time.After(time.Second): + t.Fatal("timeout waiting for first scan to finish") + } + + p1 := svc.ScanProgress(ctx) + if p1.SetsTotal != 5 { + t.Fatalf("expected sets_total 5, got %d", p1.SetsTotal) + } + + // Start a new scan on the same service with different progress. + started2 := make(chan struct{}) + done2 := make(chan struct{}) + sc2 := &fakeScanner{ + scanFunc: func(_ context.Context, _ string, progress *model.ScanProgress) error { + progress.Start(10) + close(started2) + <-done2 + return nil + }, + } + // 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", ctx) + scanDone2 := make(chan struct{}, 1) + setScanDoneCh(t, svc2, scanDone2) + if err := svc2.TriggerRescan(ctx); err != nil { + t.Fatalf("unexpected error: %v", err) + } + <-started2 + close(done2) + select { + case <-scanDone2: + case <-time.After(time.Second): + t.Fatal("timeout waiting for second scan to finish") + } + + p2 := svc2.ScanProgress(ctx) + if p2.SetsTotal != 10 { + t.Fatalf("expected sets_total 10, got %d", p2.SetsTotal) + } + if p2.LastError != "" { + t.Fatalf("unexpected last_error: %s", p2.LastError) + } +} + +func TestAdminService_ScanProgress_ReturnsEmptyWhenNotStarted(t *testing.T) { + ctx := context.Background() + 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") + } +} + +func TestAdminService_TriggerRescan_ConcurrentCalls(t *testing.T) { + ctx := context.Background() + var wg sync.WaitGroup + callCount := 0 + var mu sync.Mutex + started := make(chan struct{}, 5) + sc := &fakeScanner{ + scanFunc: func(scanCtx context.Context, _ string, progress *model.ScanProgress) error { + mu.Lock() + callCount++ + mu.Unlock() + progress.Start(1) + started <- struct{}{} + <-scanCtx.Done() + return scanCtx.Err() + }, + } + svc := NewAdminService(&repository.MockStore{}, newMockClock(), &fakeHasher{fixed: "hash"}, sc, "/media", ctx) + + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = svc.TriggerRescan(ctx) + }() + } + wg.Wait() + + timeout := time.After(time.Second) + for !svc.ScanProgress(ctx).Running { + select { + case <-started: + case <-timeout: + t.Fatal("timeout waiting for a running scan") + } + } + + mu.Lock() + if callCount == 0 { + t.Fatal("expected at least one scan to start") + } + mu.Unlock() + + // Final progress should reflect the last scan. + progress := svc.ScanProgress(ctx) + if !progress.Running { + t.Fatal("expected a scan to be running after concurrent calls") + } +} + +func TestAdminService_ListPermissions_Error(t *testing.T) { + ctx := context.Background() + + t.Run("list sets error", func(t *testing.T) { + store := &repository.MockStore{ + SetRepo: repository.MockSetRepo{ + ListSetsFunc: func(ctx context.Context) ([]model.Set, error) { + return nil, errors.New("boom") + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + _, err := svc.ListPermissions(ctx) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("list permissions by set error", func(t *testing.T) { + store := &repository.MockStore{ + SetRepo: repository.MockSetRepo{ + ListSetsFunc: func(ctx context.Context) ([]model.Set, error) { + return []model.Set{{ID: 1}}, nil + }, + }, + SetPermissionRepo: repository.MockSetPermissionRepo{ + ListPermissionsBySetFunc: func(ctx context.Context, setID int64) ([]model.SetPermission, error) { + return nil, errors.New("boom") + }, + }, + } + svc := NewAdminService(store, newMockClock(), &fakeHasher{fixed: "hash"}, nil, "", ctx) + _, err := svc.ListPermissions(ctx) + if err == nil { + t.Fatal("expected error") + } + }) +} |
