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)
+16 -6
View File
@@ -314,13 +314,23 @@ func TestWorkerPublishesPhotoUpdated(t *testing.T) {
t.Fatalf("upload: %v", err)
}
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
// The upload itself publishes photo.created; variant readiness then publishes
// photo.updated. Wait for the latter, allowing the former.
deadline := time.After(5 * time.Second)
for {
select {
case ev := <-sub.Events():
switch ev.Type {
case "photo.updated":
return
case "photo.created":
// expected from the upload, keep waiting
default:
t.Fatalf("unexpected album event %q", ev.Type)
}
case <-deadline:
t.Fatalf("no photo.updated event published for generated variants")
}
case <-time.After(5 * time.Second):
t.Fatalf("no photo.updated event published for generated variants")
}
}
+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")
}
}