summaryrefslogtreecommitdiff
path: root/internal
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
parentdfa05f2f78911c8817ff15e0c4f3806ddabdc32e (diff)
pa: raise aggregate test coverage to 81.5%
Diffstat (limited to 'internal')
-rw-r--r--internal/clock/clock_test.go24
-rw-r--r--internal/repository/sqlite_test.go815
-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
-rw-r--r--internal/setassign/setassign_test.go9
7 files changed, 1283 insertions, 0 deletions
diff --git a/internal/clock/clock_test.go b/internal/clock/clock_test.go
new file mode 100644
index 0000000..441e261
--- /dev/null
+++ b/internal/clock/clock_test.go
@@ -0,0 +1,24 @@
+package clock
+
+import (
+ "testing"
+ "time"
+)
+
+func TestRealClock_Now(t *testing.T) {
+ c := RealClock{}
+ before := time.Now()
+ got := c.Now()
+ after := time.Now()
+ if got.Before(before) || got.After(after) {
+ t.Fatalf("RealClock.Now() out of range: %v", got)
+ }
+}
+
+func TestMockClock_Now(t *testing.T) {
+ fixed := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+ c := &MockClock{T: fixed}
+ if got := c.Now(); got != fixed {
+ t.Fatalf("expected %v, got %v", fixed, got)
+ }
+}
diff --git a/internal/repository/sqlite_test.go b/internal/repository/sqlite_test.go
index 0d5e797..0fb3109 100644
--- a/internal/repository/sqlite_test.go
+++ b/internal/repository/sqlite_test.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "database/sql"
"testing"
"time"
@@ -849,3 +850,817 @@ func TestSQLite_SetPermissionRepo(t *testing.T) {
})
}
}
+
+func TestSQLite_MediaFilters(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(t *testing.T, ctx context.Context, s *SQLite)
+ }{
+ {
+ name: "favorites filter",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ uid, _ := s.CreateUser(ctx, &model.User{Username: "u", PasswordHash: "h", CreatedAt: now})
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ mid, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ _, _ = s.ToggleFavorite(ctx, uid, mid)
+ res, err := s.ListMedia(ctx, MediaFilter{Favorites: true, UserID: uid})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(res) != 1 {
+ t.Fatalf("expected 1 favorite media, got %d", len(res))
+ }
+ },
+ },
+ {
+ name: "type filter",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "b.mp3", FileName: "b.mp3", AbsPath: "/s/b.mp3", Type: model.MediaTypeAudio, CreatedAt: now})
+ audio := model.MediaTypeAudio
+ res, err := s.ListMedia(ctx, MediaFilter{Type: &audio})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(res) != 1 || res[0].FileName != "b.mp3" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+ },
+ },
+ {
+ name: "duration and sort filters",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, Duration: 100, PlayCount: 5, CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "b.mp4", FileName: "b.mp4", AbsPath: "/s/b.mp4", Type: model.MediaTypeVideo, Duration: 200, PlayCount: 1, CreatedAt: now.Add(time.Hour)})
+ minDur := 150.0
+ res, err := s.ListMedia(ctx, MediaFilter{MinDuration: &minDur, Sort: "duration"})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(res) != 1 || res[0].FileName != "b.mp4" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+ res, _ = s.ListMedia(ctx, MediaFilter{Sort: "play_count"})
+ if len(res) != 2 || res[0].FileName != "a.mp4" {
+ t.Fatalf("unexpected play_count sort: %+v", res)
+ }
+ res, _ = s.ListMedia(ctx, MediaFilter{Sort: "date"})
+ if len(res) != 2 || res[0].FileName != "b.mp4" {
+ t.Fatalf("unexpected date sort: %+v", res)
+ }
+ res, _ = s.ListMedia(ctx, MediaFilter{Sort: "random"})
+ if len(res) != 2 {
+ t.Fatalf("unexpected random sort count: %d", len(res))
+ }
+ },
+ },
+ {
+ name: "limit offset",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "b.mp4", FileName: "b.mp4", AbsPath: "/s/b.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ res, err := s.ListMedia(ctx, MediaFilter{Limit: 1, Offset: 1})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(res) != 1 {
+ t.Fatalf("expected 1, got %d", len(res))
+ }
+ },
+ },
+ {
+ name: "allowed set ids",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ s1, _ := s.CreateSet(ctx, &model.Set{Name: "s1", RootPath: "/s1", CreatedAt: now})
+ s2, _ := s.CreateSet(ctx, &model.Set{Name: "s2", RootPath: "/s2", CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: s1, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s1/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ _, _ = s.CreateMedia(ctx, &model.Media{SetID: s2, RelPath: "b.mp4", FileName: "b.mp4", AbsPath: "/s2/b.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ res, err := s.ListMedia(ctx, MediaFilter{AllowedSetIDs: []int64{s1}})
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(res) != 1 || res[0].FileName != "a.mp4" {
+ t.Fatalf("unexpected result: %+v", res)
+ }
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newTestStore(t)
+ defer s.Close()
+ tt.run(t, context.Background(), s)
+ })
+ }
+}
+
+func TestSQLite_Ping(t *testing.T) {
+ s := newTestStore(t)
+ defer s.Close()
+ if err := s.Ping(context.Background()); err != nil {
+ t.Fatalf("ping: %v", err)
+ }
+}
+
+func TestSQLite_Helpers(t *testing.T) {
+ if got := sqlNullTime(nil); got.Valid {
+ t.Fatal("expected sqlNullTime(nil) to be invalid")
+ }
+ if got := sqlNullInt(nil); got.Valid {
+ t.Fatal("expected sqlNullInt(nil) to be invalid")
+ }
+}
+
+func TestSQLite_MiscRepos(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(t *testing.T, ctx context.Context, s *SQLite)
+ }{
+ {
+ name: "IsFavorite false for missing",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ ok, err := s.IsFavorite(ctx, 1, 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ok {
+ t.Fatal("expected false")
+ }
+ },
+ },
+ {
+ name: "ListFavoritesByUser empty",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ favs, err := s.ListFavoritesByUser(ctx, 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(favs) != 0 {
+ t.Fatalf("expected 0, got %d", len(favs))
+ }
+ },
+ },
+ {
+ name: "CountUsers zero",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ cnt, err := s.CountUsers(ctx)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if cnt != 0 {
+ t.Fatalf("expected 0, got %d", cnt)
+ }
+ },
+ },
+ {
+ name: "DeleteTag and scanSet cover",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ id, _ := s.CreateTag(ctx, "action")
+ if err := s.DeleteTag(ctx, id); err != nil {
+ t.Fatalf("delete tag: %v", err)
+ }
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ if err := s.UpdateSet(ctx, &model.Set{ID: sid, Name: "s2", RootPath: "/s2", CoverThumbnailPath: "/cover.jpg"}); err != nil {
+ t.Fatalf("update set: %v", err)
+ }
+ got, _ := s.GetSetByID(ctx, sid)
+ if got.CoverThumbnailPath != "/cover.jpg" {
+ t.Fatalf("unexpected cover path: %s", got.CoverThumbnailPath)
+ }
+ },
+ },
+ {
+ name: "AssignTag and RemoveTag",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ mid, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ tid, _ := s.CreateTag(ctx, "rock")
+ if err := s.AssignTag(ctx, mid, tid); err != nil {
+ t.Fatalf("assign: %v", err)
+ }
+ if err := s.RemoveTag(ctx, mid, tid); err != nil {
+ t.Fatalf("remove: %v", err)
+ }
+ tags, _ := s.ListTagsByMedia(ctx, mid)
+ if len(tags) != 0 {
+ t.Fatalf("expected 0 tags, got %d", len(tags))
+ }
+ },
+ },
+ {
+ name: "UpdateMedia with all fields",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ now := time.Now().Truncate(time.Second)
+ sid, _ := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s", CreatedAt: now})
+ mid, _ := s.CreateMedia(ctx, &model.Media{SetID: sid, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo, CreatedAt: now})
+ m, _ := s.GetMediaByID(ctx, mid)
+ m.Duration = 120
+ m.Codec = "h264"
+ m.Resolution = "1920x1080"
+ m.Bitrate = 5000
+ m.FileSizeBytes = 1000
+ m.ThumbnailPath = "/t.jpg"
+ m.PlayCount = 3
+ if err := s.UpdateMedia(ctx, m); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ got, _ := s.GetMediaByID(ctx, mid)
+ if got.Duration != 120 || got.Codec != "h264" || got.Resolution != "1920x1080" || got.Bitrate != 5000 || got.FileSizeBytes != 1000 || got.ThumbnailPath != "/t.jpg" || got.PlayCount != 3 {
+ t.Fatalf("unexpected update: %+v", got)
+ }
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newTestStore(t)
+ defer s.Close()
+ tt.run(t, context.Background(), s)
+ })
+ }
+}
+
+func TestSQLite_OpenFailures(t *testing.T) {
+ t.Run("invalid dsn", func(t *testing.T) {
+ _, err := Open("/dev/null/invalid")
+ if err == nil {
+ t.Fatal("expected error for invalid dsn")
+ }
+ })
+
+ t.Run("closed db migrate failure", func(t *testing.T) {
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ db.Close()
+ _, err = New(db)
+ if err == nil {
+ t.Fatal("expected error when migrating closed db")
+ }
+ })
+}
+
+func TestSQLite_ErrorPaths(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(t *testing.T, ctx context.Context, s *SQLite)
+ }{
+ {
+ name: "Ping on closed store",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.Ping(ctx)
+ if err == nil {
+ t.Fatal("expected error pinging closed store")
+ }
+ },
+ },
+ {
+ name: "CreateUser error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.CreateUser(ctx, &model.User{Username: "u", PasswordHash: "h"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CreateSet error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.CreateSet(ctx, &model.Set{Name: "s", RootPath: "/s"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CreateMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.CreateMedia(ctx, &model.Media{SetID: 1, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CreateTag error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.CreateTag(ctx, "rock")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CreateSession error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.CreateSession(ctx, &model.Session{ID: "abc", UserID: 1, ExpiresAt: time.Now()})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CreateShare error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.CreateShare(ctx, &model.Share{Token: "t", MediaID: 1, CreatedBy: 1, ExpiresAt: time.Now()})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UpsertNote error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UpsertNote(ctx, &model.Note{MediaID: 1, UserID: 1, Content: "hi"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UpsertProgress error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UpsertProgress(ctx, &model.PlaybackProgress{UserID: 1, MediaID: 1, PositionSeconds: 10})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UpsertAccumulator error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UpsertAccumulator(ctx, &model.PlaybackAccumulator{SessionID: "s", MediaID: 1})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GrantPermission error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.GrantPermission(ctx, &model.SetPermission{SetID: 1, UserID: 1, Role: model.RoleOwner})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ToggleFavorite error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ToggleFavorite(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListMedia(ctx, MediaFilter{})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListSets error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListSets(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListUsers error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListUsers(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListTags error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListTags(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListDeletedMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListDeletedMedia(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListProgressByUser error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListProgressByUser(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListSharesByMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListSharesByMedia(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListPermissionsBySet error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListPermissionsBySet(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListPermissionsByUser error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListPermissionsByUser(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListFavoritesByUser error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListFavoritesByUser(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "ListTagsByMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.ListTagsByMedia(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UpdateMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UpdateMedia(ctx, &model.Media{ID: 1, SetID: 1, RelPath: "a.mp4", FileName: "a.mp4", AbsPath: "/s/a.mp4", Type: model.MediaTypeVideo})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UpdateSet error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UpdateSet(ctx, &model.Set{ID: 1, Name: "s", RootPath: "/s"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "SoftDeleteMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.SoftDeleteMedia(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "RestoreMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.RestoreMedia(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "HardDeleteMedia error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.HardDeleteMedia(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "IncrementPlayCount error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.IncrementPlayCount(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteUser error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteUser(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteSet error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteSet(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteTag error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteTag(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteSession error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteSession(ctx, "abc")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteExpiredSessions error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteExpiredSessions(ctx, time.Now())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteShare error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteShare(ctx, "abc")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteExpiredShares error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteExpiredShares(ctx, time.Now())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "DeleteNote error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.DeleteNote(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "RevokePermission error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.RevokePermission(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "AssignTag error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.AssignTag(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "RemoveTag error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.RemoveTag(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "UseShare error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ err := s.UseShare(ctx, "abc")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetUserByID error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetUserByID(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetUserByUsername error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetUserByUsername(ctx, "u")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetSetByID error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetSetByID(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetMediaByID error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetMediaByID(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetTagByID error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetTagByID(ctx, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetTagByName error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetTagByName(ctx, "rock")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetPermission error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetPermission(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetNote error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetNote(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetProgress error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetProgress(ctx, 1, 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetAccumulator error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetAccumulator(ctx, "s", 1)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetSessionByID error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetSessionByID(ctx, "abc")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "GetShareByToken error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.GetShareByToken(ctx, "abc")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ {
+ name: "CountUsers error on closed db",
+ run: func(t *testing.T, ctx context.Context, s *SQLite) {
+ s.Close()
+ _, err := s.CountUsers(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := newTestStore(t)
+ tt.run(t, context.Background(), s)
+ })
+ }
+}
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 {