feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото
Публиковать album.created/updated/deleted и изменения состава фото в личный поток SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые страницы альбома обновлялись без перезагрузки. - members.Repository получил Audience (владелец + участники) для дешёвого веера - albums.Service рассылает события в личные потоки получателей, перечитывая альбом через GetByID и attachCover, чтобы payload совпадал с GET /api/albums - members.Service публикует при принятии инвайта, смене роли и исключении - photos.Service публикует photo.created при загрузке и album.updated при добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении - photos_count теперь считает фото вне корзины - web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
@@ -15,6 +15,9 @@ import (
|
|||||||
|
|
||||||
type MemberLookup interface {
|
type MemberLookup interface {
|
||||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
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 {
|
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}
|
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 {
|
type CreateInput struct {
|
||||||
Title string
|
Title string
|
||||||
Description 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 {
|
if err := s.store.Create(ctx, a); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID))
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +219,11 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
|
|||||||
a = refreshed
|
a = refreshed
|
||||||
}
|
}
|
||||||
s.attachCover(ctx, a)
|
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
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,12 +235,16 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
|
|||||||
if role != roles.Owner {
|
if role != roles.Owner {
|
||||||
return httpx.Forbidden("forbidden", "only the owner can delete the album")
|
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 {
|
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.store.Delete(ctx, a.ID); err != nil {
|
if err := s.store.Delete(ctx, a.ID); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
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, ''),
|
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)`
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
|
||||||
|
|
||||||
const sqliteAlbumJoins = `
|
const sqliteAlbumJoins = `
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
|
|||||||
err := s.pool.QueryRow(ctx,
|
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,
|
`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, ''),
|
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)
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
FROM albums a
|
FROM albums a
|
||||||
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
|
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,
|
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,
|
`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, ''),
|
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)
|
(SELECT count(*) FROM album_members WHERE album_id = a.id)
|
||||||
FROM albums a
|
FROM albums a
|
||||||
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
|
|||||||
notifSvc := notifications.NewService(repos.Notifications, hub)
|
notifSvc := notifications.NewService(repos.Notifications, hub)
|
||||||
|
|
||||||
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
|
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)
|
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
|
||||||
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
|
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
|
||||||
|
|||||||
@@ -314,13 +314,23 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) {
|
|||||||
t.Fatalf("upload: %v", err)
|
t.Fatalf("upload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
// The upload itself publishes photo.created; variant readiness then publishes
|
||||||
case ev := <-sub.Events():
|
// photo.updated. Wait for the latter, allowing the former.
|
||||||
if ev.Type != "photo.updated" {
|
deadline := time.After(5 * time.Second)
|
||||||
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
|
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import (
|
|||||||
type Repository interface {
|
type Repository interface {
|
||||||
Role(ctx context.Context, albumID, userID string) (string, error)
|
Role(ctx context.Context, albumID, userID string) (string, error)
|
||||||
List(ctx context.Context, albumID string) ([]models.AlbumMember, 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
|
Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error
|
||||||
UpdateRole(ctx context.Context, albumID, userID, role string) error
|
UpdateRole(ctx context.Context, albumID, userID, role string) error
|
||||||
Remove(ctx context.Context, albumID, userID string) error
|
Remove(ctx context.Context, albumID, userID string) error
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -12,8 +12,13 @@ import (
|
|||||||
"galo/internal/sse"
|
"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)
|
OwnerID(ctx context.Context, albumID string) (string, error)
|
||||||
|
PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notifier interface {
|
type Notifier interface {
|
||||||
@@ -22,15 +27,53 @@ type Notifier interface {
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store Repository
|
store Repository
|
||||||
albums AlbumReader
|
albums AlbumAccess
|
||||||
hub *sse.Hub
|
hub *sse.Hub
|
||||||
notify Notifier
|
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}
|
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) {
|
func (s *Service) Role(ctx context.Context, albumID, userID string) (string, error) {
|
||||||
return s.store.Role(ctx, albumID, userID)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
s.hub.Publish(inv.AlbumID, "member.joined", map[string]string{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
|
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 {
|
if s.notify != nil {
|
||||||
ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID)
|
ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID)
|
||||||
if err == nil && ownerID != userID {
|
if err == nil && ownerID != userID {
|
||||||
@@ -168,6 +215,7 @@ func (s *Service) ChangeRole(ctx context.Context, albumID, actorID, targetID, ne
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.hub.Publish(albumID, "member.updated", map[string]string{"album_id": albumID, "user_id": targetID, "role": newRole})
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +244,10 @@ func (s *Service) Remove(ctx context.Context, albumID, actorID, targetID string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.hub.Publish(albumID, "member.left", map[string]string{"album_id": albumID, "user_id": targetID})
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -60,6 +60,31 @@ func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumM
|
|||||||
return out, rows.Err()
|
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 {
|
func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO album_members (album_id, user_id, role, invited_by)
|
`INSERT INTO album_members (album_id, user_id, role, invited_by)
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ func (s *Store) List(ctx context.Context, albumID string) ([]models.AlbumMember,
|
|||||||
return out, rows.Err()
|
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 {
|
func (s *Store) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`INSERT INTO album_members (album_id, user_id, role, invited_by)
|
`INSERT INTO album_members (album_id, user_id, role, invited_by)
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.A
|
|||||||
return &models.Album{ID: albumID}, roles.Owner, nil
|
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 {
|
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
id := auth.NewID()
|
id := auth.NewID()
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import (
|
|||||||
|
|
||||||
type AlbumAccess interface {
|
type AlbumAccess interface {
|
||||||
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
|
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 {
|
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 {
|
if err := s.attachURLs(ctx, p); err != nil {
|
||||||
return nil, err
|
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
|
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.
|
// A trashed cover leaves the album without one.
|
||||||
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
|
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
|
||||||
s.publishDeleted(p)
|
s.publishDeleted(p)
|
||||||
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +591,7 @@ func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID st
|
|||||||
}
|
}
|
||||||
if p.AlbumID != "" {
|
if p.AlbumID != "" {
|
||||||
s.hub.Publish(p.AlbumID, "photo.created", p)
|
s.hub.Publish(p.AlbumID, "photo.created", p)
|
||||||
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
@@ -620,6 +629,7 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e
|
|||||||
}
|
}
|
||||||
if p.AlbumID != "" {
|
if p.AlbumID != "" {
|
||||||
s.publishDeleted(p)
|
s.publishDeleted(p)
|
||||||
|
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
|
||||||
}
|
}
|
||||||
// Mark objects that no longer have any binding for garbage collection.
|
// Mark objects that no longer have any binding for garbage collection.
|
||||||
// The background worker removes the blobs from storage.
|
// 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")
|
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
|
||||||
}
|
}
|
||||||
moved := 0
|
moved := 0
|
||||||
|
changed := map[string]struct{}{}
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
p, err := s.store.GetByID(ctx, id, userID)
|
p, err := s.store.GetByID(ctx, id, userID)
|
||||||
if err != nil || p.DeletedAt != nil {
|
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 {
|
if err := s.attachURLs(ctx, p); err == nil {
|
||||||
s.hub.Publish(targetAlbumID, "photo.created", p)
|
s.hub.Publish(targetAlbumID, "photo.created", p)
|
||||||
}
|
}
|
||||||
|
changed[oldAlbum] = struct{}{}
|
||||||
|
changed[targetAlbumID] = struct{}{}
|
||||||
moved++
|
moved++
|
||||||
}
|
}
|
||||||
|
// One album.updated per affected album, not per photo.
|
||||||
|
for albumID := range changed {
|
||||||
|
s.albums.NotifyAlbumChanged(ctx, albumID)
|
||||||
|
}
|
||||||
return moved, nil
|
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)
|
s.hub.Publish(targetAlbumID, "photo.created", clone)
|
||||||
out = append(out, *clone)
|
out = append(out, *clone)
|
||||||
}
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
s.albums.NotifyAlbumChanged(ctx, targetAlbumID)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,23 @@ func Run(t *testing.T, r Repos) {
|
|||||||
|
|
||||||
func newID() string { return auth.NewID() }
|
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 {
|
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
|
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" {
|
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
|
||||||
t.Fatalf("list: %v %+v", err, list)
|
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 {
|
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
|
||||||
t.Fatalf("update role: %v", err)
|
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 != "" {
|
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
|
||||||
t.Fatalf("member not removed: %q", 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) {
|
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
|
||||||
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
|
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+365
-1
@@ -16,7 +16,8 @@
|
|||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -1226,6 +1227,112 @@
|
|||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||||
@@ -1247,6 +1354,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1256,6 +1372,40 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -1343,6 +1502,12 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1723,6 +1906,12 @@
|
|||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"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": "^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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1884,6 +2088,12 @@
|
|||||||
"integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==",
|
"integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -1907,6 +2117,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/svelte": {
|
||||||
"version": "5.57.2",
|
"version": "5.57.2",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
|
||||||
@@ -1976,6 +2198,45 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"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": {
|
"node_modules/vitefu": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
"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": {
|
"node_modules/zimmerframe": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"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": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"svelte-check": "^4.1.0",
|
"svelte-check": "^4.1.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, Array<(ev: MessageEvent) => 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export class SSEClient {
|
|||||||
'member.joined',
|
'member.joined',
|
||||||
'member.left',
|
'member.left',
|
||||||
'member.updated',
|
'member.updated',
|
||||||
|
'album.created',
|
||||||
'album.updated',
|
'album.updated',
|
||||||
'album.deleted',
|
'album.deleted',
|
||||||
'notification'
|
'notification'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { upsertAlbum, removeAlbum } from '$lib/albums-events';
|
||||||
import type { Album } from '$lib/types';
|
import type { Album } from '$lib/types';
|
||||||
import { auth } from './auth.svelte';
|
import { auth } from './auth.svelte';
|
||||||
|
|
||||||
@@ -55,16 +56,11 @@ class AlbumsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
upsert(album: Album) {
|
upsert(album: Album) {
|
||||||
const i = this.list.findIndex((a) => a.id === album.id);
|
this.list = upsertAlbum(this.list, album);
|
||||||
if (i === -1) {
|
|
||||||
this.list = [album, ...this.list];
|
|
||||||
} else {
|
|
||||||
this.list = this.list.map((a) => (a.id === album.id ? album : a));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(id: string) {
|
remove(id: string) {
|
||||||
this.list = this.list.filter((a) => a.id !== id);
|
this.list = removeAlbum(this.list, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
|||||||
@@ -5,10 +5,22 @@
|
|||||||
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
||||||
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
||||||
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.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();
|
let { children } = $props();
|
||||||
|
|
||||||
const showPanel = $derived($page.url.pathname !== '/');
|
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();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AppShell panelVisible={showPanel}>
|
<AppShell panelVisible={showPanel}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { albumAccessLost } from '$lib/albums-events';
|
||||||
import { density } from '$lib/stores/density.svelte';
|
import { density } from '$lib/stores/density.svelte';
|
||||||
import { selection } from '$lib/stores/selection.svelte';
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import { photoUpdates } from '$lib/stores/photo-updates.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(() => {
|
$effect(() => {
|
||||||
// Patch a photo edited in the panel (e.g. tags) into the grid list so
|
// 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
|
// derived UI like the 360 badge updates without a reload. Read the list
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
+32
-3
@@ -443,8 +443,10 @@ colors: {
|
|||||||
- `DELETE /api/comments/:id`
|
- `DELETE /api/comments/:id`
|
||||||
|
|
||||||
### Real-time (SSE)
|
### Real-time (SSE)
|
||||||
- `GET /api/sse/albums/:id` — события альбома
|
- `GET /api/sse/albums/:id` — события открытого альбома (`photo.*`, `like.*`,
|
||||||
- `GET /api/sse/me` — персональный поток (приглашения, уведомления)
|
`comment.created`, `member.*`, `album.updated`, `album.deleted`)
|
||||||
|
- `GET /api/sse/me` — персональный поток: `notification` и события жизненного цикла
|
||||||
|
альбома `album.created`, `album.updated`, `album.deleted`
|
||||||
|
|
||||||
## 12. SSE — детали реализации
|
## 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 на каналах
|
### Backend: hub на каналах
|
||||||
- В памяти `map[albumID]map[clientID]chan Event`
|
- В памяти `map[albumID]map[clientID]chan Event`
|
||||||
- Регистрация клиента при подключении, удаление при отключении
|
- Регистрация клиента при подключении, удаление при отключении
|
||||||
@@ -481,7 +507,10 @@ X-Accel-Buffering: no
|
|||||||
|
|
||||||
### Догон пропущенного
|
### Догон пропущенного
|
||||||
`EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении
|
`EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении
|
||||||
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`.
|
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`
|
||||||
|
для открытого альбома и `GET /api/albums` для списка альбомов (персональный поток).
|
||||||
|
Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе
|
||||||
|
удаления: удалённый альбом в ответе уже не приходит.
|
||||||
|
|
||||||
### Ограничения SSE
|
### Ограничения SSE
|
||||||
- Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE
|
- Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE
|
||||||
|
|||||||
Reference in New Issue
Block a user