feat(profile): add round avatar with upload and cropping
- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs, avatar service (EXIF-stripping WebP 128/512), public avatar read route - web: avatar upload/crop on profile, round avatar in header/members/comments/author - android: avatar upload/crop, round avatar on profile and albums header - openspec: add-user-avatar proposal/design/specs/tasks
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user