Files
iu e82f45647c feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото
Публиковать album.created/updated/deleted и изменения состава фото в личный поток
SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые
страницы альбома обновлялись без перезагрузки.

- members.Repository получил Audience (владелец + участники) для дешёвого веера
- albums.Service рассылает события в личные потоки получателей, перечитывая альбом
  через GetByID и attachCover, чтобы payload совпадал с GET /api/albums
- members.Service публикует при принятии инвайта, смене роли и исключении
- photos.Service публикует photo.created при загрузке и album.updated при
  добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении
- photos_count теперь считает фото вне корзины
- web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и
  применяет события к стору альбомов; страница альбома уходит при потере доступа
2026-10-15 23:25:07 +03:00

109 lines
2.5 KiB
Go

package members_test
import (
"context"
"database/sql"
"io/fs"
"path/filepath"
"sort"
"testing"
"time"
assets "galo"
"galo/internal/albums"
"galo/internal/auth"
"galo/internal/db"
"galo/internal/members"
"galo/internal/sse"
"galo/internal/store"
)
type stack struct {
db *sql.DB
repos store.Repos
hub *sse.Hub
albums *albums.Service
svc *members.Service
}
// newStack opens a migrated SQLite database and wires the albums and members
// services over the real repositories, mirroring the production wiring.
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)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, nil)
membersSvc := members.NewService(repos.Members, albumsSvc, hub, nil)
return &stack{db: database, repos: repos, hub: hub, albums: albumsSvc, svc: membersSvc}
}
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):
}
}
func assertAudience(t *testing.T, st *stack, albumID string, want ...string) {
t.Helper()
got, err := st.repos.Members.Audience(context.Background(), albumID)
if err != nil {
t.Fatalf("audience: %v", err)
}
sort.Strings(got)
sorted := append([]string(nil), want...)
sort.Strings(sorted)
if len(got) != len(sorted) {
t.Fatalf("audience = %v, want %v", got, sorted)
}
for i := range got {
if got[i] != sorted[i] {
t.Fatalf("audience = %v, want %v", got, sorted)
}
}
}