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:
iu
2026-10-15 23:25:07 +03:00
parent cbff7a340c
commit e82f45647c
31 changed files with 2157 additions and 28 deletions
+77 -2
View File
@@ -15,6 +15,9 @@ import (
type MemberLookup interface {
Role(ctx context.Context, albumID, userID string) (string, error)
// Audience resolves the users the album is visible to (owner + members) for
// personal event fan-out.
Audience(ctx context.Context, albumID string) ([]string, error)
}
type Service struct {
@@ -28,6 +31,69 @@ func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage st
return &Service{store: store, members: members, hub: hub, storage: storage}
}
// Event names published to personal streams for the album lifecycle. They are
// part of the client contract documented in project.md.
const (
EventAlbumCreated = "album.created"
EventAlbumUpdated = "album.updated"
EventAlbumDeleted = "album.deleted"
)
// OwnerID returns the owner of the album, delegating to the store so callers
// that only need the owner (members invites, role checks) can use this service
// as their album reader.
func (s *Service) OwnerID(ctx context.Context, albumID string) (string, error) {
return s.store.OwnerID(ctx, albumID)
}
// PublishAlbumTo fans an album lifecycle event out to the personal streams of
// the given users. Events other than deletion carry the album re-read through
// Store.GetByID and enriched with attachCover, so the payload has the same
// shape as an element of GET /api/albums; deletion carries only {id} because
// the album is already gone when it is published.
func (s *Service) PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string) {
if len(userIDs) == 0 || s.hub == nil {
return
}
var payload any
if eventType == EventAlbumDeleted {
payload = map[string]string{"id": albumID}
} else {
a, err := s.store.GetByID(ctx, albumID)
if err != nil {
return
}
s.attachCover(ctx, a)
payload = a
}
for _, uid := range userIDs {
s.hub.PublishUser(uid, eventType, payload)
}
}
// NotifyAlbumChanged re-reads the album and publishes album.updated to the
// personal streams of its owner and members, without touching the per-album
// channel. Photo-composition changes use it: the open album page learns about
// them from photo.* events, and a per-album album.updated would force a REST
// reload for every photo.
func (s *Service) NotifyAlbumChanged(ctx context.Context, albumID string) {
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
}
// albumAudience resolves the users an album is visible to. Resolution failures
// are swallowed: a missed fan-out is recovered by the client's reconnect sync
// and must not fail the mutation that triggered it.
func (s *Service) albumAudience(ctx context.Context, albumID string) []string {
if s.members == nil {
return nil
}
ids, err := s.members.Audience(ctx, albumID)
if err != nil {
return nil
}
return ids
}
type CreateInput struct {
Title string
Description string
@@ -47,6 +113,7 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
if err := s.store.Create(ctx, a); err != nil {
return nil, err
}
s.PublishAlbumTo(ctx, a.ID, EventAlbumCreated, s.albumAudience(ctx, a.ID))
return a, nil
}
@@ -152,7 +219,11 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
a = refreshed
}
s.attachCover(ctx, a)
s.hub.Publish(albumID, "album.updated", a)
s.hub.Publish(albumID, EventAlbumUpdated, a)
// The per-album event above keeps the mobile client and the open album page
// working; the personal fan-out below reaches everyone whose album list
// changes, whether or not they are looking at this album.
s.PublishAlbumTo(ctx, albumID, EventAlbumUpdated, s.albumAudience(ctx, albumID))
return a, nil
}
@@ -164,12 +235,16 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album")
}
// The audience must be computed before deletion: album_members is removed by
// cascade with the album, so the recipient list is unavailable afterwards.
audience := s.albumAudience(ctx, a.ID)
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err
}
if err := s.store.Delete(ctx, a.ID); err != nil {
return err
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
s.hub.Publish(albumID, EventAlbumDeleted, map[string]string{"id": a.ID})
s.PublishAlbumTo(ctx, albumID, EventAlbumDeleted, audience)
return nil
}
+351
View File
@@ -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)
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
a.created_at, a.updated_at, COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
const sqliteAlbumJoins = `
+2 -2
View File
@@ -45,7 +45,7 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
err := s.pool.QueryRow(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
@@ -67,7 +67,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
rows, err := s.pool.Query(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''), COALESCE(com.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM photos WHERE album_id = a.id AND deleted_at IS NULL),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
+457
View File
@@ -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)
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
notifSvc := notifications.NewService(repos.Notifications, hub)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
membersSvc := members.NewService(repos.Members, albumsSvc, hub, notifSvc)
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
+13 -3
View File
@@ -314,14 +314,24 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) {
t.Fatalf("upload: %v", err)
}
// The upload itself publishes photo.created; variant readiness then publishes
// photo.updated. Wait for the latter, allowing the former.
deadline := time.After(5 * time.Second)
for {
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
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 <-time.After(5 * time.Second):
case <-deadline:
t.Fatalf("no photo.updated event published for generated variants")
}
}
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
+108
View File
@@ -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)
}
}
}
+4
View File
@@ -10,6 +10,10 @@ import (
type Repository interface {
Role(ctx context.Context, albumID, userID string) (string, error)
List(ctx context.Context, albumID string) ([]models.AlbumMember, error)
// Audience returns the ids of every user the album is visible to: the owner
// plus all members. It resolves the set without joining users, so it stays
// cheap enough to call for every published album event.
Audience(ctx context.Context, albumID string) ([]string, error)
Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error
UpdateRole(ctx context.Context, albumID, userID, role string) error
Remove(ctx context.Context, albumID, userID string) error
@@ -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")
}
+55 -3
View File
@@ -12,8 +12,13 @@ import (
"galo/internal/sse"
)
type AlbumReader interface {
// AlbumAccess is what the members domain needs from the albums service: the
// owner of an album for authorization, and the ability to publish an album
// lifecycle event to specific users' personal streams. It is satisfied by
// albums.Service.
type AlbumAccess interface {
OwnerID(ctx context.Context, albumID string) (string, error)
PublishAlbumTo(ctx context.Context, albumID, eventType string, userIDs []string)
}
type Notifier interface {
@@ -22,15 +27,53 @@ type Notifier interface {
type Service struct {
store Repository
albums AlbumReader
albums AlbumAccess
hub *sse.Hub
notify Notifier
}
func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
func NewService(store Repository, albums AlbumAccess, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, hub: hub, notify: notify}
}
// Event names published to personal streams on membership changes. They match
// the album lifecycle contract published by albums.Service.
const (
eventAlbumCreated = "album.created"
eventAlbumUpdated = "album.updated"
eventAlbumDeleted = "album.deleted"
)
// audience returns the users an album is visible to (owner + members), or nil
// when the lookup fails. A missed fan-out is recovered by the client's
// reconnect sync, so it must not fail the membership mutation.
func (s *Service) audience(ctx context.Context, albumID string) []string {
if s.albums == nil {
return nil
}
ids, err := s.store.Audience(ctx, albumID)
if err != nil {
return nil
}
return ids
}
// audienceExcept is audience without the given user, used when one recipient
// must get a different event (the invitee, the removed member).
func (s *Service) audienceExcept(ctx context.Context, albumID, exclude string) []string {
ids := s.audience(ctx, albumID)
if exclude == "" {
return ids
}
out := make([]string, 0, len(ids))
for _, id := range ids {
if id != exclude {
out = append(out, id)
}
}
return out
}
func (s *Service) Role(ctx context.Context, albumID, userID string) (string, error) {
return s.store.Role(ctx, albumID, userID)
}
@@ -113,6 +156,10 @@ func (s *Service) Accept(ctx context.Context, token, userID string) (*models.Inv
return nil, err
}
s.hub.Publish(inv.AlbumID, "member.joined", map[string]string{"album_id": inv.AlbumID, "user_id": userID, "role": inv.Role})
// The invitee gains the album and gets its object; everyone already in the
// album sees the updated member count.
s.albums.PublishAlbumTo(ctx, inv.AlbumID, eventAlbumCreated, []string{userID})
s.albums.PublishAlbumTo(ctx, inv.AlbumID, eventAlbumUpdated, s.audienceExcept(ctx, inv.AlbumID, userID))
if s.notify != nil {
ownerID, err := s.albums.OwnerID(ctx, inv.AlbumID)
if err == nil && ownerID != userID {
@@ -168,6 +215,7 @@ func (s *Service) ChangeRole(ctx context.Context, albumID, actorID, targetID, ne
return err
}
s.hub.Publish(albumID, "member.updated", map[string]string{"album_id": albumID, "user_id": targetID, "role": newRole})
s.albums.PublishAlbumTo(ctx, albumID, eventAlbumUpdated, s.audience(ctx, albumID))
return nil
}
@@ -196,6 +244,10 @@ func (s *Service) Remove(ctx context.Context, albumID, actorID, targetID string)
return err
}
s.hub.Publish(albumID, "member.left", map[string]string{"album_id": albumID, "user_id": targetID})
// Those who remain see the updated member count; the removed member only
// learns that the album is gone (album.deleted carries just its id).
s.albums.PublishAlbumTo(ctx, albumID, eventAlbumUpdated, s.audience(ctx, albumID))
s.albums.PublishAlbumTo(ctx, albumID, eventAlbumDeleted, []string{targetID})
return nil
}
+141
View File
@@ -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)
}
+25
View File
@@ -60,6 +60,31 @@ func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumM
return out, rows.Err()
}
// Audience returns the owner id plus every member id for the album. It reads
// both from their own tables (no join with users) because the fan-out only
// needs identifiers.
func (s *SQLiteStore) Audience(ctx context.Context, albumID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT owner_id FROM albums WHERE id = $1
UNION
SELECT user_id FROM album_members WHERE album_id = $1
ORDER BY 1`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
+25
View File
@@ -57,6 +57,31 @@ func (s *Store) List(ctx context.Context, albumID string) ([]models.AlbumMember,
return out, rows.Err()
}
// Audience returns the owner id plus every member id for the album. It reads
// both from their own tables (no join with users) because the fan-out only
// needs identifiers.
func (s *Store) Audience(ctx context.Context, albumID string) ([]string, error) {
rows, err := s.pool.Query(ctx,
`SELECT owner_id FROM albums WHERE id = $1
UNION
SELECT user_id FROM album_members WHERE album_id = $1
ORDER BY 1`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func (s *Store) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
+2
View File
@@ -50,6 +50,8 @@ func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.A
return &models.Album{ID: albumID}, roles.Owner, nil
}
func (stubAlbums) NotifyAlbumChanged(context.Context, string) {}
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
t.Helper()
id := auth.NewID()
+20
View File
@@ -26,6 +26,9 @@ import (
type AlbumAccess interface {
Access(ctx context.Context, albumID, userID string) (*models.Album, string, error)
// NotifyAlbumChanged announces a change to the album's photo composition on
// the recipients' personal streams (album cards and counts update live).
NotifyAlbumChanged(ctx context.Context, albumID string)
}
type Service struct {
@@ -237,6 +240,10 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
// The photo appears in the album: subscribers insert it, and the album's
// recipients see the new count (and cover, if this was the first photo).
s.hub.Publish(in.AlbumID, "photo.created", p)
s.albums.NotifyAlbumChanged(ctx, in.AlbumID)
return p, nil
}
@@ -501,6 +508,7 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
// A trashed cover leaves the album without one.
s.store.ClearCoverIfMatches(ctx, p.AlbumID, photoID)
s.publishDeleted(p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
return nil
}
@@ -583,6 +591,7 @@ func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID st
}
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
}
return p, nil
}
@@ -620,6 +629,7 @@ func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) e
}
if p.AlbumID != "" {
s.publishDeleted(p)
s.albums.NotifyAlbumChanged(ctx, p.AlbumID)
}
// Mark objects that no longer have any binding for garbage collection.
// The background worker removes the blobs from storage.
@@ -658,6 +668,7 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
moved := 0
changed := map[string]struct{}{}
for _, id := range ids {
p, err := s.store.GetByID(ctx, id, userID)
if err != nil || p.DeletedAt != nil {
@@ -686,8 +697,14 @@ func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlb
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(targetAlbumID, "photo.created", p)
}
changed[oldAlbum] = struct{}{}
changed[targetAlbumID] = struct{}{}
moved++
}
// One album.updated per affected album, not per photo.
for albumID := range changed {
s.albums.NotifyAlbumChanged(ctx, albumID)
}
return moved, nil
}
@@ -724,6 +741,9 @@ func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlb
s.hub.Publish(targetAlbumID, "photo.created", clone)
out = append(out, *clone)
}
if len(out) > 0 {
s.albums.NotifyAlbumChanged(ctx, targetAlbumID)
}
return out, nil
}
@@ -65,6 +65,23 @@ func Run(t *testing.T, r Repos) {
func newID() string { return auth.NewID() }
// sameIDs reports whether got contains exactly the wanted ids (any order).
func sameIDs(got []string, want ...string) bool {
if len(got) != len(want) {
return false
}
set := make(map[string]struct{}, len(got))
for _, id := range got {
set[id] = struct{}{}
}
for _, id := range want {
if _, ok := set[id]; !ok {
return false
}
}
return true
}
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
t.Helper()
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
@@ -249,6 +266,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) {
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list: %v %+v", err, list)
}
if aud, err := r.Members.Audience(ctx, a.ID); err != nil || !sameIDs(aud, owner.ID, member.ID) {
t.Fatalf("audience: %v %v", err, aud)
}
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
@@ -284,6 +304,9 @@ func testMembers(t *testing.T, ctx context.Context, r Repos) {
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("member not removed: %q", role)
}
if aud, _ := r.Members.Audience(ctx, a.ID); !sameIDs(aud, owner.ID) {
t.Fatalf("audience after remove = %v", aud)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
}
+47
View File
@@ -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")
}
}
+365 -1
View File
@@ -16,7 +16,8 @@
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -1226,6 +1227,112 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
@@ -1247,6 +1354,15 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1256,6 +1372,40 @@
"node": ">= 0.4"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1306,6 +1456,15 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1343,6 +1502,12 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1404,6 +1569,24 @@
}
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1723,6 +1906,12 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1774,6 +1963,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1884,6 +2088,12 @@
"integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==",
"dev": true
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -1907,6 +2117,18 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/svelte": {
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -1976,6 +2198,45 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -2057,6 +2318,28 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -2076,6 +2359,87 @@
}
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+4 -2
View File
@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
@@ -18,6 +19,7 @@
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
}
+144
View File
@@ -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');
});
});
+54
View File
@@ -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;
}
};
}
+66
View File
@@ -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);
});
});
+39
View File
@@ -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;
}
}
+1
View File
@@ -24,6 +24,7 @@ export class SSEClient {
'member.joined',
'member.left',
'member.updated',
'album.created',
'album.updated',
'album.deleted',
'notification'
+14
View File
@@ -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()
});
+3 -7
View File
@@ -1,4 +1,5 @@
import { api } from '$lib/api';
import { upsertAlbum, removeAlbum } from '$lib/albums-events';
import type { Album } from '$lib/types';
import { auth } from './auth.svelte';
@@ -55,16 +56,11 @@ class AlbumsStore {
}
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
this.list = [album, ...this.list];
} else {
this.list = this.list.map((a) => (a.id === album.id ? album : a));
}
this.list = upsertAlbum(this.list, album);
}
remove(id: string) {
this.list = this.list.filter((a) => a.id !== id);
this.list = removeAlbum(this.list, id);
}
reset() {
+12
View File
@@ -5,10 +5,22 @@
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albumLive } from '$lib/stores/album-live';
let { children } = $props();
const showPanel = $derived($page.url.pathname !== '/');
// Personal stream: one subscription for the whole session, kept alive across
// navigation inside the (app) group. The stream updates the album list
// directly from event payloads and re-syncs it after a reconnect.
$effect(() => {
const token = auth.access;
if (!token) return;
albumLive.start(token);
return () => albumLive.stop();
});
</script>
<AppShell panelVisible={showPanel}>
@@ -10,6 +10,7 @@
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { albumAccessLost } from '$lib/albums-events';
import { density } from '$lib/stores/density.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
@@ -82,6 +83,15 @@
});
});
$effect(() => {
// The personal stream removes albums from the store on deletion or access
// loss (member excluded); when the album open here disappears, leave to
// the overview instead of showing a stale page.
if (!albums.loaded || loading || !album) return;
if (!albumAccessLost(albums.list, albumId)) return;
goto('/');
});
$effect(() => {
// Patch a photo edited in the panel (e.g. tags) into the grid list so
// derived UI like the 360 badge updates without a reload. Read the list
+17
View File
@@ -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
View File
@@ -443,8 +443,10 @@ colors: {
- `DELETE /api/comments/:id`
### Real-time (SSE)
- `GET /api/sse/albums/:id` — события альбома
- `GET /api/sse/me` — персональный поток (приглашения, уведомления)
- `GET /api/sse/albums/:id` — события открытого альбома (`photo.*`, `like.*`,
`comment.created`, `member.*`, `album.updated`, `album.deleted`)
- `GET /api/sse/me` — персональный поток: `notification` и события жизненного цикла
альбома `album.created`, `album.updated`, `album.deleted`
## 12. SSE — детали реализации
@@ -455,6 +457,30 @@ data: {"id":"...","album_id":"...","thumb_url":"...","uploader":{...}}
```
### Персональный поток (`/api/sse/me`)
Несёт события, адресованные конкретному пользователю:
| Событие | Когда | payload |
|---|---|---|
| `notification` | создано уведомление для пользователя | объект уведомления |
| `album.created` | альбом появился в списке пользователя (он его создал или принял инвайт) | объект альбома |
| `album.updated` | изменены название/описание/обложка, состав участников или состав фото | объект альбома |
| `album.deleted` | альбом удалён владельцем или доступ отозван (исключение из участников) | `{"id":"..."}` |
Объект альбома в `album.created` и `album.updated` совпадает по полям с элементом
ответа `GET /api/albums`: `id`, `owner_id`, `title`, `description`, `cover_photo_id`,
`cover_thumb_url`, `cover_medium_url`, `photos_count`, `members_count`, `created_at`,
`updated_at`. Событие рассылается веером по личным каналам владельца и каждого
участника альбома; `album.deleted` при исключении участника получает только
исключённый. Публикация аддитивна: клиенты, которые не знают новый тип события,
его игнорируют, а пер-альбомный контракт (`album.updated`, `album.deleted`) сохранён.
Состав фото отражается в персональном потоке: загрузка, копирование, перемещение,
восстановление, перевод в корзину и окончательное удаление публикуют `album.updated`
получателям (в пер-альбомный канал `album.updated` по этим поводам не идёт — открытая
страница узнаёт об изменениях из `photo.*`). `photos_count` считает только фото вне
корзины и совпадает с числом фото в сетке альбома.
### Backend: hub на каналах
- В памяти `map[albumID]map[clientID]chan Event`
- Регистрация клиента при подключении, удаление при отключении
@@ -481,7 +507,10 @@ X-Accel-Buffering: no
### Догон пропущенного
`EventSource` не отправляет `Last-Event-ID` надёжно. Поэтому при переподключении
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`.
клиент догоняет пропущенное через REST: `GET /api/albums/:id/photos?since=<last_event_time>`
для открытого альбома и `GET /api/albums` для списка альбомов (персональный поток).
Догон списка восстанавливает изменения, пропущенные за время разрыва, в том числе
удаления: удалённый альбом в ответе уже не приходит.
### Ограничения SSE
- Односторонний поток (server → client), действия клиента — обычные POST/PATCH/DELETE