package avatar import ( "bytes" "context" "errors" "io" "io/fs" "path/filepath" "testing" "time" assets "galo" "galo/internal/auth" "galo/internal/db" "galo/internal/httpx" "galo/internal/models" "galo/internal/photos" "galo/internal/storage" "galo/internal/store" ) type stack struct { svc *Service repos store.Repos blob storage.Backend userID string } func newStack(t *testing.T) *stack { t.Helper() migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") if err != nil { t.Fatalf("migrations fs: %v", err) } path := filepath.Join(t.TempDir(), "meta.db") if err := db.MigrateSQLite(path, migFS); err != nil { t.Fatalf("migrate: %v", err) } database, err := db.OpenSQLite(path) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { database.Close() }) repos := store.NewSQLite(database) signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute) if err != nil { t.Fatalf("signer: %v", err) } blob, err := storage.NewFS(t.TempDir(), signer) if err != nil { t.Fatalf("fs: %v", err) } u := &models.User{ID: auth.NewID(), Email: auth.NewID()[:10] + "@test.local", PasswordHash: "x", Name: "Tester"} if err := repos.Auth.CreateUser(context.Background(), u); err != nil { t.Fatalf("create user: %v", err) } return &stack{svc: NewService(repos.Photos, repos.Auth, blob), repos: repos, blob: blob, userID: u.ID} } func (s *stack) objectKeys(t *testing.T) int { t.Helper() keys, err := s.repos.Photos.AllStorageKeys(context.Background()) if err != nil { t.Fatalf("all storage keys: %v", err) } return len(keys) } func errCode(t *testing.T, err error) string { t.Helper() var appErr *httpx.AppError if !errors.As(err, &appErr) { t.Fatalf("expected AppError, got %v", err) } return appErr.Code } func TestSetAvatarBindsBothVariants(t *testing.T) { ctx := context.Background() st := newStack(t) before := st.objectKeys(t) updated, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 400, 400))) if err != nil { t.Fatalf("set avatar: %v", err) } if updated.AvatarURL == "" || !bytes.Contains([]byte(updated.AvatarURL), []byte(st.userID)) { t.Fatalf("avatar_url not set: %q", updated.AvatarURL) } if st.objectKeys(t) != before+2 { t.Fatalf("object count = %d, want %d", st.objectKeys(t), before+2) } large, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) if err != nil || large.Width != SizeLarge || large.Height != SizeLarge { t.Fatalf("large variant: %v %+v", err, large) } small, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall) if err != nil || small.Width != SizeSmall || small.Height != SizeSmall { t.Fatalf("small variant: %v %+v", err, small) } data, err := st.svc.AvatarImage(ctx, st.userID, false) if err != nil || len(data) == 0 { t.Fatalf("serve small: %v len=%d", err, len(data)) } } func TestSetAvatarReplacementReclaimsOldObjects(t *testing.T) { ctx := context.Background() st := newStack(t) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 300, 300))); err != nil { t.Fatalf("first avatar: %v", err) } oldLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) oldSmall, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 200, 200))); err != nil { t.Fatalf("replace avatar: %v", err) } claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10) if err != nil { t.Fatalf("claim garbage: %v", err) } got := map[string]bool{} for _, o := range claimed { got[o.ID] = true } if !got[oldLarge.ID] || !got[oldSmall.ID] { t.Fatalf("superseded objects not collected: %+v", got) } // The current avatar keeps its objects. newLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) if got[newLarge.ID] { t.Fatalf("current avatar object was collected") } } func TestRemoveAvatarClearsAndReclaims(t *testing.T) { ctx := context.Background() st := newStack(t) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err != nil { t.Fatalf("set avatar: %v", err) } large, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar) updated, err := st.svc.RemoveAvatar(ctx, st.userID) if err != nil { t.Fatalf("remove avatar: %v", err) } if updated.AvatarURL != "" { t.Fatalf("avatar_url not cleared: %q", updated.AvatarURL) } if _, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) { t.Fatalf("avatar still bound: %v", err) } claimed, _ := st.repos.Photos.ClaimGarbage(ctx, 10) found := false for _, o := range claimed { if o.ID == large.ID { found = true } } if !found { t.Fatalf("removed avatar object not collected: %+v", claimed) } } func TestSetAvatarRejectsNonImage(t *testing.T) { ctx := context.Background() st := newStack(t) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader([]byte("not an image"))); errCode(t, err) != "invalid_image" { t.Fatalf("code = %v, want invalid_image", err) } if st.objectKeys(t) != 0 { t.Fatalf("objects created for an invalid image") } } func TestSetAvatarRejectsOversize(t *testing.T) { ctx := context.Background() st := newStack(t) st.svc.SetMaxUploadBytes(1024) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(make([]byte, 2048))); errCode(t, err) != "file_too_large" { t.Fatalf("code = %v, want file_too_large", err) } if st.objectKeys(t) != 0 { t.Fatalf("objects created for an oversize upload") } } // failingStorage delegates to a real backend but fails every Put, to exercise // the rollback that must not leave orphaned object rows. type failingStorage struct { storage.Backend } func (failingStorage) Put(context.Context, string, io.Reader, int64, string) error { return errors.New("put failed") } func TestSetAvatarStorageFailureLeavesNoObjects(t *testing.T) { ctx := context.Background() st := newStack(t) st.svc = NewService(st.repos.Photos, st.repos.Auth, failingStorage{st.blob}) if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err == nil { t.Fatalf("expected storage failure") } if st.objectKeys(t) != 0 { t.Fatalf("orphaned object rows after storage failure") } }