Files
pipu-music/services/api/internal/social/features_integration_test.go
T

214 lines
11 KiB
Go

//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='nobody' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, friend, join))
pendingJoin, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, pendingJoin.Room, "sending a request is not admission")
require.Len(t, pendingJoin.Requests, 1)
pendingHost, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.Len(t, pendingHost.Room.Participants, 1)
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: join.Id}))
join.Id = uuid.New()
_, 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)
// Reopening the dialog must not create another pending request for an
// already admitted listener, even with a new action ID.
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Empty(t, state.Requests)
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)
reserved, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.NotNil(t, reserved.Room)
require.Len(t, reserved.Room.Participants, 1, "pending invite must not admit the guest")
require.NoError(t, s.listeningAction(ctx, owner, invite), "network retry is idempotent")
duplicate := invite
duplicate.Id = uuid.New()
require.ErrorContains(t, s.listeningAction(ctx, owner, duplicate), "listening_pending")
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE listening_requests SET expires_at=now()-interval '1 second' WHERE id=$1`, invite.Id)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_expired")
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "decline", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='nobody' WHERE id=$1`, other)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_unavailable")
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone' WHERE id=$1`, other)
require.NoError(t, e)
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)
require.Len(t, state.Room.Participants, 2)
require.ErrorContains(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}), "listening_busy")
_, 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)
}