fix(photos): убрать мигание предпросмотра и задержку сброса правок
- фото и photo.updated несут edit_pending: рендер сохранённых правок ещё идёт - клиент держит предпросмотр, пока серверная версия не загружена и не декодирована, поэтому оригинал не мелькает при подмене - сброс возвратом контролов в значения по умолчанию виден сразу, не через 10 с - Viewer берёт URL из актуального объекта (SSE), fresh остаётся фолбэком - попутно исправлен порядок чтения tags и album_title в ListTrash
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// TestEditPendingLifecycle covers the render-state flag clients use to decide
|
||||
// whether the edit_* URLs can be shown: saving edits reports a pending render,
|
||||
// the worker clears it and publishes the fresh URLs, a second edit keeps the
|
||||
// previous render (still pending), and a reset clears both the flag and the
|
||||
// edited version.
|
||||
func TestEditPendingLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "editpending@test.local", "", "password123", "E")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||
AlbumID: album.ID, UserID: user.ID, Filename: "p.png", Data: makePNG(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
|
||||
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
|
||||
w.ProcessOnce(ctx)
|
||||
|
||||
first, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 0.5},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit: %v", err)
|
||||
}
|
||||
if !first.EditPending {
|
||||
t.Fatalf("edit response does not report a pending render: %+v", first)
|
||||
}
|
||||
if first.EditPreviewURL != "" {
|
||||
t.Fatalf("first render already exposes edit_preview_url: %q", first.EditPreviewURL)
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
rendered, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get rendered photo: %v", err)
|
||||
}
|
||||
if rendered.EditPending {
|
||||
t.Fatalf("rendered photo still reports edit_pending: %+v", rendered)
|
||||
}
|
||||
if rendered.EditPreviewURL == "" {
|
||||
t.Fatalf("rendered photo has no edit_preview_url: %+v", rendered)
|
||||
}
|
||||
firstPreview := rendered.EditPreviewURL
|
||||
|
||||
// A second edit is queued while the previous render is still bound: the
|
||||
// photo stays pending and the old URLs must not be treated as current.
|
||||
second, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{
|
||||
Light: photos.LightParams{Exposure: 1.25},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second edit: %v", err)
|
||||
}
|
||||
if !second.EditPending {
|
||||
t.Fatalf("second edit does not report a pending render: %+v", second)
|
||||
}
|
||||
if second.EditPreviewURL != firstPreview {
|
||||
t.Fatalf("second edit should keep the previous render while pending: %q want %q",
|
||||
second.EditPreviewURL, firstPreview)
|
||||
}
|
||||
|
||||
w.ProcessOnce(ctx)
|
||||
after, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after second render: %v", err)
|
||||
}
|
||||
if after.EditPending {
|
||||
t.Fatalf("second render still reports edit_pending: %+v", after)
|
||||
}
|
||||
if after.EditPreviewURL == "" || after.EditPreviewURL == firstPreview {
|
||||
t.Fatalf("second render preview url = %q, want a fresh one (old %q)",
|
||||
after.EditPreviewURL, firstPreview)
|
||||
}
|
||||
|
||||
reset, err := st.photos.ResetEdit(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
if reset.EditPending {
|
||||
t.Fatalf("reset photo reports edit_pending: %+v", reset)
|
||||
}
|
||||
if reset.EditPreviewURL != "" || reset.EditURL != "" {
|
||||
t.Fatalf("reset photo still exposes edit urls: %+v", reset)
|
||||
}
|
||||
if reset.EditParams != nil && len(reset.EditParams) > 0 {
|
||||
t.Fatalf("reset photo still exposes edit params: %+v", reset.EditParams)
|
||||
}
|
||||
}
|
||||
@@ -108,24 +108,27 @@ type Photo struct {
|
||||
Caption string `json:"caption"`
|
||||
EXIF map[string]any `json:"exif,omitempty"`
|
||||
EditParams map[string]any `json:"edit_params,omitempty"`
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
MediumURL string `json:"medium_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
PlaybackURL string `json:"playback_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
EditURL string `json:"edit_url,omitempty"`
|
||||
EditThumbURL string `json:"edit_thumb_url,omitempty"`
|
||||
EditMediumURL string `json:"edit_medium_url,omitempty"`
|
||||
EditPreviewURL string `json:"edit_preview_url,omitempty"`
|
||||
// EditPending is true while the server still renders the saved edit
|
||||
// parameters, so the edit_* URLs do not describe the current edit yet.
|
||||
EditPending bool `json:"edit_pending"`
|
||||
Tags []Tag `json:"tags,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
AlbumTitle string `json:"album_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Uploader PublicUser `json:"uploader"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
LikedByMe bool `json:"liked_by_me"`
|
||||
ThumbURL string `json:"thumb_url,omitempty"`
|
||||
MediumURL string `json:"medium_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
PlaybackURL string `json:"playback_url,omitempty"`
|
||||
OriginalURL string `json:"original_url,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
EditURL string `json:"edit_url,omitempty"`
|
||||
EditThumbURL string `json:"edit_thumb_url,omitempty"`
|
||||
EditMediumURL string `json:"edit_medium_url,omitempty"`
|
||||
EditPreviewURL string `json:"edit_preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
|
||||
@@ -41,7 +41,8 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
|
||||
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2
|
||||
ORDER BY lower(t.name)) t), '[]')`
|
||||
ORDER BY lower(t.name)) t), '[]'),
|
||||
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
|
||||
|
||||
const sqlitePhotoJoins = `
|
||||
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
||||
@@ -76,7 +77,7 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
@@ -311,7 +312,7 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
|
||||
&p.Kind, &p.DurationMS,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &albumTitle, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &editText, &tagsText, &p.EditPending, &albumTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
|
||||
@@ -47,7 +47,8 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(p.edit_params, '{}'::jsonb)::text,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(t.name))
|
||||
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text,
|
||||
EXISTS(SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)`
|
||||
|
||||
const photoJoins = `
|
||||
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
||||
@@ -79,7 +80,7 @@ func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText)
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -228,7 +229,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
@@ -348,7 +349,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
&p.Kind, &p.DurationMS,
|
||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &p.AlbumTitle, &tagsText); err != nil {
|
||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &editText, &tagsText, &p.EditPending, &p.AlbumTitle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setExif(&p, exifText)
|
||||
|
||||
@@ -48,6 +48,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"ShareLinks", testShareLinks},
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"EditPending", testEditPending},
|
||||
{"Objects", testObjects},
|
||||
{"Clone", testClone},
|
||||
{"UserAvatar", testUserAvatar},
|
||||
@@ -490,6 +491,11 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("cover not cleared: %+v", al.CoverPhotoID)
|
||||
}
|
||||
|
||||
// Trash rows are read with the same column list as the album list, so the
|
||||
// tags JSON and the album title must land in their own fields.
|
||||
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
|
||||
t.Fatalf("re-attach tag for trash: %v", err)
|
||||
}
|
||||
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
@@ -500,6 +506,12 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
if err != nil || len(trash) != 1 {
|
||||
t.Fatalf("trash: %v len=%d", err, len(trash))
|
||||
}
|
||||
if trash[0].AlbumTitle != a.Title {
|
||||
t.Fatalf("trash album title = %q, want %q", trash[0].AlbumTitle, a.Title)
|
||||
}
|
||||
if len(trash[0].Tags) != 1 || trash[0].Tags[0].Name != "Vacation" {
|
||||
t.Fatalf("trash tags = %+v", trash[0].Tags)
|
||||
}
|
||||
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
@@ -563,6 +575,56 @@ func testPhotos(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
// testEditPending verifies that reads expose whether an edit render is still
|
||||
// queued: clients rely on it to tell stale edit URLs from the current ones.
|
||||
func testEditPending(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
|
||||
getPending := func() bool {
|
||||
t.Helper()
|
||||
got, err := r.Photos.GetByID(ctx, p.ID, owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get photo: %v", err)
|
||||
}
|
||||
return got.EditPending
|
||||
}
|
||||
listPending := func() bool {
|
||||
t.Helper()
|
||||
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Viewer: owner.ID, Limit: 10})
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
return list[0].EditPending
|
||||
}
|
||||
|
||||
if getPending() || listPending() {
|
||||
t.Fatalf("photo without an edit job reports a pending edit render")
|
||||
}
|
||||
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("enqueue edit job: %v", err)
|
||||
}
|
||||
if !getPending() || !listPending() {
|
||||
t.Fatalf("photo with a queued edit render does not report edit_pending")
|
||||
}
|
||||
if err := r.Photos.CompleteEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("complete edit job: %v", err)
|
||||
}
|
||||
if getPending() || listPending() {
|
||||
t.Fatalf("rendered photo still reports edit_pending")
|
||||
}
|
||||
if err := r.Photos.EnqueueEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("enqueue again: %v", err)
|
||||
}
|
||||
if err := r.Photos.DeleteEditJob(ctx, p.ID); err != nil {
|
||||
t.Fatalf("delete edit job: %v", err)
|
||||
}
|
||||
if getPending() {
|
||||
t.Fatalf("photo reports edit_pending after the job was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
|
||||
Reference in New Issue
Block a user