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) и применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user