fix(social): complete shared listening request lifecycle
This commit is contained in:
1 parent
cb22f15912
commit
59983ed1b3
14 files changed
+302
-18
No files matched your search
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
if (!automatic) {
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
expect(api.listeningRequests.size).toBe(1);
|
||||
const request = [...api.listeningRequests.values()][0]!;
|
||||
const room = [...api.listeningRooms.values()][0]!;
|
||||
api.listeningMembers.set(alice.id, room.id);
|
||||
api.listeningRequests.delete(request.id);
|
||||
refreshListening(session);
|
||||
}
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
|
||||
await router.navigate({ to: '/friends' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(bob.id)).toBe(false);
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
|
||||
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
|
||||
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
api.presence.set(bob.id, Date.now());
|
||||
api.activityVisibility.set(bob.id, 'friends');
|
||||
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
|
||||
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
|
||||
expect(api.listeningRequests.size).toBe(0);
|
||||
expect(api.listeningRooms.size).toBe(0);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables listening together only while a friend is online, independently of music privacy', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
|
||||
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
|
||||
const { api, key } = useListening(); const state = usePlayerState();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||
const [id] = useState(uuidv7);
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||
|
||||
@@ -40,7 +40,7 @@ function fixture() {
|
||||
expect(overlap.volume).toBeGreaterThan(0);
|
||||
};
|
||||
const follow = async () => {
|
||||
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } };
|
||||
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
|
||||
player.start();
|
||||
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fakeApi, makeUser } from './fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser } from './fakeApi';
|
||||
import { mobileFixtures } from './mobileFixtures';
|
||||
|
||||
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
|
||||
@@ -14,6 +14,21 @@ export function installE2EFetch() {
|
||||
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
|
||||
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
|
||||
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
|
||||
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
|
||||
if (listeningScenario) {
|
||||
const device = crypto.randomUUID();
|
||||
api.presence.set(friend.id, Date.now());
|
||||
api.activityVisibility.set(friend.id, 'friends');
|
||||
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
|
||||
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
|
||||
if (listeningScenario === 'incoming') {
|
||||
const roomId = crypto.randomUUID();
|
||||
const requestId = crypto.randomUUID();
|
||||
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(friend.id, roomId);
|
||||
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
|
||||
}
|
||||
}
|
||||
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
|
||||
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
|
||||
for (let index = 0; index < 28; index++) api.notifications.push({
|
||||
|
||||
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
if(op==='join'||op==='invite'){
|
||||
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
|
||||
const friend=!!friendships.get(pair(user.id,target))?.accepted;
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable');
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
|
||||
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
|
||||
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
|
||||
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
|
||||
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
|
||||
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
|
||||
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
|
||||
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
|
||||
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
|
||||
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
|
||||
|
||||
Reference in new issue
Block a user