//go:build integration package social import ( "context" "github.com/google/uuid" "github.com/stretchr/testify/require" "os" "pipu/api/internal/api/gen" "pipu/api/internal/auth" "pipu/api/internal/platform/db" "testing" ) func TestActivityRoomsAndChatPrivacy(t *testing.T) { url := os.Getenv("TEST_DATABASE_URL") if url == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, e := db.Connect(ctx, url) require.NoError(t, e) defer pool.Close() require.NoError(t, db.Migrate(ctx, pool)) s := &Store{pool: pool} h := &Handler{store: s} owner, friend, other := uuid.New(), uuid.New(), uuid.New() users := []uuid.UUID{owner, friend, other} for _, id := range users { _, e = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash) VALUES($1,$2,'x')`, id, id.String()) require.NoError(t, e) } defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, users) for _, id := range users { require.NoError(t, s.markPresent(ctx, id)) } artist, album, track, playlist, device := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New() _, e = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String()) require.NoError(t, e) defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist) _, e = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album',$2,$3)`, album, album.String(), artist) require.NoError(t, e) defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album) _, e = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'Track',$2,$3,180000)`, track, track.String(), album) require.NoError(t, e) defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=$1`, track) _, e = pool.Exec(ctx, `INSERT INTO playlists(id,user_id,name) VALUES($1,$2,'Private playlist')`, playlist, owner) require.NoError(t, e) activity := gen.ListeningActivityInput{DeviceId: device, Sequence: 1, Playing: true, TrackId: &track, Context: "playlist", PlaylistId: &playlist} require.NoError(t, s.publishActivity(ctx, owner, activity)) var count int require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM listening_activity WHERE user_id=$1`, owner).Scan(&count)) require.Zero(t, count) _, e = pool.Exec(ctx, `UPDATE users SET activity_visibility='friends' WHERE id=$1`, owner) require.NoError(t, e) require.NoError(t, s.publishActivity(ctx, owner, activity)) hidden, e := s.activities(ctx, friend, []uuid.UUID{owner}) require.NoError(t, e) require.Empty(t, hidden) require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "request", TargetId: friend})) require.NoError(t, s.action(ctx, friend, gen.SocialAction{Action: "accept", TargetId: owner})) visible, e := s.activities(ctx, friend, []uuid.UUID{owner}) require.NoError(t, e) require.Equal(t, &playlist, visible[owner].PlaylistId) hidden, e = s.activities(ctx, other, []uuid.UUID{owner}) require.NoError(t, e) require.Empty(t, hidden) activity.Sequence = 3 activity.Playing = false activity.Context = "other" activity.PlaylistId = nil activity.TrackId = nil require.NoError(t, s.publishActivity(ctx, owner, activity)) activity.Sequence = 2 activity.Playing = true activity.Context = "playlist" activity.PlaylistId = &playlist activity.TrackId = &track require.NoError(t, s.publishActivity(ctx, owner, activity)) hidden, e = s.activities(ctx, friend, []uuid.UUID{owner}) require.NoError(t, e) require.Empty(t, hidden) activity.Sequence = 4 require.NoError(t, s.publishActivity(ctx, owner, activity)) join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner} require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody _, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner) require.NoError(t, e) require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner})) require.NoError(t, s.listeningAction(ctx, friend, join)) state, e := s.listeningState(ctx, friend) require.NoError(t, e) require.NotNil(t, state.Room) require.Equal(t, owner, state.Room.OwnerId) require.Len(t, state.Room.Participants, 2) hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner}) badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend}) input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000} _, e = h.PublishListeningState(badctx, gen.PublishListeningStateRequestObject{Body: &input}) require.Error(t, e) _, e = h.PublishListeningState(hostctx, gen.PublishListeningStateRequestObject{Body: &input}) require.NoError(t, e) require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "end", TargetId: state.Room.Id})) state, e = s.listeningState(ctx, friend) require.NoError(t, e) require.Nil(t, state.Room) _, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone',listening_auto_accept='nobody' WHERE id=$1`, other) require.NoError(t, e) invite := gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track} require.NoError(t, s.listeningAction(ctx, owner, invite)) state, e = s.listeningState(ctx, other) require.NoError(t, e) require.Len(t, state.Requests, 1) require.Nil(t, state.Room) require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) state, e = s.listeningState(ctx, other) require.NoError(t, e) require.Equal(t, owner, state.Room.OwnerId) _, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id) require.NoError(t, e) state, e = s.listeningState(ctx, other) require.NoError(t, e) require.Nil(t, state.Room) // Chat: ownership, replies, reaction toggles, content removal and conversation search. message, e := s.send(ctx, owner, friend, gen.SendDirectMessage{Id: uuid.New(), Text: "First message", AttachmentKind: "none"}) require.NoError(t, e) reply, e := s.send(ctx, friend, owner, gen.SendDirectMessage{Id: uuid.New(), Text: "Reply", AttachmentKind: "none", ReplyTo: &message.Id}) require.NoError(t, e) edited := "Edited message" require.Error(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited})) require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited})) reaction := gen.UpdateChatMessageJSONBodyEmoji("🔥") require.NoError(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "react", Emoji: &reaction})) history, e := s.messages(ctx, owner, friend, nil) require.NoError(t, e) require.Len(t, *history[0].Reactions, 1) require.Equal(t, "Edited message", *history[1].ReplyPreview) matches, e := s.messagePage(ctx, friend, owner, nil, "Edited") require.NoError(t, e) require.Len(t, matches, 1) require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"})) history, e = s.messages(ctx, owner, friend, nil) require.NoError(t, e) require.True(t, *history[0].Deleted) require.Empty(t, history[0].Text) require.Empty(t, *history[0].Reactions) require.Equal(t, reply.ReplyTo, history[1].ReplyTo) require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "block", TargetId: friend})) hidden, e = s.activities(ctx, friend, []uuid.UUID{owner}) require.NoError(t, e) require.Empty(t, hidden) _, e = h.GetChatMeta(badctx, gen.GetChatMetaRequestObject{PeerId: owner}) require.Error(t, e) }