Files
ds-galo/backend/internal/app/avatar_flow_test.go
iu 04617d1cdb 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
2026-10-12 16:16:47 +03:00

278 lines
9.1 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
assets "galo"
"galo/internal/auth"
"galo/internal/config"
"galo/internal/db"
"galo/internal/models"
"galo/internal/photos"
"galo/internal/sse"
"galo/internal/storage"
"galo/internal/store"
)
// newAvatarServer builds a full router over SQLite and FS storage, plus a valid
// access token for a freshly created user. It needs no external database.
func newAvatarServer(t *testing.T) (*gin.Engine, string, string, store.Repos) {
t.Helper()
gin.SetMode(gin.TestMode)
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)
}
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, repos, func(context.Context) error { return nil }, tm, blob, sse.NewHub(1))
uid := auth.NewID()
u := &models.User{ID: uid, Email: uid[:8] + "@test.local", PasswordHash: "x", Name: "Tester"}
if err := repos.Auth.CreateUser(context.Background(), u); err != nil {
t.Fatalf("create user: %v", err)
}
access, err := tm.GenerateAccess(uid, u.Email)
if err != nil {
t.Fatalf("access token: %v", err)
}
return srv.Router(), access, uid, repos
}
func multipartFile(t *testing.T, field, filename string, data []byte) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("write form file: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
return &buf, w.FormDataContentType()
}
func doAuthed(r *gin.Engine, method, target string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
req = httptest.NewRequest(method, target, body)
req.Header.Set("Content-Type", contentType)
} else {
req = httptest.NewRequest(method, target, nil)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
func avatarURLOf(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
User models.SelfUser `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
}
return body.User.AvatarURL
}
func TestAvatarWritesRequireAuth(t *testing.T) {
r, _, _, _ := newAvatarServer(t)
img := makeTestPNG(t)
body, ct := multipartFile(t, "file", "a.png", img)
if rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("upload without token = %d, want 401", rec.Code)
}
if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("delete without token = %d, want 401", rec.Code)
}
}
func TestAvatarUploadServeReplaceRemove(t *testing.T) {
r, token, _, _ := newAvatarServer(t)
img := makeTestPNG(t)
// Upload.
body, ct := multipartFile(t, "file", "a.png", img)
rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
if rec.Code != http.StatusOK {
t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String())
}
first := avatarURLOf(t, rec)
if first == "" || !strings.HasPrefix(first, "/api/avatars/") {
t.Fatalf("avatar_url = %q", first)
}
// The image loader fetches without an Authorization header: the read route
// is public and returns a WebP blob.
rec = doAuthed(r, http.MethodGet, first, nil, "", "")
if rec.Code != http.StatusOK {
t.Fatalf("serve = %d body=%s", rec.Code, rec.Body.String())
}
if !bytes.HasPrefix(rec.Body.Bytes(), []byte("RIFF")) || !bytes.Contains(rec.Body.Bytes()[:16], []byte("WEBP")) {
t.Fatalf("served body is not WebP")
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Fatalf("cache-control = %q", cc)
}
// A non-image upload is rejected and does not change the avatar.
body, ct = multipartFile(t, "file", "a.txt", []byte("not an image"))
rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
if rec.Code != http.StatusBadRequest {
t.Fatalf("bad image = %d, want 400", rec.Code)
}
// Replace changes the version segment so caches invalidate.
body, ct = multipartFile(t, "file", "b.png", img)
rec = doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
if rec.Code != http.StatusOK {
t.Fatalf("replace = %d body=%s", rec.Code, rec.Body.String())
}
second := avatarURLOf(t, rec)
if second == "" || second == first {
t.Fatalf("avatar_url did not change on replace: %q", second)
}
// Remove clears the avatar and the served URL 404s.
if rec := doAuthed(r, http.MethodDelete, "/api/auth/me/avatar", nil, "", token); rec.Code != http.StatusOK {
t.Fatalf("remove = %d body=%s", rec.Code, rec.Body.String())
} else if url := avatarURLOf(t, rec); url != "" {
t.Fatalf("avatar_url not cleared: %q", url)
}
if rec := doAuthed(r, http.MethodGet, second, nil, "", token); rec.Code != http.StatusNotFound {
t.Fatalf("serve after remove = %d, want 404", rec.Code)
}
}
func TestServeAvatarMissingUser(t *testing.T) {
r, token, _, _ := newAvatarServer(t)
rec := doAuthed(r, http.MethodGet, "/api/avatars/does-not-exist/x", nil, "", token)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestAvatarAppearsInUserViews(t *testing.T) {
ctx := context.Background()
r, token, uid, repos := newAvatarServer(t)
body, ct := multipartFile(t, "file", "a.png", makeTestPNG(t))
rec := doAuthed(r, http.MethodPost, "/api/auth/me/avatar", body, ct, token)
if rec.Code != http.StatusOK {
t.Fatalf("upload = %d body=%s", rec.Code, rec.Body.String())
}
url := avatarURLOf(t, rec)
// The self profile carries the avatar.
rec = doAuthed(r, http.MethodGet, "/api/auth/me", nil, "", token)
if me := avatarURLOf(t, rec); me != url {
t.Fatalf("auth/me avatar_url = %q, want %q", me, url)
}
// Public representations carry it too: a member with an avatar exposes the
// URL, a member without one omits it.
album := &models.Album{ID: auth.NewID(), OwnerID: uid, Title: "A"}
if err := repos.Albums.Create(ctx, album); err != nil {
t.Fatalf("create album: %v", err)
}
if err := repos.Members.Add(ctx, album.ID, uid, "editor", nil); err != nil {
t.Fatalf("add owner member: %v", err)
}
other := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Other"}
if err := repos.Auth.CreateUser(ctx, other); err != nil {
t.Fatalf("create other user: %v", err)
}
if err := repos.Members.Add(ctx, album.ID, other.ID, "viewer", nil); err != nil {
t.Fatalf("add other member: %v", err)
}
rec = doAuthed(r, http.MethodGet, "/api/albums/"+album.ID+"/members", nil, "", token)
if rec.Code != http.StatusOK {
t.Fatalf("members = %d body=%s", rec.Code, rec.Body.String())
}
var mb struct {
Members []models.AlbumMember `json:"members"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &mb); err != nil {
t.Fatalf("decode members: %v", err)
}
seen := map[string]string{}
for _, m := range mb.Members {
seen[m.User.ID] = m.User.AvatarURL
}
if seen[uid] != url {
t.Fatalf("member avatar_url = %q, want %q", seen[uid], url)
}
if seen[other.ID] != "" {
t.Fatalf("member without avatar has avatar_url = %q", seen[other.ID])
}
// Comments carry the commenter's avatar too.
photo := &models.Photo{ID: auth.NewID(), AlbumID: album.ID, UploaderID: uid, OriginalFilename: "p.jpg"}
obj := &photos.Object{ID: auth.NewID(), StorageKey: "objects/" + auth.NewID(), State: photos.ObjectPending}
if err := repos.Photos.CreateWithOriginal(ctx, photo, obj); err != nil {
t.Fatalf("create photo: %v", err)
}
if err := repos.Photos.ActivateObject(ctx, obj.ID); err != nil {
t.Fatalf("activate photo object: %v", err)
}
rec = doAuthed(r, http.MethodPost, "/api/photos/"+photo.ID+"/comments", bytes.NewBufferString(`{"text":"hi"}`), "application/json", token)
if rec.Code != http.StatusCreated {
t.Fatalf("comment = %d body=%s", rec.Code, rec.Body.String())
}
rec = doAuthed(r, http.MethodGet, "/api/photos/"+photo.ID+"/comments", nil, "", token)
if rec.Code != http.StatusOK {
t.Fatalf("list comments = %d body=%s", rec.Code, rec.Body.String())
}
var cb struct {
Comments []models.Comment `json:"comments"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &cb); err != nil {
t.Fatalf("decode comments: %v", err)
}
if len(cb.Comments) != 1 || cb.Comments[0].User.AvatarURL != url {
t.Fatalf("comment avatar_url = %+v, want %q", cb.Comments, url)
}
}