summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-30 23:03:39 +0300
committerPaul Buetow <paul@buetow.org>2026-04-30 23:03:39 +0300
commitcfbffc5e8911f0f4bdd63c0fcf4953372a990ac0 (patch)
tree37dd46ae6cc1a9cd735aa7e4d2239c315735385c /internal/service
parentdfa05f2f78911c8817ff15e0c4f3806ddabdc32e (diff)
pa: raise aggregate test coverage to 81.5%
Diffstat (limited to 'internal/service')
-rw-r--r--internal/service/admin_test.go39
-rw-r--r--internal/service/gc_test.go18
-rw-r--r--internal/service/media_test.go218
-rw-r--r--internal/service/mock_test.go160
4 files changed, 435 insertions, 0 deletions
diff --git a/internal/service/admin_test.go b/internal/service/admin_test.go
index bc6e9c5..d6dbfeb 100644
--- a/internal/service/admin_test.go
+++ b/internal/service/admin_test.go
@@ -252,3 +252,42 @@ func TestAdminService_RevokePermission(t *testing.T) {
t.Fatal("expected revoke called")
}
}
+
+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, "")
+ _, 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, "")
+ _, err := svc.ListPermissions(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ })
+}
diff --git a/internal/service/gc_test.go b/internal/service/gc_test.go
index 381cfd4..541bc34 100644
--- a/internal/service/gc_test.go
+++ b/internal/service/gc_test.go
@@ -166,3 +166,21 @@ func TestGCWorker_RelPathFallback(t *testing.T) {
t.Fatal("expected file to be deleted")
}
}
+
+func TestGCWorker_WithInterval(t *testing.T) {
+ now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ w := NewGCWorker(&repository.MockStore{}, &clock.MockClock{T: now}, "/tmp", time.Minute, logger).WithInterval(2 * time.Minute)
+ if w.interval != 2*time.Minute {
+ t.Fatalf("expected interval 2m, got %v", w.interval)
+ }
+}
+
+func TestGCWorker_RunOnce_NotStarted(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
+ w := NewGCWorker(&repository.MockStore{}, &clock.MockClock{}, "/tmp", 0, logger)
+ err := w.RunOnce()
+ if err == nil {
+ t.Fatal("expected error when interval is 0")
+ }
+}
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index e3e7b42..ef20dd5 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -1925,3 +1925,221 @@ func TestMediaService_RegenerateSetCover(t *testing.T) {
}
})
}
+
+func TestMediaService_GetThumbnail(t *testing.T) {
+ ctx := context.Background()
+ tmpDir := t.TempDir()
+ thumbPath := filepath.Join(tmpDir, "thumb.jpg")
+ _ = os.WriteFile(thumbPath, []byte("thumb"), 0o644)
+
+ tests := []struct {
+ name string
+ media *model.Media
+ wantPath string
+ wantErr bool
+ }{
+ {
+ name: "ok",
+ media: &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/a.mp4", FileName: "a.mp4", ThumbnailPath: thumbPath},
+ wantPath: thumbPath,
+ },
+ {
+ name: "no thumbnail path",
+ media: &model.Media{ID: 1, SetID: 1, AbsPath: "/tmp/a.mp4", FileName: "a.mp4", ThumbnailPath: ""},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ store := &repository.MockStore{
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return tt.media, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: 1, IsAdmin: true}, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ res, err := svc.GetThumbnail(ctx, 1, 1)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.Path != tt.wantPath {
+ t.Fatalf("unexpected path %q", res.Path)
+ }
+ })
+ }
+}
+
+func TestMediaService_RevokeShare(t *testing.T) {
+ ctx := context.Background()
+ now := newMockClock().T
+
+ tests := []struct {
+ name string
+ share *model.Share
+ media *model.Media
+ wantErr bool
+ }{
+ {
+ name: "ok",
+ share: &model.Share{Token: "abc", MediaID: 1, CreatedBy: 1, ExpiresAt: now.Add(time.Hour)},
+ media: &model.Media{ID: 1, SetID: 1},
+ },
+ {
+ name: "share not found",
+ share: nil,
+ wantErr: true,
+ },
+ {
+ name: "access denied",
+ share: &model.Share{Token: "abc", MediaID: 1, CreatedBy: 1, ExpiresAt: now.Add(time.Hour)},
+ media: &model.Media{ID: 1, SetID: 1},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ store := &repository.MockStore{
+ ShareRepo: repository.MockShareRepo{
+ GetShareByTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
+ return tt.share, nil
+ },
+ DeleteShareFunc: func(ctx context.Context, token string) error {
+ return nil
+ },
+ },
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return tt.media, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ if tt.name == "access denied" {
+ return &model.User{ID: id, IsAdmin: false}, nil
+ }
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
+ SetRepo: repository.MockSetRepo{
+ GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) {
+ return &model.Set{ID: id}, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ err := svc.RevokeShare(ctx, "abc", 1)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+func TestMediaService_ListShares(t *testing.T) {
+ ctx := context.Background()
+ now := newMockClock().T
+
+ store := &repository.MockStore{
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return &model.Media{ID: 1, SetID: 1}, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
+ ShareRepo: repository.MockShareRepo{
+ ListSharesByMediaFunc: func(ctx context.Context, mediaID int64) ([]model.Share, error) {
+ return []model.Share{{Token: "abc", MediaID: 1, ExpiresAt: now.Add(time.Hour)}}, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ shares, err := svc.ListShares(ctx, 1, 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(shares) != 1 {
+ t.Fatalf("expected 1 share, got %d", len(shares))
+ }
+}
+
+func TestMediaService_StreamSharedMedia_MissingMedia(t *testing.T) {
+ ctx := context.Background()
+ now := newMockClock().T
+
+ store := &repository.MockStore{
+ ShareRepo: repository.MockShareRepo{
+ GetShareByTokenFunc: func(ctx context.Context, token string) (*model.Share, error) {
+ return &model.Share{Token: "abc", MediaID: 1, ExpiresAt: now.Add(time.Hour)}, nil
+ },
+ UseShareFunc: func(ctx context.Context, token string) error { return nil },
+ },
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return nil, nil
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ _, err := svc.StreamSharedMedia(ctx, "abc")
+ if !errors.Is(err, ErrMediaNotFound) {
+ t.Fatalf("expected ErrMediaNotFound, got %v", err)
+ }
+}
+
+func TestMediaService_GuessMediaType_Edge(t *testing.T) {
+ if got := guessMediaType("unknown.xyz"); got != model.MediaTypeVideo {
+ t.Fatalf("expected video for unknown ext, got %v", got)
+ }
+}
+
+func TestMediaService_CreateShare_StoreError(t *testing.T) {
+ ctx := context.Background()
+ now := newMockClock().T
+
+ store := &repository.MockStore{
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return &model.Media{ID: 1, SetID: 1}, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: 1, IsAdmin: true}, nil
+ },
+ },
+ ShareRepo: repository.MockShareRepo{
+ CreateShareFunc: func(ctx context.Context, share *model.Share) error {
+ return errors.New("db error")
+ },
+ },
+ }
+ svc := NewMediaService(store, newMockClock(), "/tmp/media", nil, nil)
+ _, err := svc.CreateShare(ctx, 1, 1, now.Add(time.Hour))
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
diff --git a/internal/service/mock_test.go b/internal/service/mock_test.go
new file mode 100644
index 0000000..796de58
--- /dev/null
+++ b/internal/service/mock_test.go
@@ -0,0 +1,160 @@
+package service
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "codeberg.org/snonux/play/internal/model"
+ "codeberg.org/snonux/play/internal/repository"
+)
+
+func TestMockMediaService_Defaults(t *testing.T) {
+ ctx := context.Background()
+ m := &MockMediaService{}
+
+ // Methods that return nil, nil or zero-value defaults
+ m.ListSets(ctx, 1)
+ m.GetMediaDetail(ctx, 1, 1)
+ m.ListMedia(ctx, 1, repository.MediaFilter{})
+ m.ToggleFavorite(ctx, 1, 1)
+ m.AssignTag(ctx, 1, 1, "rock")
+ m.RemoveTag(ctx, 1, 1, "rock")
+ m.SoftDeleteMedia(ctx, 1, 1)
+ m.RestoreMedia(ctx, 1, 1)
+ m.RegenerateThumbnail(ctx, 1, 1)
+ m.RegenerateSetCover(ctx, 1, 1)
+ m.GetNote(ctx, 1, 1)
+ m.UpsertNote(ctx, &model.Note{MediaID: 1, UserID: 1, Content: "hi"})
+ m.DeleteNote(ctx, 1, 1)
+ m.ListShares(ctx, 1, 1)
+ m.RevokeShare(ctx, "abc", 1)
+ m.ValidateShareToken(ctx, "abc")
+
+ // Methods that return errors when not implemented
+ if _, err := m.StreamMedia(ctx, 1, 1); err == nil {
+ t.Fatal("expected error")
+ }
+ if _, err := m.DownloadMedia(ctx, 1, 1); err == nil {
+ t.Fatal("expected error")
+ }
+ if _, err := m.GetThumbnail(ctx, 1, 1); err == nil {
+ t.Fatal("expected error")
+ }
+ if _, err := m.UploadMedia(ctx, 1, 1, "x.mp3", strings.NewReader("x"), 1); err == nil {
+ t.Fatal("expected error")
+ }
+ if _, err := m.CreateShare(ctx, 1, 1, time.Now()); err == nil {
+ t.Fatal("expected error")
+ }
+ if _, err := m.StreamSharedMedia(ctx, "abc"); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestMockMediaService_WithFuncs(t *testing.T) {
+ ctx := context.Background()
+ m := &MockMediaService{
+ ListSetsFunc: func(ctx context.Context, userID int64) ([]model.Set, error) { return nil, nil },
+ GetMediaDetailFunc: func(ctx context.Context, mediaID, userID int64) (*MediaDetail, error) { return nil, nil },
+ ListMediaFunc: func(ctx context.Context, userID int64, filter repository.MediaFilter) ([]model.Media, error) { return nil, nil },
+ StreamMediaFunc: func(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return nil, nil },
+ DownloadMediaFunc: func(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return nil, nil },
+ GetThumbnailFunc: func(ctx context.Context, mediaID, userID int64) (*FileResult, error) { return nil, nil },
+ RegenerateThumbnailFunc: func(ctx context.Context, mediaID, userID int64) error { return nil },
+ RegenerateSetCoverFunc: func(ctx context.Context, setID, userID int64) error { return nil },
+ ToggleFavoriteFunc: func(ctx context.Context, userID, mediaID int64) (bool, error) { return false, nil },
+ AssignTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { return nil },
+ RemoveTagFunc: func(ctx context.Context, mediaID, userID int64, tagName string) error { return nil },
+ SoftDeleteMediaFunc: func(ctx context.Context, mediaID, userID int64) error { return nil },
+ RestoreMediaFunc: func(ctx context.Context, mediaID, userID int64) error { return nil },
+ UploadMediaFunc: func(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error) { return nil, nil },
+ CreateShareFunc: func(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) { return nil, nil },
+ ListSharesFunc: func(ctx context.Context, mediaID, userID int64) ([]model.Share, error) { return nil, nil },
+ RevokeShareFunc: func(ctx context.Context, token string, userID int64) error { return nil },
+ ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { return nil, nil },
+ StreamSharedMediaFunc: func(ctx context.Context, token string) (*FileResult, error) { return nil, nil },
+ GetNoteFunc: func(ctx context.Context, mediaID, userID int64) (*model.Note, error) { return nil, nil },
+ UpsertNoteFunc: func(ctx context.Context, note *model.Note) error { return nil },
+ DeleteNoteFunc: func(ctx context.Context, mediaID, userID int64) error { return nil },
+ }
+
+ m.ListSets(ctx, 1)
+ m.GetMediaDetail(ctx, 1, 1)
+ m.ListMedia(ctx, 1, repository.MediaFilter{})
+ m.StreamMedia(ctx, 1, 1)
+ m.DownloadMedia(ctx, 1, 1)
+ m.GetThumbnail(ctx, 1, 1)
+ m.RegenerateThumbnail(ctx, 1, 1)
+ m.RegenerateSetCover(ctx, 1, 1)
+ m.ToggleFavorite(ctx, 1, 1)
+ m.AssignTag(ctx, 1, 1, "rock")
+ m.RemoveTag(ctx, 1, 1, "rock")
+ m.SoftDeleteMedia(ctx, 1, 1)
+ m.RestoreMedia(ctx, 1, 1)
+ m.UploadMedia(ctx, 1, 1, "x.mp3", strings.NewReader("x"), 1)
+ m.CreateShare(ctx, 1, 1, time.Now())
+ m.ListShares(ctx, 1, 1)
+ m.RevokeShare(ctx, "abc", 1)
+ m.ValidateShareToken(ctx, "abc")
+ m.StreamSharedMedia(ctx, "abc")
+ m.GetNote(ctx, 1, 1)
+ m.UpsertNote(ctx, &model.Note{MediaID: 1, UserID: 1, Content: "hi"})
+ m.DeleteNote(ctx, 1, 1)
+}
+
+func TestMockAdminService_Defaults(t *testing.T) {
+ ctx := context.Background()
+ m := &MockAdminService{}
+
+ m.ListTrash(ctx)
+ m.TriggerRescan(ctx)
+ m.ListUsers(ctx)
+ m.DeleteUser(ctx, 1)
+ m.ListPermissions(ctx)
+ m.GrantPermission(ctx, 1, 2, model.RoleViewer)
+ m.RevokePermission(ctx, 1, 2)
+
+ if _, err := m.CreateUser(ctx, "alice", "secret", false); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestMockAdminService_WithFuncs(t *testing.T) {
+ ctx := context.Background()
+ m := &MockAdminService{
+ ListTrashFunc: func(ctx context.Context) ([]model.Media, error) { return nil, nil },
+ TriggerRescanFunc: func(ctx context.Context) error { return nil },
+ ListUsersFunc: func(ctx context.Context) ([]model.User, error) { return nil, nil },
+ CreateUserFunc: func(ctx context.Context, username, password string, isAdmin bool) (*model.User, error) { return nil, nil },
+ DeleteUserFunc: func(ctx context.Context, id int64) error { return nil },
+ ListPermissionsFunc: func(ctx context.Context) ([]model.SetPermission, error) { return nil, nil },
+ GrantPermissionFunc: func(ctx context.Context, setID, userID int64, role model.Role) error { return nil },
+ RevokePermissionFunc: func(ctx context.Context, setID, userID int64) error { return nil },
+ }
+
+ m.ListTrash(ctx)
+ m.TriggerRescan(ctx)
+ m.ListUsers(ctx)
+ m.CreateUser(ctx, "alice", "secret", false)
+ m.DeleteUser(ctx, 1)
+ m.ListPermissions(ctx)
+ m.GrantPermission(ctx, 1, 2, model.RoleViewer)
+ m.RevokePermission(ctx, 1, 2)
+}
+
+func TestMockProgressService_Defaults(t *testing.T) {
+ ctx := context.Background()
+ m := &MockProgressService{}
+ m.UpdateProgress(ctx, "sess", 1, 1, 10)
+}
+
+func TestMockProgressService_WithFunc(t *testing.T) {
+ ctx := context.Background()
+ m := &MockProgressService{
+ UpdateProgressFunc: func(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error { return nil },
+ }
+ m.UpdateProgress(ctx, "sess", 1, 1, 10)
+}