feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото
Публиковать album.created/updated/deleted и изменения состава фото в личный поток SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые страницы альбома обновлялись без перезагрузки. - members.Repository получил Audience (владелец + участники) для дешёвого веера - albums.Service рассылает события в личные потоки получателей, перечитывая альбом через GetByID и attachCover, чтобы payload совпадал с GET /api/albums - members.Service публикует при принятии инвайта, смене роли и исключении - photos.Service публикует photo.created при загрузке и album.updated при добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении - photos_count теперь считает фото вне корзины - web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user