fix(realtime): доставлять обложку альбома после готовности вариантов

Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
This commit is contained in:
iu
2026-10-16 12:47:50 +03:00
parent 742745e6aa
commit 44cb7c7d68
7 changed files with 129 additions and 11 deletions
+38 -5
View File
@@ -7,6 +7,7 @@ import (
"image/color"
"image/png"
"os"
"sync"
"testing"
"time"
@@ -52,6 +53,35 @@ func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.A
func (stubAlbums) NotifyAlbumChanged(context.Context, string) {}
func (stubAlbums) NotifyCoverReady(context.Context, string, string) {}
// recordingAlbums records cover-readiness announcements so worker tests can
// assert the worker reports them.
type recordingAlbums struct {
stubAlbums
mu sync.Mutex
noticed map[string]struct{}
}
func newRecordingAlbums() *recordingAlbums {
return &recordingAlbums{noticed: map[string]struct{}{}}
}
func (r *recordingAlbums) NotifyCoverReady(_ context.Context, albumID, photoID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.noticed[albumID+"|"+photoID] = struct{}{}
}
// coverReady reports whether the given album/photo pair was announced.
func (r *recordingAlbums) coverReady(albumID, photoID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.noticed[albumID+"|"+photoID]
return ok
}
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
t.Helper()
id := auth.NewID()
@@ -126,19 +156,22 @@ func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
}
// newTestServiceFS builds a service backed by a temporary local file-system
// storage backend so the full flow can run without external infrastructure.
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
// newRecordingServiceFS builds a service backed by a temporary local file-system
// storage backend (so the full flow runs without external infrastructure) with an
// album port that records cover-readiness announcements, for the worker's
// publication assertions.
func newRecordingServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend, *recordingAlbums) {
t.Helper()
signer, err := storage.NewSigner("test-secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("storage init: %v", err)
t.Fatalf("signer init: %v", err)
}
fs, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("storage init: %v", err)
}
return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs
rec := newRecordingAlbums()
return NewService(NewStore(pool), rec, fs, sse.NewHub(1)), fs, rec
}
// cleanupPhoto removes a photo, its objects (rows and blobs) and bindings.
+3
View File
@@ -29,6 +29,9 @@ type AlbumAccess interface {
// NotifyAlbumChanged announces a change to the album's photo composition on
// the recipients' personal streams (album cards and counts update live).
NotifyAlbumChanged(ctx context.Context, albumID string)
// NotifyCoverReady announces the album when the photo that is its cover gains
// variant URLs: only then does the album object carry a cover link.
NotifyCoverReady(ctx context.Context, albumID, photoID string)
}
type Service struct {
+3
View File
@@ -454,6 +454,9 @@ func (w *Worker) notify(ctx context.Context, photoID string) {
// Variant readiness updates an existing photo; it is not a new photo in the
// album, so clients update it in place instead of inserting at the top.
w.svc.hub.Publish(p.AlbumID, "photo.updated", p)
// The album object only carries a cover link once its variants exist, so the
// cover's readiness is what actually refreshes album cards.
w.svc.albums.NotifyCoverReady(ctx, p.AlbumID, p.ID)
}
func (w *Worker) fail(ctx context.Context, photoID, msg string) {
+10 -4
View File
@@ -15,16 +15,16 @@ import (
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
pool := testPool(t)
svc, cl := newTestService(t, pool)
runWorkerFlow(t, pool, svc, cl)
runWorkerFlow(t, pool, svc, cl, nil)
}
func TestWorkerGeneratesVariantsWithFS(t *testing.T) {
pool := testPool(t)
svc, cl := newTestServiceFS(t, pool)
runWorkerFlow(t, pool, svc, cl)
svc, cl, albums := newRecordingServiceFS(t, pool)
runWorkerFlow(t, pool, svc, cl, albums)
}
func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend) {
func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend, albums *recordingAlbums) {
t.Helper()
ctx := context.Background()
user := createUser(t, ctx, pool)
@@ -57,6 +57,12 @@ func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Ba
t.Fatalf("no variant-ready event published")
}
// Variant readiness must also announce the album to personal streams, because
// the album object only gains a cover link once the variants exist.
if albums != nil && !albums.coverReady(album, p.ID) {
t.Fatalf("cover readiness was not announced for the uploaded photo")
}
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get refreshed: %v", err)