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:
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"Objects", testObjects},
|
||||
{"UserAvatar", testUserAvatar},
|
||||
{"VariantJobKinds", testVariantJobKinds},
|
||||
{"Settings", testSettings},
|
||||
}
|
||||
@@ -648,6 +649,79 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
|
||||
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, obj); err != nil {
|
||||
t.Fatalf("create avatar object: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotReady) {
|
||||
t.Fatalf("bind pending avatar err = %v, want ErrObjectNotReady", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||
t.Fatalf("activate avatar object: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); err != nil {
|
||||
t.Fatalf("bind avatar: %v", err)
|
||||
}
|
||||
|
||||
got, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar)
|
||||
if err != nil || got.ID != obj.ID || got.StorageKey != obj.StorageKey {
|
||||
t.Fatalf("avatar object: %v %+v", err, got)
|
||||
}
|
||||
// Roles are independent: no small variant was bound.
|
||||
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatarSmall); !errors.Is(err, photos.ErrObjectNotFound) {
|
||||
t.Fatalf("unbound small role err = %v, want ErrObjectNotFound", err)
|
||||
}
|
||||
|
||||
// A bound avatar blob survives garbage collection.
|
||||
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("claim garbage: %v", err)
|
||||
}
|
||||
if containsObject(claimed, obj.ID) {
|
||||
t.Fatalf("avatar object collected while bound: %+v", claimed)
|
||||
}
|
||||
|
||||
// Replacing the binding (a new generation) makes the old blob collectible.
|
||||
replacement := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, replacement); err != nil {
|
||||
t.Fatalf("create replacement: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, replacement.ID); err != nil {
|
||||
t.Fatalf("activate replacement: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindUserAvatar(ctx, owner.ID, replacement.ID, photos.RoleAvatar); err != nil {
|
||||
t.Fatalf("rebind avatar: %v", err)
|
||||
}
|
||||
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
|
||||
if !containsObject(claimed, obj.ID) {
|
||||
t.Fatalf("superseded avatar object not collected: %+v", claimed)
|
||||
}
|
||||
|
||||
// Clearing removes every binding so the current blob is collected too.
|
||||
if err := r.Photos.ClearUserAvatar(ctx, owner.ID); err != nil {
|
||||
t.Fatalf("clear avatar: %v", err)
|
||||
}
|
||||
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) {
|
||||
t.Fatalf("avatar still bound after clear: %v", err)
|
||||
}
|
||||
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
|
||||
if !containsObject(claimed, replacement.ID) {
|
||||
t.Fatalf("cleared avatar object not collected: %+v", claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func containsObject(list []photos.Object, id string) bool {
|
||||
for _, o := range list {
|
||||
if o.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestSQLiteContract(t *testing.T) {
|
||||
Settings: settings.NewSQLiteStore(database),
|
||||
Reset: func(ctx context.Context) error {
|
||||
tables := []string{
|
||||
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
|
||||
"variant_jobs", "photo_objects", "user_avatar_objects", "objects", "photo_tags", "tags",
|
||||
"refresh_tokens", "share_links", "notifications", "likes", "comments",
|
||||
"invites", "album_members", "photos", "albums", "users", "app_settings",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user