diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-30 23:03:39 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-30 23:03:39 +0300 |
| commit | cfbffc5e8911f0f4bdd63c0fcf4953372a990ac0 (patch) | |
| tree | 37dd46ae6cc1a9cd735aa7e4d2239c315735385c /internal/service | |
| parent | dfa05f2f78911c8817ff15e0c4f3806ddabdc32e (diff) | |
pa: raise aggregate test coverage to 81.5%
Diffstat (limited to 'internal/service')
| -rw-r--r-- | internal/service/admin_test.go | 39 | ||||
| -rw-r--r-- | internal/service/gc_test.go | 18 | ||||
| -rw-r--r-- | internal/service/media_test.go | 218 | ||||
| -rw-r--r-- | internal/service/mock_test.go | 160 |
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) +} |
