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) } }