Files
ds-galo/backend/internal/albums/service_test.go
iu 44cb7c7d68 fix(realtime): доставлять обложку альбома после готовности вариантов
Карточка альбома не получала ссылку на обложку: она появляется в объекте
альбома только после генерации thumb/medium, а событие об этом шаге в
персональные потоки не публиковалось — счётчик обновлялся, обложка нет.

- albums.Service.NotifyCoverReady публикует album.updated получателям, когда
  варианты готовы у фото, являющегося текущей обложкой (иначе no-op, чтобы
  массовая загрузка не рассылала событие на каждый файл)
- photos: порт AlbumAccess расширен, worker вызывает публикацию после
  photo.updated
- project.md §11/§12: триггер добавлен в контракт персонального потока
2026-10-16 12:47:50 +03:00

402 lines
12 KiB
Go

package albums_test
import (
"context"
"database/sql"
"encoding/json"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"
"time"
assets "galo"
"galo/internal/albums"
"galo/internal/auth"
"galo/internal/db"
"galo/internal/models"
"galo/internal/sse"
"galo/internal/store"
)
type stack struct {
db *sql.DB
repos store.Repos
hub *sse.Hub
svc *albums.Service
}
// newStack opens a migrated SQLite database and wires the albums service over
// the real repositories. Storage is nil: attachCover tolerates it and these
// tests never inspect cover URLs.
func newStack(t *testing.T) *stack {
t.Helper()
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
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)
hub := sse.NewHub(16)
return &stack{db: database, repos: repos, hub: hub, svc: albums.NewService(repos.Albums, repos.Members, hub, nil)}
}
func seedUser(t *testing.T, st *stack, name string) string {
t.Helper()
id := auth.NewID()
if _, err := st.db.Exec(
`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', $3)`,
id, id[:8]+"@test.local", name); err != nil {
t.Fatalf("seed user: %v", err)
}
return id
}
func nextEvent(t *testing.T, sub *sse.Subscription) sse.Event {
t.Helper()
select {
case ev, ok := <-sub.Events():
if !ok {
t.Fatal("subscription closed before an event arrived")
}
return ev
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for an event")
}
return sse.Event{}
}
func assertNoEvent(t *testing.T, sub *sse.Subscription) {
t.Helper()
select {
case ev, ok := <-sub.Events():
if !ok {
return
}
t.Fatalf("unexpected event %q", ev.Type)
case <-time.After(50 * time.Millisecond):
}
}
// jsonFields returns the sorted top-level JSON keys of v, used to assert that
// an event payload exposes the same fields as a REST album element.
func jsonFields(t *testing.T, v any) []string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func assertSameFields(t *testing.T, want, got any) {
t.Helper()
w := strings.Join(jsonFields(t, want), ",")
g := strings.Join(jsonFields(t, got), ",")
if w != g {
t.Fatalf("payload fields differ:\n want %s\n got %s", w, g)
}
}
// Create must publish album.created to the owner, and the payload must be the
// album re-read with its counters (Create itself returns the album without
// them), matching the shape of a GET /api/albums element.
func TestCreatePublishesAlbumCreatedToOwner(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
sub := st.hub.SubscribeUser(owner)
defer sub.Close()
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Trip", Description: "d"})
if err != nil {
t.Fatalf("create: %v", err)
}
ev := nextEvent(t, sub)
if ev.Type != albums.EventAlbumCreated {
t.Fatalf("event type = %q, want %q", ev.Type, albums.EventAlbumCreated)
}
got, ok := ev.Data.(*models.Album)
if !ok {
t.Fatalf("payload type = %T, want *models.Album", ev.Data)
}
if got.ID != album.ID || got.Title != "Trip" || got.OwnerID != owner {
t.Fatalf("payload album = %+v", got)
}
if got.PhotosCount != 0 || got.MembersCount != 0 {
t.Fatalf("counters = photos:%d members:%d, want 0/0", got.PhotosCount, got.MembersCount)
}
list, err := st.svc.List(ctx, owner)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
assertSameFields(t, &list[0], got)
}
// A metadata change must reach the owner, every member and the per-album
// channel, with a payload identical in shape to GET /api/albums/:id.
func TestUpdatePublishesAlbumUpdatedToAudience(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
member := seedUser(t, st, "Member")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Old"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
t.Fatalf("add member: %v", err)
}
ownerSub := st.hub.SubscribeUser(owner)
defer ownerSub.Close()
memberSub := st.hub.SubscribeUser(member)
defer memberSub.Close()
albumSub := st.hub.SubscribeAlbum(album.ID)
defer albumSub.Close()
title := "New"
if _, err := st.svc.Update(ctx, album.ID, owner, albums.UpdateInput{Title: &title}); err != nil {
t.Fatalf("update: %v", err)
}
ownerEv := nextEvent(t, ownerSub)
memberEv := nextEvent(t, memberSub)
albumEv := nextEvent(t, albumSub)
for name, ev := range map[string]sse.Event{"owner": ownerEv, "member": memberEv, "album": albumEv} {
if ev.Type != albums.EventAlbumUpdated {
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
}
}
got, ok := ownerEv.Data.(*models.Album)
if !ok {
t.Fatalf("owner payload type = %T", ownerEv.Data)
}
if got.Title != "New" || got.MembersCount != 1 {
t.Fatalf("owner payload = %+v", got)
}
fetched, _, err := st.svc.Get(ctx, album.ID, owner)
if err != nil {
t.Fatalf("get: %v", err)
}
assertSameFields(t, fetched, got)
memberAlbum, ok := memberEv.Data.(*models.Album)
if !ok || memberAlbum.ID != album.ID || memberAlbum.Title != "New" {
t.Fatalf("member payload = %#v", memberEv.Data)
}
}
// Deleting an album must notify the whole audience computed before the delete,
// and the cascade must leave no members behind.
func TestDeletePublishesAlbumDeletedToAudience(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
member := seedUser(t, st, "Member")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Doomed"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
t.Fatalf("add member: %v", err)
}
ownerSub := st.hub.SubscribeUser(owner)
defer ownerSub.Close()
memberSub := st.hub.SubscribeUser(member)
defer memberSub.Close()
if err := st.svc.Delete(ctx, album.ID, owner); err != nil {
t.Fatalf("delete: %v", err)
}
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
ev := nextEvent(t, sub)
if ev.Type != albums.EventAlbumDeleted {
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumDeleted)
}
payload, ok := ev.Data.(map[string]string)
if !ok || payload["id"] != album.ID {
t.Fatalf("%s payload = %#v", name, ev.Data)
}
}
list, err := st.repos.Members.List(ctx, album.ID)
if err != nil || len(list) != 0 {
t.Fatalf("members after delete: %v len=%d, want 0", err, len(list))
}
}
// Personal fan-out with no subscriber is safe and never reaches the per-album
// channel, and an empty audience is a no-op.
func TestPersonalPublishDoesNotReachAlbumChannel(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Quiet"})
if err != nil {
t.Fatalf("create: %v", err)
}
albumSub := st.hub.SubscribeAlbum(album.ID)
defer albumSub.Close()
// Nobody subscribes to the owner's personal stream.
st.svc.PublishAlbumTo(ctx, album.ID, albums.EventAlbumCreated, []string{owner})
assertNoEvent(t, albumSub)
// An empty audience must not panic or read the album.
st.svc.PublishAlbumTo(ctx, album.ID, albums.EventAlbumDeleted, nil)
assertNoEvent(t, albumSub)
}
// NotifyAlbumChanged reaches the recipients' personal streams and never the
// per-album channel.
func TestNotifyAlbumChangedReachesRecipientsOnly(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
member := seedUser(t, st, "Member")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Notify"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
t.Fatalf("add member: %v", err)
}
ownerSub := st.hub.SubscribeUser(owner)
defer ownerSub.Close()
memberSub := st.hub.SubscribeUser(member)
defer memberSub.Close()
albumSub := st.hub.SubscribeAlbum(album.ID)
defer albumSub.Close()
st.svc.NotifyAlbumChanged(ctx, album.ID)
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
ev := nextEvent(t, sub)
if ev.Type != albums.EventAlbumUpdated {
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
}
if a, ok := ev.Data.(*models.Album); !ok || a.MembersCount != 1 {
t.Fatalf("%s payload = %#v", name, ev.Data)
}
}
assertNoEvent(t, albumSub)
}
// Cover readiness republishes the album only when the photo that became ready is
// the current cover; any other photo leaves the album object unchanged.
func TestNotifyCoverReadyPublishesOnlyForTheCover(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
member := seedUser(t, st, "Member")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Cover"})
if err != nil {
t.Fatalf("create: %v", err)
}
if err := st.repos.Members.Add(ctx, album.ID, member, "member", &owner); err != nil {
t.Fatalf("add member: %v", err)
}
const coverID = "photo-cover"
if _, err := st.db.Exec(
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, $2, $3)`,
coverID, album.ID, owner,
); err != nil {
t.Fatalf("seed cover photo: %v", err)
}
if _, err := st.db.Exec(
`UPDATE albums SET cover_photo_id = $1 WHERE id = $2`, coverID, album.ID,
); err != nil {
t.Fatalf("set cover: %v", err)
}
ownerSub := st.hub.SubscribeUser(owner)
defer ownerSub.Close()
memberSub := st.hub.SubscribeUser(member)
defer memberSub.Close()
// A photo that is not the cover changes nothing.
st.svc.NotifyCoverReady(ctx, album.ID, "photo-other")
assertNoEvent(t, ownerSub)
assertNoEvent(t, memberSub)
// The cover becoming ready reaches the owner and every member.
st.svc.NotifyCoverReady(ctx, album.ID, coverID)
for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "member": memberSub} {
ev := nextEvent(t, sub)
if ev.Type != albums.EventAlbumUpdated {
t.Fatalf("%s event = %q, want %q", name, ev.Type, albums.EventAlbumUpdated)
}
if a, ok := ev.Data.(*models.Album); !ok || a.ID != album.ID {
t.Fatalf("%s payload = %#v", name, ev.Data)
}
}
}
func TestPhotosCountExcludesTrash(t *testing.T) {
ctx := context.Background()
st := newStack(t)
owner := seedUser(t, st, "Owner")
album, err := st.svc.Create(ctx, owner, albums.CreateInput{Title: "Count"})
if err != nil {
t.Fatalf("create: %v", err)
}
insert := func(deleted bool) {
t.Helper()
var del any
if deleted {
del = time.Now().UTC().Format(time.RFC3339)
}
if _, err := st.db.Exec(
`INSERT INTO photos (id, album_id, uploader_id, deleted_at) VALUES ($1, $2, $3, $4)`,
auth.NewID(), album.ID, owner, del); err != nil {
t.Fatalf("insert photo: %v", err)
}
}
insert(false)
insert(false)
insert(true)
got, err := st.repos.Albums.GetByID(ctx, album.ID)
if err != nil {
t.Fatalf("get album: %v", err)
}
if got.PhotosCount != 2 {
t.Fatalf("photos_count = %d, want 2 (trashed photo excluded)", got.PhotosCount)
}
}