From e82f45647cc2cf258351e613e9b787defc519520 Mon Sep 17 00:00:00 2001 From: Iu Date: Thu, 15 Oct 2026 23:24:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(realtime):=20=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D0=BC=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=86=D0=B8=D0=BA=D0=BB=D0=B0=20=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=B1=D0=BE=D0=BC=D0=B0=20=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=20=D1=84=D0=BE=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публиковать 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) и применяет события к стору альбомов; страница альбома уходит при потере доступа --- backend/internal/albums/service.go | 79 ++- backend/internal/albums/service_test.go | 351 ++++++++++++++ backend/internal/albums/sqlite_store.go | 2 +- backend/internal/albums/store.go | 4 +- backend/internal/app/album_live_test.go | 457 ++++++++++++++++++ backend/internal/app/app.go | 2 +- backend/internal/e2e/sqlite_test.go | 22 +- backend/internal/members/helpers_test.go | 108 +++++ backend/internal/members/repository.go | 4 + backend/internal/members/repository_test.go | 41 ++ backend/internal/members/service.go | 58 ++- backend/internal/members/service_test.go | 141 ++++++ backend/internal/members/sqlite_store.go | 25 + backend/internal/members/store.go | 25 + backend/internal/photos/helpers_test.go | 2 + backend/internal/photos/service.go | 20 + backend/internal/repo/contract/contract.go | 23 + backend/internal/sse/hub_test.go | 47 ++ frontend/package-lock.json | 366 +++++++++++++- frontend/package.json | 6 +- frontend/src/lib/album-live.test.ts | 144 ++++++ frontend/src/lib/album-live.ts | 54 +++ frontend/src/lib/albums-events.test.ts | 66 +++ frontend/src/lib/albums-events.ts | 39 ++ frontend/src/lib/sse.ts | 1 + frontend/src/lib/stores/album-live.ts | 14 + frontend/src/lib/stores/albums.svelte.ts | 10 +- frontend/src/routes/(app)/+layout.svelte | 12 + .../src/routes/(app)/albums/[id]/+page.svelte | 10 + frontend/vitest.config.ts | 17 + project.md | 35 +- 31 files changed, 2157 insertions(+), 28 deletions(-) create mode 100644 backend/internal/albums/service_test.go create mode 100644 backend/internal/app/album_live_test.go create mode 100644 backend/internal/members/helpers_test.go create mode 100644 backend/internal/members/repository_test.go create mode 100644 backend/internal/members/service_test.go create mode 100644 backend/internal/sse/hub_test.go create mode 100644 frontend/src/lib/album-live.test.ts create mode 100644 frontend/src/lib/album-live.ts create mode 100644 frontend/src/lib/albums-events.test.ts create mode 100644 frontend/src/lib/albums-events.ts create mode 100644 frontend/src/lib/stores/album-live.ts create mode 100644 frontend/vitest.config.ts diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index c52c2d5..19f9691 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -15,6 +15,9 @@ import ( type MemberLookup interface { Role(ctx context.Context, albumID, userID string) (string, error) + // Audience resolves the users the album is visible to (owner + members) for + // personal event fan-out. + Audience(ctx context.Context, albumID string) ([]string, error) } type Service struct { @@ -28,6 +31,69 @@ func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage st return &Service{store: store, members: members, hub: hub, storage: storage} } +// Event names published to personal streams for the album lifecycle. They are +// part of the client contract documented in project.md. +const ( + EventAlbumCreated = "album.created" + EventAlbumUpdated = "album.updated" + EventAlbumDeleted = "album.deleted" +) + +// OwnerID returns the owner of the album, delegating to the store so callers +// that only need the owner (members invites, role checks) can use this service +// as their album reader. +func (s *Service) OwnerID(ctx context.Context, albumID string) (string, error) { + return s.store.OwnerID(ctx, albumID) +} + +// PublishAlbumTo fans an album lifecycle event out to the personal streams of +// the given users. Events other than deletion carry the album re-read through +// Store.GetByID and enriched with attachCover, so the payload has the same +// shape as an element of GET /api/albums; deletion carries only {id} because +// the album is already gone when it is published. +func (s *Service) PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) { + if len(userIDs) == 0 || s.hub == nil { + return + } + var payload any + if eventType == EventAlbumDeleted { + payload = map[string]string{"id": albumID} + } else { + a, err := s.store.GetByID(ctx, albumID) + if err != nil { + return + } + s.attachCover(ctx, a) + payload = a + } + for _, uid := range userIDs { + s.hub.PublishUser(uid, eventType, payload) + } +} + +// NotifyAlbumChanged re-reads the album and publishes album.updated to the +// personal streams of its owner and members, without touching the per-album +// channel. Photo-composition changes use it: the open album page learns about +// them from photo.* events, and a per-album album.updated would force a REST +// reload for every photo. +func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) { + s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID)) +} + +// albumAudience resolves the users an album is visible to. Resolution failures +// are swallowed: a missed fan-out is recovered by the client's reconnect sync +// and must not fail the mutation that triggered it. +func (s *Service) albumAudience(ctx context.Context, albumID string) []string { + if s.members == nil { + return nil + } + ids, err := s.members.Audience(ctx, albumID) + if err != nil { + return nil + } + return ids +} + type CreateInput struct { Title string Description string @@ -47,6 +113,7 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m if err := s.store.Create(ctx, a); err != nil { return nil, err } + s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID)) return a, nil } @@ -152,7 +219,11 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI a = refreshed } s.attachCover(ctx, a) - s.hub.Publish(albumID, "album.updated", a) + s.hub.Publish(albumID, EventAlbumUpdated, a) + // The per-album event above keeps the mobile client and the open album page + // working; the personal fan-out below reaches everyone whose album list + // changes, whether or not they are looking at this album. + s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID)) return a, nil } @@ -164,12 +235,16 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error { if role != roles.Owner { return httpx.Forbidden("forbidden", "only the owner can delete the album") } + // The audience must be computed before deletion: album_members is removed by + // cascade with the album, so the recipient list is unavailable afterwards. + audience := s.albumAudience(ctx, a.ID) if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil { return err } if err := s.store.Delete(ctx, a.ID); err != nil { return err } - s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID}) + s.hub.Publish(albumID, EventAlbumDeleted, map[string]string{"id": a.ID}) + s.PublishAlbumTo(ctx, albumID, EventAlbumDeleted, audience) return nil } diff --git a/backend/internal/albums/service_test.go b/backend/internal/albums/service_test.go new file mode 100644 index 0000000..f3c67e1 --- /dev/null +++ b/backend/internal/albums/service_test.go @@ -0,0 +1,351 @@ +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) +} + +// photos_count counts only photos outside the trash. +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) + } +} diff --git a/backend/internal/albums/sqlite_store.go b/backend/internal/albums/sqlite_store.go index 9a98e92..6229bf0 100644 --- a/backend/internal/albums/sqlite_store.go +++ b/backend/internal/albums/sqlite_store.go @@ -47,7 +47,7 @@ func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) { const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), - (SELECT count(*) FROM photos WHERE album_id = a.id), + (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id)` const sqliteAlbumJoins = ` diff --git a/backend/internal/albums/store.go b/backend/internal/albums/store.go index 9dfefba..447f905 100644 --- a/backend/internal/albums/store.go +++ b/backend/internal/albums/store.go @@ -45,7 +45,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) { err := s.pool.QueryRow(ctx, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), - (SELECT count(*) FROM photos WHERE album_id = a.id), + (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb' @@ -67,7 +67,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, rows, err := s.pool.Query(ctx, `SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''), - (SELECT count(*) FROM photos WHERE album_id = a.id), + (SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL), (SELECT count(*) FROM album_members WHERE album_id = a.id) FROM albums a LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1 diff --git a/backend/internal/app/album_live_test.go b/backend/internal/app/album_live_test.go new file mode 100644 index 0000000..b2aef13 --- /dev/null +++ b/backend/internal/app/album_live_test.go @@ -0,0 +1,457 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + assets "galo" + "galo/internal/auth" + "galo/internal/config" + "galo/internal/db" + "galo/internal/sse" + "galo/internal/storage" + "galo/internal/store" +) + +// newAlbumLiveServer starts the real router over a migrated SQLite database and +// a filesystem blob store, so personal-stream behaviour is exercised through +// HTTP exactly as clients see it. +func newAlbumLiveServer(t *testing.T) *httptest.Server { + t.Helper() + gin.SetMode(gin.TestMode) + + 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() }) + + blob, err := storage.NewFS(t.TempDir(), mustSigner(t)) + if err != nil { + t.Fatalf("storage: %v", err) + } + tm := auth.NewTokenManager("album-live-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, store.NewSQLite(database), database.PingContext, tm, blob, sse.NewHub(128)) + + ts := httptest.NewServer(srv.Router()) + t.Cleanup(ts.Close) + return ts +} + +func doJSON(t *testing.T, method, url, token string, body any) (int, map[string]any) { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode body: %v", err) + } + } + req, err := http.NewRequest(method, url, &buf) + if err != nil { + t.Fatalf("request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + out := map[string]any{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &out) + } + return resp.StatusCode, out +} + +func registerUser(t *testing.T, base, email string) (token, id string) { + t.Helper() + status, body := doJSON(t, http.MethodPost, base+"/api/auth/register", "", map[string]any{ + "email": email, "password": "password123", "name": email, + }) + if status != http.StatusCreated { + t.Fatalf("register %s: status %d body %v", email, status, body) + } + return body["tokens"].(map[string]any)["access_token"].(string), + body["user"].(map[string]any)["id"].(string) +} + +func createAlbum(t *testing.T, base, token, title string) string { + t.Helper() + status, body := doJSON(t, http.MethodPost, base+"/api/albums", token, map[string]any{"title": title}) + if status != http.StatusCreated { + t.Fatalf("create album: status %d body %v", status, body) + } + return body["album"].(map[string]any)["id"].(string) +} + +type streamEvent struct { + typ string + data string +} + +// albumPayload is the subset of the album object the assertions inspect. +type albumPayload struct { + ID string `json:"id"` + Title string `json:"title"` + MembersCount int `json:"members_count"` + PhotosCount int `json:"photos_count"` +} + +func decodeAlbum(t *testing.T, data string) albumPayload { + t.Helper() + var a albumPayload + if err := json.Unmarshal([]byte(data), &a); err != nil { + t.Fatalf("decode album %q: %v", data, err) + } + return a +} + +type personalStream struct { + events chan streamEvent + cancel context.CancelFunc +} + +func openPersonalStream(t *testing.T, base, token string) *personalStream { + t.Helper() + return openStream(t, base, "/api/sse/me?token="+token) +} + +func openAlbumStream(t *testing.T, base, albumID, token string) *personalStream { + t.Helper() + return openStream(t, base, "/api/sse/albums/"+albumID+"?token="+token) +} + +func openStream(t *testing.T, base, path string) *personalStream { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil) + if err != nil { + cancel() + t.Fatalf("sse request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + cancel() + t.Fatalf("sse connect: %v", err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + cancel() + t.Fatalf("sse status = %d, want 200", resp.StatusCode) + } + + s := &personalStream{events: make(chan streamEvent, 128), cancel: cancel} + go func() { + defer resp.Body.Close() + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var typ string + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event: "): + typ = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + select { + case s.events <- streamEvent{typ, strings.TrimPrefix(line, "data: ")}: + default: + } + } + } + }() + t.Cleanup(cancel) + return s +} + +func (s *personalStream) next(t *testing.T) streamEvent { + t.Helper() + select { + case ev := <-s.events: + return ev + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a personal event") + return streamEvent{} + } +} + +func (s *personalStream) expectNoEvent(t *testing.T) { + t.Helper() + select { + case ev := <-s.events: + t.Fatalf("unexpected event %q", ev.typ) + case <-time.After(100 * time.Millisecond): + } +} + +// TestAlbumLifecycleReachesPersonalStreams drives the whole album lifecycle over +// HTTP for an owner and a member and asserts what each personal stream receives. +func TestAlbumLifecycleReachesPersonalStreams(t *testing.T) { + ts := newAlbumLiveServer(t) + base := ts.URL + + ownerTok, _ := registerUser(t, base, "owner@live.local") + memberTok, memberID := registerUser(t, base, "member@live.local") + + ownerSSE := openPersonalStream(t, base, ownerTok) + memberSSE := openPersonalStream(t, base, memberTok) + + // Creating an album tells the owner. + albumID := createAlbum(t, base, ownerTok, "Trip") + if ev := ownerSSE.next(t); ev.typ != "album.created" { + t.Fatalf("owner event = %q, want album.created", ev.typ) + } else if a := decodeAlbum(t, ev.data); a.ID != albumID || a.MembersCount != 0 { + t.Fatalf("album.created payload = %+v", a) + } + + // Inviting and accepting gives the invitee the album and updates the owner. + status, invBody := doJSON(t, http.MethodPost, base+"/api/albums/"+albumID+"/invites", ownerTok, + map[string]any{"role": "member", "max_uses": 1, "expires_in": "1h"}) + if status != http.StatusCreated { + t.Fatalf("create invite: status %d body %v", status, invBody) + } + inviteToken := invBody["invite"].(map[string]any)["token"].(string) + if status, body := doJSON(t, http.MethodPost, base+"/api/invites/"+inviteToken+"/accept", memberTok, nil); status != http.StatusOK { + t.Fatalf("accept invite: status %d body %v", status, body) + } + + if ev := memberSSE.next(t); ev.typ != "album.created" { + t.Fatalf("invitee event = %q, want album.created", ev.typ) + } else if a := decodeAlbum(t, ev.data); a.ID != albumID || a.MembersCount != 1 { + t.Fatalf("invitee payload = %+v", a) + } + if ev := ownerSSE.next(t); ev.typ != "album.updated" { + t.Fatalf("owner event = %q, want album.updated", ev.typ) + } else if a := decodeAlbum(t, ev.data); a.MembersCount != 1 { + t.Fatalf("owner payload = %+v", a) + } + // The owner also receives the persistent notification for the new member. + if ev := ownerSSE.next(t); ev.typ != "notification" { + t.Fatalf("owner event = %q, want notification", ev.typ) + } + + // Changing the member's role reaches both. + if status, body := doJSON(t, http.MethodPatch, base+"/api/albums/"+albumID+"/members/"+memberID, ownerTok, + map[string]any{"role": "editor"}); status != http.StatusNoContent { + t.Fatalf("change role: status %d body %v", status, body) + } + for _, s := range []*personalStream{ownerSSE, memberSSE} { + if ev := s.next(t); ev.typ != "album.updated" { + t.Fatalf("role change event = %q, want album.updated", ev.typ) + } + } + + // Removing the member updates the owner and tells the removed member the + // album is gone, carrying only an id. + if status, body := doJSON(t, http.MethodDelete, base+"/api/albums/"+albumID+"/members/"+memberID, ownerTok, nil); status != http.StatusNoContent { + t.Fatalf("remove member: status %d body %v", status, body) + } + if ev := ownerSSE.next(t); ev.typ != "album.updated" { + t.Fatalf("owner event = %q, want album.updated", ev.typ) + } else if a := decodeAlbum(t, ev.data); a.MembersCount != 0 { + t.Fatalf("owner payload = %+v", a) + } + if ev := memberSSE.next(t); ev.typ != "album.deleted" { + t.Fatalf("removed member event = %q, want album.deleted", ev.typ) + } else { + var raw map[string]any + if err := json.Unmarshal([]byte(ev.data), &raw); err != nil { + t.Fatalf("decode deleted payload: %v", err) + } + if raw["id"] != albumID || raw["owner_id"] != nil { + t.Fatalf("album.deleted payload = %v", raw) + } + } + memberSSE.expectNoEvent(t) + + // Deleting the album reaches the owner, and the list reconciles without it. + if status, body := doJSON(t, http.MethodDelete, base+"/api/albums/"+albumID, ownerTok, nil); status != http.StatusNoContent { + t.Fatalf("delete album: status %d body %v", status, body) + } + if ev := ownerSSE.next(t); ev.typ != "album.deleted" { + t.Fatalf("owner event = %q, want album.deleted", ev.typ) + } + + status, listBody := doJSON(t, http.MethodGet, base+"/api/albums", memberTok, nil) + if status != http.StatusOK { + t.Fatalf("list albums: status %d", status) + } + if albums := listBody["albums"].([]any); len(albums) != 0 { + t.Fatalf("albums after delete = %d, want 0", len(albums)) + } +} + +func uploadPhoto(t *testing.T, base, token, albumID, filename string) string { + t.Helper() + body, contentType := multipartFile(t, "file", filename, makeTestPNG(t)) + req, err := http.NewRequest(http.MethodPost, base+"/api/albums/"+albumID+"/photos", body) + if err != nil { + t.Fatalf("upload request: %v", err) + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("upload status = %d body %s", resp.StatusCode, raw) + } + var out struct { + Photos []struct { + ID string `json:"id"` + } `json:"photos"` + } + if err := json.Unmarshal(raw, &out); err != nil || len(out.Photos) == 0 { + t.Fatalf("upload decode: %v body %s", err, raw) + } + return out.Photos[0].ID +} + +// TestPhotoChangesUpdateAlbumCountsAndStreams covers photo composition changes: +// upload publishes photo.created on the album stream and album.updated with the +// new count to the owner and members; trashing and restoring move the count back +// and forth (the counter ignores photos in the trash). +func TestPhotoChangesUpdateAlbumCountsAndStreams(t *testing.T) { + ts := newAlbumLiveServer(t) + base := ts.URL + + ownerTok, _ := registerUser(t, base, "photo-owner@live.local") + memberTok, _ := registerUser(t, base, "photo-member@live.local") + + albumID := createAlbum(t, base, ownerTok, "Photos") + status, invBody := doJSON(t, http.MethodPost, base+"/api/albums/"+albumID+"/invites", ownerTok, + map[string]any{"role": "member", "max_uses": 1, "expires_in": "1h"}) + if status != http.StatusCreated { + t.Fatalf("create invite: status %d body %v", status, invBody) + } + inviteToken := invBody["invite"].(map[string]any)["token"].(string) + if status, body := doJSON(t, http.MethodPost, base+"/api/invites/"+inviteToken+"/accept", memberTok, nil); status != http.StatusOK { + t.Fatalf("accept invite: status %d body %v", status, body) + } + + // Streams open after the membership is settled, so only photo changes flow. + ownerSSE := openPersonalStream(t, base, ownerTok) + memberSSE := openPersonalStream(t, base, memberTok) + albumStream := openAlbumStream(t, base, albumID, ownerTok) + + awaitAlbum := func(context string, wantCount int) { + t.Helper() + for name, s := range map[string]*personalStream{"owner": ownerSSE, "member": memberSSE} { + ev := s.next(t) + if ev.typ != "album.updated" { + t.Fatalf("%s/%s: event = %q, want album.updated", context, name, ev.typ) + } + if a := decodeAlbum(t, ev.data); a.PhotosCount != wantCount { + t.Fatalf("%s/%s: photos_count = %d, want %d", context, name, a.PhotosCount, wantCount) + } + } + } + + // Upload: the photo appears in the album and the count grows. + photoID := uploadPhoto(t, base, ownerTok, albumID, "a.png") + if ev := albumStream.next(t); ev.typ != "photo.created" { + t.Fatalf("album stream event = %q, want photo.created", ev.typ) + } + awaitAlbum("upload", 1) + + // Trash: the photo leaves the album and the count drops. + if status, body := doJSON(t, http.MethodDelete, base+"/api/photos/"+photoID, ownerTok, nil); status != http.StatusNoContent { + t.Fatalf("trash photo: status %d body %v", status, body) + } + if ev := albumStream.next(t); ev.typ != "photo.deleted" { + t.Fatalf("album stream event = %q, want photo.deleted", ev.typ) + } + awaitAlbum("trash", 0) + + // Restore: the photo is back and the count grows again. + if status, body := doJSON(t, http.MethodPost, base+"/api/photos/"+photoID+"/restore", ownerTok, nil); status != http.StatusOK { + t.Fatalf("restore photo: status %d body %v", status, body) + } + if ev := albumStream.next(t); ev.typ != "photo.created" { + t.Fatalf("album stream event = %q, want photo.created", ev.typ) + } + awaitAlbum("restore", 1) +} + +// collectAlbumUpdates reads n album.updated events and maps album id -> count. +func collectAlbumUpdates(t *testing.T, s *personalStream, n int) map[string]int { + t.Helper() + out := map[string]int{} + for i := 0; i < n; i++ { + ev := s.next(t) + if ev.typ != "album.updated" { + t.Fatalf("event = %q, want album.updated", ev.typ) + } + a := decodeAlbum(t, ev.data) + out[a.ID] = a.PhotosCount + } + return out +} + +// Moving a photo updates the counts of both albums; copying updates the target. +func TestPhotoMoveAndCopyUpdateAlbumCounts(t *testing.T) { + ts := newAlbumLiveServer(t) + base := ts.URL + + ownerTok, _ := registerUser(t, base, "mover@live.local") + personal := openPersonalStream(t, base, ownerTok) + + albumA := createAlbum(t, base, ownerTok, "A") + albumB := createAlbum(t, base, ownerTok, "B") + for i := 0; i < 2; i++ { + if ev := personal.next(t); ev.typ != "album.created" { + t.Fatalf("event = %q, want album.created", ev.typ) + } + } + + photoID := uploadPhoto(t, base, ownerTok, albumA, "a.png") + if got := collectAlbumUpdates(t, personal, 1); got[albumA] != 1 { + t.Fatalf("after upload A = %d, want 1 (%v)", got[albumA], got) + } + + status, body := doJSON(t, http.MethodPost, base+"/api/photo-batch/move", ownerTok, + map[string]any{"ids": []string{photoID}, "album_id": albumB}) + if status != http.StatusOK { + t.Fatalf("move: status %d body %v", status, body) + } + if got := collectAlbumUpdates(t, personal, 2); got[albumA] != 0 || got[albumB] != 1 { + t.Fatalf("after move A=%d B=%d, want 0/1 (%v)", got[albumA], got[albumB], got) + } + + status, body = doJSON(t, http.MethodPost, base+"/api/photo-batch/copy", ownerTok, + map[string]any{"ids": []string{photoID}, "album_id": albumA}) + if status != http.StatusOK { + t.Fatalf("copy: status %d body %v", status, body) + } + if got := collectAlbumUpdates(t, personal, 1); got[albumA] != 1 { + t.Fatalf("after copy A = %d, want 1 (%v)", got[albumA], got) + } +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index eea9d0c..d30488e 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -63,7 +63,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e notifSvc := notifications.NewService(repos.Notifications, hub) albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st) - membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc) + membersSvc := members.NewService(repos.Members, albumsSvc, hub, notifSvc) photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub) photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency) diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go index 4162ee2..01bc5ef 100644 --- a/backend/internal/e2e/sqlite_test.go +++ b/backend/internal/e2e/sqlite_test.go @@ -314,13 +314,23 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) { t.Fatalf("upload: %v", err) } - select { - case ev := <-sub.Events(): - if ev.Type != "photo.updated" { - t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type) + // The upload itself publishes photo.created; variant readiness then publishes + // photo.updated. Wait for the latter, allowing the former. + deadline := time.After(5 * time.Second) + for { + select { + case ev := <-sub.Events(): + switch ev.Type { + case "photo.updated": + return + case "photo.created": + // expected from the upload, keep waiting + default: + t.Fatalf("unexpected album event %q", ev.Type) + } + case <-deadline: + t.Fatalf("no photo.updated event published for generated variants") } - case <-time.After(5 * time.Second): - t.Fatalf("no photo.updated event published for generated variants") } } diff --git a/backend/internal/members/helpers_test.go b/backend/internal/members/helpers_test.go new file mode 100644 index 0000000..d5f9c19 --- /dev/null +++ b/backend/internal/members/helpers_test.go @@ -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) + } + } +} diff --git a/backend/internal/members/repository.go b/backend/internal/members/repository.go index 69b95ec..83e05eb 100644 --- a/backend/internal/members/repository.go +++ b/backend/internal/members/repository.go @@ -10,6 +10,10 @@ import ( type Repository interface { Role(ctx context.Context, albumID, userID string) (string, error) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) + // Audience returns the ids of every user the album is visible to: the owner + // plus all members. It resolves the set without joining users, so it stays + // cheap enough to call for every published album event. + Audience(ctx context.Context, albumID string) ([]string, error) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error UpdateRole(ctx context.Context, albumID, userID, role string) error Remove(ctx context.Context, albumID, userID string) error diff --git a/backend/internal/members/repository_test.go b/backend/internal/members/repository_test.go new file mode 100644 index 0000000..930a339 --- /dev/null +++ b/backend/internal/members/repository_test.go @@ -0,0 +1,41 @@ +package members_test + +import ( + "context" + "testing" + + "galo/internal/auth" + "galo/internal/models" +) + +// Audience must resolve the owner plus every member from their own tables, and +// must be empty for an album that does not exist. +func TestAudience(t *testing.T) { + ctx := context.Background() + st := newStack(t) + owner := seedUser(t, st, "Owner") + first := seedUser(t, st, "First") + second := seedUser(t, st, "Second") + + album := &models.Album{ID: auth.NewID(), OwnerID: owner, Title: "A"} + if err := st.repos.Albums.Create(ctx, album); err != nil { + t.Fatalf("create album: %v", err) + } + + assertAudience(t, st, album.ID, owner) + + if err := st.repos.Members.Add(ctx, album.ID, first, "member", &owner); err != nil { + t.Fatalf("add first: %v", err) + } + if err := st.repos.Members.Add(ctx, album.ID, second, "editor", &owner); err != nil { + t.Fatalf("add second: %v", err) + } + assertAudience(t, st, album.ID, owner, first, second) + + if err := st.repos.Members.Remove(ctx, album.ID, second); err != nil { + t.Fatalf("remove second: %v", err) + } + assertAudience(t, st, album.ID, owner, first) + + assertAudience(t, st, "missing-album") +} diff --git a/backend/internal/members/service.go b/backend/internal/members/service.go index 5d4cb92..9f05ad5 100644 --- a/backend/internal/members/service.go +++ b/backend/internal/members/service.go @@ -12,8 +12,13 @@ import ( "galo/internal/sse" ) -type AlbumReader interface { +// AlbumAccess is what the members domain needs from the albums service: the +// owner of an album for authorization, and the ability to publish an album +// lifecycle event to specific users' personal streams. It is satisfied by +// albums.Service. +type AlbumAccess interface { OwnerID(ctx context.Context, albumID string) (string, error) + PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) } type Notifier interface { @@ -22,15 +27,53 @@ type Notifier interface { type Service struct { store Repository - albums AlbumReader + albums AlbumAccess hub *sse.Hub notify Notifier } -func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service { +func NewService(store Repository, albums AlbumAccess, hub *sse.Hub, notify Notifier) *Service { return &Service{store: store, albums: albums, hub: hub, notify: notify} } +// Event names published to personal streams on membership changes. They match +// the album lifecycle contract published by albums.Service. +const ( + eventAlbumCreated = "album.created" + eventAlbumUpdated = "album.updated" + eventAlbumDeleted = "album.deleted" +) + +// audience returns the users an album is visible to (owner + members), or nil +// when the lookup fails. A missed fan-out is recovered by the client's +// reconnect sync, so it must not fail the membership mutation. +func (s *Service) audience(ctx context.Context, albumID string) []string { + if s.albums == nil { + return nil + } + ids, err := s.store.Audience(ctx, albumID) + if err != nil { + return nil + } + return ids +} + +// audienceExcept is audience without the given user, used when one recipient +// must get a different event (the invitee, the removed member). +func (s *Service) audienceExcept(ctx context.Context, albumID, exclude string) []string { + ids := s.audience(ctx, albumID) + if exclude == "" { + return ids + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if id != exclude { + out = append(out, id) + } + } + return out +} + func (s *Service) Role(ctx context.Context, albumID, userID string) (string, error) { return s.store.Role(ctx, albumID, userID) } @@ -113,6 +156,10 @@ func (s *Service) Accept(ctx context.Context, token, userID string) (*models.Inv return nil, err } s.hub.Publish(inv.AlbumID, "member.joined", map[string]string{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role}) + // The invitee gains the album and gets its object; everyone already in the + // album sees the updated member count. + s.albums.PublishAlbumTo(ctx, inv.AlbumID, eventAlbumCreated, []string{userID}) + s.albums.PublishAlbumTo(ctx, inv.AlbumID, eventAlbumUpdated, s.audienceExcept(ctx, inv.AlbumID, userID)) if s.notify != nil { ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID) if err == nil && ownerID != userID { @@ -168,6 +215,7 @@ func (s *Service) ChangeRole(ctx context.Context, albumID, actorID, targetID, ne return err } s.hub.Publish(albumID, "member.updated", map[string]string{"album_id": albumID, "user_id": targetID, "role": newRole}) + s.albums.PublishAlbumTo(ctx, albumID, eventAlbumUpdated, s.audience(ctx, albumID)) return nil } @@ -196,6 +244,10 @@ func (s *Service) Remove(ctx context.Context, albumID, actorID, targetID string) return err } s.hub.Publish(albumID, "member.left", map[string]string{"album_id": albumID, "user_id": targetID}) + // Those who remain see the updated member count; the removed member only + // learns that the album is gone (album.deleted carries just its id). + s.albums.PublishAlbumTo(ctx, albumID, eventAlbumUpdated, s.audience(ctx, albumID)) + s.albums.PublishAlbumTo(ctx, albumID, eventAlbumDeleted, []string{targetID}) return nil } diff --git a/backend/internal/members/service_test.go b/backend/internal/members/service_test.go new file mode 100644 index 0000000..4258caf --- /dev/null +++ b/backend/internal/members/service_test.go @@ -0,0 +1,141 @@ +package members_test + +import ( + "context" + "testing" + + "galo/internal/albums" + "galo/internal/members" + "galo/internal/models" + "galo/internal/sse" +) + +// Accepting an invite gives the invitee album.created with the album object and +// tells everyone already in the album that the member count changed. +func TestAcceptInvitePublishesAlbumEvents(t *testing.T) { + ctx := context.Background() + st := newStack(t) + owner := seedUser(t, st, "Owner") + invitee := seedUser(t, st, "Invitee") + + album, err := st.albums.Create(ctx, owner, albums.CreateInput{Title: "Trip"}) + if err != nil { + t.Fatalf("create album: %v", err) + } + inv, err := st.svc.CreateInvite(ctx, album.ID, owner, members.CreateInviteInput{Role: "member", MaxUses: 1}) + if err != nil { + t.Fatalf("create invite: %v", err) + } + + inviteeSub := st.hub.SubscribeUser(invitee) + defer inviteeSub.Close() + ownerSub := st.hub.SubscribeUser(owner) + defer ownerSub.Close() + + if _, err := st.svc.Accept(ctx, inv.Token, invitee); err != nil { + t.Fatalf("accept: %v", err) + } + + ev := nextEvent(t, inviteeSub) + if ev.Type != "album.created" { + t.Fatalf("invitee event = %q, want album.created", ev.Type) + } + invitedAlbum, ok := ev.Data.(*models.Album) + if !ok || invitedAlbum.ID != album.ID || invitedAlbum.MembersCount != 1 { + t.Fatalf("invitee payload = %#v", ev.Data) + } + assertNoEvent(t, inviteeSub) + + ownerEv := nextEvent(t, ownerSub) + if ownerEv.Type != "album.updated" { + t.Fatalf("owner event = %q, want album.updated", ownerEv.Type) + } + ownerAlbum, ok := ownerEv.Data.(*models.Album) + if !ok || ownerAlbum.MembersCount != 1 { + t.Fatalf("owner payload = %#v", ownerEv.Data) + } + assertNoEvent(t, ownerSub) +} + +// Changing a member's role notifies the owner, the remaining members and the +// user whose role changed. +func TestChangeRolePublishesAlbumUpdated(t *testing.T) { + ctx := context.Background() + st := newStack(t) + owner := seedUser(t, st, "Owner") + target := seedUser(t, st, "Target") + + album, err := st.albums.Create(ctx, owner, albums.CreateInput{Title: "Trip"}) + if err != nil { + t.Fatalf("create album: %v", err) + } + if err := st.repos.Members.Add(ctx, album.ID, target, "member", &owner); err != nil { + t.Fatalf("add member: %v", err) + } + + ownerSub := st.hub.SubscribeUser(owner) + defer ownerSub.Close() + targetSub := st.hub.SubscribeUser(target) + defer targetSub.Close() + + if err := st.svc.ChangeRole(ctx, album.ID, owner, target, "editor"); err != nil { + t.Fatalf("change role: %v", err) + } + + for name, sub := range map[string]*sse.Subscription{"owner": ownerSub, "target": targetSub} { + ev := nextEvent(t, sub) + if ev.Type != "album.updated" { + t.Fatalf("%s event = %q, want album.updated", name, ev.Type) + } + if _, ok := ev.Data.(*models.Album); !ok { + t.Fatalf("%s payload type = %T, want *models.Album", name, ev.Data) + } + } +} + +// Removing a member tells those who remain that the count changed, and sends +// the removed member album.deleted carrying only the id. +func TestRemoveMemberPublishesUpdatedAndDeleted(t *testing.T) { + ctx := context.Background() + st := newStack(t) + owner := seedUser(t, st, "Owner") + member := seedUser(t, st, "Member") + + album, err := st.albums.Create(ctx, owner, albums.CreateInput{Title: "Trip"}) + if err != nil { + t.Fatalf("create album: %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.Remove(ctx, album.ID, owner, member); err != nil { + t.Fatalf("remove: %v", err) + } + + ownerEv := nextEvent(t, ownerSub) + if ownerEv.Type != "album.updated" { + t.Fatalf("owner event = %q, want album.updated", ownerEv.Type) + } + if a, ok := ownerEv.Data.(*models.Album); !ok || a.MembersCount != 0 { + t.Fatalf("owner payload = %#v", ownerEv.Data) + } + + memberEv := nextEvent(t, memberSub) + if memberEv.Type != "album.deleted" { + t.Fatalf("member event = %q, want album.deleted", memberEv.Type) + } + if _, isAlbum := memberEv.Data.(*models.Album); isAlbum { + t.Fatalf("member payload carried an album object") + } + payload, ok := memberEv.Data.(map[string]string) + if !ok || payload["id"] != album.ID { + t.Fatalf("member payload = %#v", memberEv.Data) + } + assertNoEvent(t, memberSub) +} diff --git a/backend/internal/members/sqlite_store.go b/backend/internal/members/sqlite_store.go index 76e9709..0c38438 100644 --- a/backend/internal/members/sqlite_store.go +++ b/backend/internal/members/sqlite_store.go @@ -60,6 +60,31 @@ func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumM return out, rows.Err() } +// Audience returns the owner id plus every member id for the album. It reads +// both from their own tables (no join with users) because the fan-out only +// needs identifiers. +func (s *SQLiteStore) Audience(ctx context.Context, albumID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT owner_id FROM albums WHERE id = $1 + UNION + SELECT user_id FROM album_members WHERE album_id = $1 + ORDER BY 1`, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error { _, err := s.db.ExecContext(ctx, `INSERT INTO album_members (album_id, user_id, role, invited_by) diff --git a/backend/internal/members/store.go b/backend/internal/members/store.go index 970f06c..34dded6 100644 --- a/backend/internal/members/store.go +++ b/backend/internal/members/store.go @@ -57,6 +57,31 @@ func (s *Store) List(ctx context.Context, albumID string) ([]models.AlbumMember, return out, rows.Err() } +// Audience returns the owner id plus every member id for the album. It reads +// both from their own tables (no join with users) because the fan-out only +// needs identifiers. +func (s *Store) Audience(ctx context.Context, albumID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT owner_id FROM albums WHERE id = $1 + UNION + SELECT user_id FROM album_members WHERE album_id = $1 + ORDER BY 1`, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + func (s *Store) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error { _, err := s.pool.Exec(ctx, `INSERT INTO album_members (album_id, user_id, role, invited_by) diff --git a/backend/internal/photos/helpers_test.go b/backend/internal/photos/helpers_test.go index bbfb646..d7805aa 100644 --- a/backend/internal/photos/helpers_test.go +++ b/backend/internal/photos/helpers_test.go @@ -50,6 +50,8 @@ func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.A return &models.Album{ID: albumID}, roles.Owner, nil } +func (stubAlbums) NotifyAlbumChanged(context.Context, string) {} + func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string { t.Helper() id := auth.NewID() diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index f6839b1..48e3fe2 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -26,6 +26,9 @@ import ( type AlbumAccess interface { Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) + // 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) } type Service struct { @@ -237,6 +240,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er if err := s.attachURLs(ctx, p); err != nil { return nil, err } + // The photo appears in the album: subscribers insert it, and the album's + // recipients see the new count (and cover, if this was the first photo). + s.hub.Publish(in.AlbumID, "photo.created", p) + s.albums.NotifyAlbumChanged(ctx, in.AlbumID) return p, nil } @@ -501,6 +508,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error { // A trashed cover leaves the album without one. s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID) s.publishDeleted(p) + s.albums.NotifyAlbumChanged(ctx, p.AlbumID) return nil } @@ -583,6 +591,7 @@ func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID st } if p.AlbumID != "" { s.hub.Publish(p.AlbumID, "photo.created", p) + s.albums.NotifyAlbumChanged(ctx, p.AlbumID) } return p, nil } @@ -620,6 +629,7 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e } if p.AlbumID != "" { s.publishDeleted(p) + s.albums.NotifyAlbumChanged(ctx, p.AlbumID) } // Mark objects that no longer have any binding for garbage collection. // The background worker removes the blobs from storage. @@ -658,6 +668,7 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album") } moved := 0 + changed := map[string]struct{}{} for _, id := range ids { p, err := s.store.GetByID(ctx, id, userID) if err != nil || p.DeletedAt != nil { @@ -686,8 +697,14 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb if err := s.attachURLs(ctx, p); err == nil { s.hub.Publish(targetAlbumID, "photo.created", p) } + changed[oldAlbum] = struct{}{} + changed[targetAlbumID] = struct{}{} moved++ } + // One album.updated per affected album, not per photo. + for albumID := range changed { + s.albums.NotifyAlbumChanged(ctx, albumID) + } return moved, nil } @@ -724,6 +741,9 @@ func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlb s.hub.Publish(targetAlbumID, "photo.created", clone) out = append(out, *clone) } + if len(out) > 0 { + s.albums.NotifyAlbumChanged(ctx, targetAlbumID) + } return out, nil } diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 3d947e6..6d4d5f3 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -65,6 +65,23 @@ func Run(t *testing.T, r Repos) { func newID() string { return auth.NewID() } +// sameIDs reports whether got contains exactly the wanted ids (any order). +func sameIDs(got []string, want ...string) bool { + if len(got) != len(want) { + return false + } + set := make(map[string]struct{}, len(got)) + for _, id := range got { + set[id] = struct{}{} + } + for _, id := range want { + if _, ok := set[id]; !ok { + return false + } + } + return true +} + func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User { t.Helper() u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name} @@ -249,6 +266,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) { if err != nil || len(list) != 1 || list[0].User.Name != "Member" { t.Fatalf("list: %v %+v", err, list) } + if aud, err := r.Members.Audience(ctx, a.ID); err != nil || !sameIDs(aud, owner.ID, member.ID) { + t.Fatalf("audience: %v %v", err, aud) + } if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil { t.Fatalf("update role: %v", err) } @@ -284,6 +304,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) { if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" { t.Fatalf("member not removed: %q", role) } + if aud, _ := r.Members.Audience(ctx, a.ID); !sameIDs(aud, owner.ID) { + t.Fatalf("audience after remove = %v", aud) + } if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) { t.Fatalf("remove missing err = %v, want ErrNotFound", err) } diff --git a/backend/internal/sse/hub_test.go b/backend/internal/sse/hub_test.go new file mode 100644 index 0000000..b7ae16f --- /dev/null +++ b/backend/internal/sse/hub_test.go @@ -0,0 +1,47 @@ +package sse + +import ( + "testing" + "time" +) + +// Publishing to a user with no subscription must be a no-op: it must neither +// panic nor register an empty subscription entry. +func TestPublishUserWithoutSubscriberIsNoop(t *testing.T) { + h := NewHub(4) + h.PublishUser("nobody", "album.created", map[string]string{"id": "a1"}) + + h.mu.RLock() + _, created := h.users["nobody"] + h.mu.RUnlock() + if created { + t.Fatalf("PublishUser created a subscription entry for an absent user") + } +} + +// The personal and per-album channels are independent: a personal event must +// not leak into the album channel. +func TestPublishUserDoesNotReachAlbumChannel(t *testing.T) { + h := NewHub(4) + albumSub := h.SubscribeAlbum("a1") + defer albumSub.Close() + userSub := h.SubscribeUser("u1") + defer userSub.Close() + + h.PublishUser("u1", "album.created", map[string]string{"id": "a1"}) + + select { + case ev := <-albumSub.Events(): + t.Fatalf("album channel received personal event %q", ev.Type) + case <-time.After(50 * time.Millisecond): + } + + select { + case ev := <-userSub.Events(): + if ev.Type != "album.created" { + t.Fatalf("user event = %q, want album.created", ev.Type) + } + case <-time.After(time.Second): + t.Fatalf("personal subscriber did not receive the event") + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8ea332..606d3a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,7 +16,8 @@ "svelte-check": "^4.1.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1226,6 +1227,112 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -1247,6 +1354,15 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1256,6 +1372,40 @@ "node": ">= 0.4" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1306,6 +1456,15 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1343,6 +1502,12 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1404,6 +1569,24 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1723,6 +1906,12 @@ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "dev": true }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1774,6 +1963,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1884,6 +2088,12 @@ "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", "dev": true }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -1907,6 +2117,18 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/svelte": { "version": "5.57.2", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", @@ -1976,6 +2198,45 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -2057,6 +2318,28 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -2076,6 +2359,87 @@ } } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8978ef4..2db82a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -18,6 +19,7 @@ "svelte-check": "^4.1.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^5.4.0" + "vite": "^5.4.0", + "vitest": "^2.1.9" } } diff --git a/frontend/src/lib/album-live.test.ts b/frontend/src/lib/album-live.test.ts new file mode 100644 index 0000000..01f3847 --- /dev/null +++ b/frontend/src/lib/album-live.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createAlbumLive, type AlbumLiveTarget } from './album-live'; +import type { Album } from '$lib/types'; + +function album(id: string, title = id): Album { + return { + id, + owner_id: 'owner', + title, + description: '', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }; +} + +// Minimal EventSource stand-in that records listeners and lets a test drive +// open/error/message without a browser. +class FakeEventSource { + static instances: FakeEventSource[] = []; + url: string; + closed = false; + onopen: ((ev: Event) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + private listeners = new Map void>>(); + + constructor(url: string) { + this.url = url; + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, cb: EventListenerOrEventListenerObject) { + const list = this.listeners.get(type) ?? []; + list.push(cb as (ev: MessageEvent) => void); + this.listeners.set(type, list); + } + + close() { + this.closed = true; + } + + emit(type: string, data: unknown) { + for (const cb of this.listeners.get(type) ?? []) { + cb({ data: JSON.stringify(data) } as MessageEvent); + } + } + + open() { + this.onopen?.(new Event('open')); + } + + fail() { + this.onerror?.(new Event('error')); + } +} + +function target(): AlbumLiveTarget { + return { list: [], load: vi.fn(async () => {}) }; +} + +beforeEach(() => { + FakeEventSource.instances = []; + vi.stubGlobal('EventSource', FakeEventSource); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('personal album stream', () => { + it('opens one connection and applies album events to the list', () => { + const t = target(); + const live = createAlbumLive(t); + live.start('tok'); + + expect(FakeEventSource.instances).toHaveLength(1); + const es = FakeEventSource.instances[0]; + expect(es.url).toBe('/api/sse/me?token=tok'); + + es.emit('album.created', album('a')); + expect(t.list.map((a) => a.id)).toEqual(['a']); + + es.emit('album.updated', { ...album('a'), title: 'A2' }); + expect(t.list.map((a) => a.id)).toEqual(['a']); + expect(t.list[0].title).toBe('A2'); + + es.emit('album.deleted', { id: 'a' }); + expect(t.list).toEqual([]); + }); + + it('keeps the list order when an album in the middle is updated', () => { + const t = target(); + t.list = [album('a'), album('b'), album('c')]; + const live = createAlbumLive(t); + live.start('tok'); + + FakeEventSource.instances[0].emit('album.updated', { ...album('b'), title: 'B2' }); + expect(t.list.map((a) => a.id)).toEqual(['a', 'b', 'c']); + expect(t.list[1].title).toBe('B2'); + }); + + it('re-syncs the list over REST after a reconnect, not on first open', () => { + const t = target(); + const live = createAlbumLive(t); + live.start('tok'); + + FakeEventSource.instances[0].open(); + expect(t.load).not.toHaveBeenCalled(); + + FakeEventSource.instances[0].fail(); + vi.advanceTimersByTime(1000); + expect(FakeEventSource.instances).toHaveLength(2); + + FakeEventSource.instances[1].open(); + expect(t.load).toHaveBeenCalledTimes(1); + }); + + it('closes the connection on stop and does not reconnect afterwards', () => { + const t = target(); + const live = createAlbumLive(t); + live.start('tok'); + const es = FakeEventSource.instances[0]; + + live.stop(); + expect(es.closed).toBe(true); + expect(live.connected).toBe(false); + + es.fail(); + vi.advanceTimersByTime(5000); + expect(FakeEventSource.instances).toHaveLength(1); + }); + + it('replacing the stream closes the previous connection (one per session)', () => { + const t = target(); + const live = createAlbumLive(t); + live.start('first'); + live.start('second'); + + expect(FakeEventSource.instances).toHaveLength(2); + expect(FakeEventSource.instances[0].closed).toBe(true); + expect(FakeEventSource.instances[1].url).toBe('/api/sse/me?token=second'); + }); +}); diff --git a/frontend/src/lib/album-live.ts b/frontend/src/lib/album-live.ts new file mode 100644 index 0000000..f5089e3 --- /dev/null +++ b/frontend/src/lib/album-live.ts @@ -0,0 +1,54 @@ +import { SSEClient, type SSEEvent } from '$lib/sse'; +import { applyAlbumEvent } from './albums-events'; +import type { Album } from '$lib/types'; + +// Target abstracts the album list and its REST reload so the personal stream +// can be tested without the Svelte runes store. +export interface AlbumLiveTarget { + list: Album[]; + load: () => Promise; +} + +export interface AlbumLive { + // start opens the personal stream, replacing any previous connection so a + // session keeps exactly one subscription. + start(token: string): void; + // stop closes the stream and cancels reconnection. + stop(): void; + // handle is exposed for tests; the real client routes events through it. + handle(event: SSEEvent): void; + // connected reports whether a stream is currently open. + readonly connected: boolean; +} + +export function createAlbumLive(target: AlbumLiveTarget): AlbumLive { + let client: SSEClient | null = null; + + function handle(event: SSEEvent) { + target.list = applyAlbumEvent(target.list, event.type, event.data); + } + + return { + start(token: string) { + this.stop(); + client = new SSEClient({ + path: '/api/sse/me', + onEvent: handle, + // After a reconnect, re-sync from REST to recover changes missed + // while the connection was down. + onReconnect: () => { + target.load().catch(() => {}); + } + }); + client.connect(token); + }, + stop() { + client?.close(); + client = null; + }, + handle, + get connected() { + return client !== null; + } + }; +} diff --git a/frontend/src/lib/albums-events.test.ts b/frontend/src/lib/albums-events.test.ts new file mode 100644 index 0000000..b45ddbc --- /dev/null +++ b/frontend/src/lib/albums-events.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { applyAlbumEvent, removeAlbum, upsertAlbum, albumAccessLost } from './albums-events'; +import type { Album } from '$lib/types'; + +function album(id: string, title = id): Album { + return { + id, + owner_id: 'owner', + title, + description: '', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }; +} + +describe('upsertAlbum', () => { + it('prepends an unseen album', () => { + const list = [album('a')]; + expect(upsertAlbum(list, album('b')).map((a) => a.id)).toEqual(['b', 'a']); + }); + + it('replaces an existing album in place, preserving order', () => { + const list = [album('a'), album('b'), album('c')]; + const updated = upsertAlbum(list, { ...album('b'), title: 'B2' }); + expect(updated.map((a) => a.id)).toEqual(['a', 'b', 'c']); + expect(updated[1].title).toBe('B2'); + }); +}); + +describe('removeAlbum', () => { + it('drops the album with the given id and keeps the rest', () => { + const list = [album('a'), album('b'), album('c')]; + expect(removeAlbum(list, 'b').map((a) => a.id)).toEqual(['a', 'c']); + }); +}); + +describe('albumAccessLost', () => { + it('is true once the album is gone from the list', () => { + const list = [album('a'), album('b')]; + expect(albumAccessLost(list, 'a')).toBe(false); + expect(albumAccessLost(removeAlbum(list, 'a'), 'a')).toBe(true); + }); +}); + +describe('applyAlbumEvent', () => { + it('adds an album on album.created', () => { + expect(applyAlbumEvent([], 'album.created', album('a')).map((a) => a.id)).toEqual(['a']); + }); + + it('replaces an album in place on album.updated', () => { + const list = [album('a'), album('b')]; + const next = applyAlbumEvent(list, 'album.updated', { ...album('a'), title: 'A2' }); + expect(next.map((a) => a.id)).toEqual(['a', 'b']); + expect(next[0].title).toBe('A2'); + }); + + it('removes an album on album.deleted carrying only an id', () => { + const list = [album('a'), album('b')]; + expect(applyAlbumEvent(list, 'album.deleted', { id: 'a' }).map((a) => a.id)).toEqual(['b']); + }); + + it('ignores events it does not handle', () => { + const list = [album('a')]; + expect(applyAlbumEvent(list, 'notification', { id: 'x' })).toBe(list); + }); +}); diff --git a/frontend/src/lib/albums-events.ts b/frontend/src/lib/albums-events.ts new file mode 100644 index 0000000..3571e3a --- /dev/null +++ b/frontend/src/lib/albums-events.ts @@ -0,0 +1,39 @@ +import type { Album } from '$lib/types'; + +// Pure list mutations for the album store. They are kept free of runes so the +// personal-stream handling can be unit-tested without mounting a component. + +// upsertAlbum inserts a new album at the head of the list or replaces an +// existing one in place, so receiving an update never reorders the list. +export function upsertAlbum(list: Album[], album: Album): Album[] { + const i = list.findIndex((a) => a.id === album.id); + if (i === -1) { + return [album, ...list]; + } + return list.map((a) => (a.id === album.id ? album : a)); +} + +export function removeAlbum(list: Album[], id: string): Album[] { + return list.filter((a) => a.id !== id); +} + +// albumAccessLost reports whether an album is no longer in the user's list, +// which is the signal for the open album page to leave to the overview. +export function albumAccessLost(list: Album[], albumId: string): boolean { + return !list.some((a) => a.id === albumId); +} + +// applyAlbumEvent applies a personal-stream event to the album list: an album +// appeared or changed, or it is gone (deleted or access revoked). Unknown event +// types leave the list untouched. +export function applyAlbumEvent(list: Album[], type: string, data: unknown): Album[] { + switch (type) { + case 'album.created': + case 'album.updated': + return upsertAlbum(list, data as Album); + case 'album.deleted': + return removeAlbum(list, (data as { id: string }).id); + default: + return list; + } +} diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts index 0951c09..63f17cf 100644 --- a/frontend/src/lib/sse.ts +++ b/frontend/src/lib/sse.ts @@ -24,6 +24,7 @@ export class SSEClient { 'member.joined', 'member.left', 'member.updated', + 'album.created', 'album.updated', 'album.deleted', 'notification' diff --git a/frontend/src/lib/stores/album-live.ts b/frontend/src/lib/stores/album-live.ts new file mode 100644 index 0000000..9900a15 --- /dev/null +++ b/frontend/src/lib/stores/album-live.ts @@ -0,0 +1,14 @@ +import { albums } from './albums.svelte'; +import { createAlbumLive } from '$lib/album-live'; + +// Single personal-stream subscription for the (app) layout. It is a module +// singleton so navigation between pages reuses the same connection. +export const albumLive = createAlbumLive({ + get list() { + return albums.list; + }, + set list(next) { + albums.list = next; + }, + load: () => albums.load() +}); diff --git a/frontend/src/lib/stores/albums.svelte.ts b/frontend/src/lib/stores/albums.svelte.ts index 38e50bc..efdcfda 100644 --- a/frontend/src/lib/stores/albums.svelte.ts +++ b/frontend/src/lib/stores/albums.svelte.ts @@ -1,4 +1,5 @@ import { api } from '$lib/api'; +import { upsertAlbum, removeAlbum } from '$lib/albums-events'; import type { Album } from '$lib/types'; import { auth } from './auth.svelte'; @@ -55,16 +56,11 @@ class AlbumsStore { } upsert(album: Album) { - const i = this.list.findIndex((a) => a.id === album.id); - if (i === -1) { - this.list = [album, ...this.list]; - } else { - this.list = this.list.map((a) => (a.id === album.id ? album : a)); - } + this.list = upsertAlbum(this.list, album); } remove(id: string) { - this.list = this.list.filter((a) => a.id !== id); + this.list = removeAlbum(this.list, id); } reset() { diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 6f4a2ee..f765fe0 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -5,10 +5,22 @@ import AlbumPanel from '$lib/components/AlbumPanel.svelte'; import TransferDialog from '$lib/components/TransferDialog.svelte'; import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte'; + import { auth } from '$lib/stores/auth.svelte'; + import { albumLive } from '$lib/stores/album-live'; let { children } = $props(); const showPanel = $derived($page.url.pathname !== '/'); + + // Personal stream: one subscription for the whole session, kept alive across + // navigation inside the (app) group. The stream updates the album list + // directly from event payloads and re-syncs it after a reconnect. + $effect(() => { + const token = auth.access; + if (!token) return; + albumLive.start(token); + return () => albumLive.stop(); + }); diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index a8bd45e..739c20b 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -10,6 +10,7 @@ import Icon from '$lib/components/Icon.svelte'; import { auth } from '$lib/stores/auth.svelte'; import { albums } from '$lib/stores/albums.svelte'; + import { albumAccessLost } from '$lib/albums-events'; import { density } from '$lib/stores/density.svelte'; import { selection } from '$lib/stores/selection.svelte'; import { photoUpdates } from '$lib/stores/photo-updates.svelte'; @@ -82,6 +83,15 @@ }); }); + $effect(() => { + // The personal stream removes albums from the store on deletion or access + // loss (member excluded); when the album open here disappears, leave to + // the overview instead of showing a stale page. + if (!albums.loaded || loading || !album) return; + if (!albumAccessLost(albums.list, albumId)) return; + goto('/'); + }); + $effect(() => { // Patch a photo edited in the panel (e.g. tags) into the grid list so // derived UI like the 360 badge updates without a reload. Read the list diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..b10b93c --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +// Frontend tests cover the transport-agnostic logic (event application, stream +// lifecycle). They run on plain TypeScript modules, so no Svelte plugin or DOM +// environment is needed. +export default defineConfig({ + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) + } + }, + test: { + include: ['src/**/*.test.ts'], + environment: 'node' + } +}); diff --git a/project.md b/project.md index 061c419..645152b 100644 --- a/project.md +++ b/project.md @@ -443,8 +443,10 @@ colors: { - `DELETE /api/comments/:id` ### Real-time (SSE) -- `GET /api/sse/albums/:id` — события альбома -- `GET /api/sse/me` — персональный поток (приглашения, уведомления) +- `GET /api/sse/albums/:id` — события открытого альбома (`photo.*`, `like.*`, + `comment.created`, `member.*`, `album.updated`, `album.deleted`) +- `GET /api/sse/me` — персональный поток: `notification` и события жизненного цикла + альбома `album.created`, `album.updated`, `album.deleted` ## 12. SSE — детали реализации @@ -455,6 +457,30 @@ data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}} ``` +### Персональный поток (`/api/sse/me`) +Несёт события, адресованные конкретному пользователю: + +| Событие | Когда | payload | +|---|---|---| +| `notification` | создано уведомление для пользователя | объект уведомления | +| `album.created` | альбом появился в списке пользователя (он его создал или принял инвайт) | объект альбома | +| `album.updated` | изменены название/описание/обложка, состав участников или состав фото | объект альбома | +| `album.deleted` | альбом удалён владельцем или доступ отозван (исключение из участников) | `{"id":"..."}` | + +Объект альбома в `album.created` и `album.updated` совпадает по полям с элементом +ответа `GET /api/albums`: `id`, `owner_id`, `title`, `description`, `cover_photo_id`, +`cover_thumb_url`, `cover_medium_url`, `photos_count`, `members_count`, `created_at`, +`updated_at`. Событие рассылается веером по личным каналам владельца и каждого +участника альбома; `album.deleted` при исключении участника получает только +исключённый. Публикация аддитивна: клиенты, которые не знают новый тип события, +его игнорируют, а пер-альбомный контракт (`album.updated`, `album.deleted`) сохранён. + +Состав фото отражается в персональном потоке: загрузка, копирование, перемещение, +восстановление, перевод в корзину и окончательное удаление публикуют `album.updated` +получателям (в пер-альбомный канал `album.updated` по этим поводам не идёт — открытая +страница узнаёт об изменениях из `photo.*`). `photos_count` считает только фото вне +корзины и совпадает с числом фото в сетке альбома. + ### Backend: hub на каналах - В памяти `map[albumID]map[clientID]chan Event` - Регистрация клиента при подключении, удаление при отключении @@ -481,7 +507,10 @@ X-Accel-Buffering: no ### Догон пропущенного `EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении -клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=`. +клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=` +для открытого альбома и `GET /api/albums` для списка альбомов (персональный поток). +Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе +удаления: удалённый альбом в ответе уже не приходит. ### Ограничения SSE - Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE