mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
8cc022581f
Move the 5 subsonic api modules that M3 had co-located into features (subsonicAlbumInfo/Artists/Playlists/Radio/Statistics) into src/lib/api/, the feature-free infra layer, and drop their feature-barrel re-exports. Consumers now import these protocol calls directly from @/lib/api/subsonic*; the feature barrels export only domain UI/hooks/state. This is the consistent api placement (plan §10.3, revised: ALL subsonic protocol clients are feature-free infra, not per-feature) and it kills the documented api-induced feature<->feature cycles: offline->artist/playlist (getArtist/ getPlaylist*), album->artist (getArtistInfo), deviceSync/nowPlaying/orbit->*. Remaining artist<->album refs are UI-only (OpenArtistRefInline/coerceOpenArtist Refs) and offline->playlist is store-level (usePlaylistStore) — both deeper, tracked for M5, not api placement. Pure move: import specifiers + vi.mock/spy targets repointed; no behaviour change. tsc 0, lint 0/0, full suite 319 files / 2353 tests green. Tooling note: barrel-imported and dynamic-import api symbols were split out of @/features/* to @/lib/api/* (tsc-driven); 12 vi.mock barrels retargeted to the deep lib module (mock-collapse sweep), AlbumHeader's UI mock left untouched.
264 lines
7.8 KiB
TypeScript
264 lines
7.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { TFunction } from 'i18next';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
authState: {
|
|
current: {
|
|
servers: [] as Array<{ id: string; name: string; url: string; username: string; password: string }>,
|
|
isLoggedIn: true,
|
|
activeServerId: 'active',
|
|
setActiveServer: vi.fn(),
|
|
},
|
|
},
|
|
enqueue: vi.fn(),
|
|
getAlbum: vi.fn(),
|
|
getAlbumWithCredentials: vi.fn(),
|
|
getArtist: vi.fn(),
|
|
getArtistWithCredentials: vi.fn(),
|
|
getSong: vi.fn(),
|
|
getSongWithCredentials: vi.fn(),
|
|
orbitBulkGuard: vi.fn(),
|
|
showToast: vi.fn(),
|
|
songToTrack: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
|
getAlbum: mocks.getAlbum,
|
|
getSong: mocks.getSong,
|
|
}));
|
|
|
|
vi.mock('@/lib/api/subsonicArtists', () => ({
|
|
getArtist: mocks.getArtist,
|
|
}));
|
|
|
|
vi.mock('@/lib/api/subsonicEntityWithCredentials', () => ({
|
|
getAlbumWithCredentials: mocks.getAlbumWithCredentials,
|
|
getArtistWithCredentials: mocks.getArtistWithCredentials,
|
|
getSongWithCredentials: mocks.getSongWithCredentials,
|
|
}));
|
|
|
|
vi.mock('../../store/authStore', () => ({
|
|
useAuthStore: {
|
|
getState: () => mocks.authState.current,
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../store/playerStore', () => ({
|
|
usePlayerStore: {
|
|
getState: () => ({ enqueue: mocks.enqueue }),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../playback/songToTrack', () => ({
|
|
songToTrack: mocks.songToTrack,
|
|
}));
|
|
|
|
vi.mock('@/features/orbit', () => ({
|
|
orbitBulkGuard: mocks.orbitBulkGuard,
|
|
}));
|
|
|
|
vi.mock('../ui/toast', () => ({
|
|
showToast: mocks.showToast,
|
|
}));
|
|
|
|
import {
|
|
activateShareSearchServer,
|
|
enqueueShareSearchPayload,
|
|
resolveShareSearchAlbum,
|
|
resolveShareSearchArtist,
|
|
resolveShareSearchPayload,
|
|
} from './enqueueShareSearchPayload';
|
|
|
|
const sharedServer = {
|
|
id: 'shared',
|
|
name: 'Shared',
|
|
url: 'https://shared.example.com',
|
|
username: 'shared-user',
|
|
password: 'shared-pass',
|
|
};
|
|
|
|
const activeServer = {
|
|
id: 'active',
|
|
name: 'Active',
|
|
url: 'https://active.example.com',
|
|
username: 'active-user',
|
|
password: 'active-pass',
|
|
};
|
|
|
|
const sharedSong = {
|
|
id: 'song-1',
|
|
title: 'Shared Song',
|
|
artist: 'Shared Artist',
|
|
album: 'Shared Album',
|
|
albumId: 'album-1',
|
|
duration: 180,
|
|
minutesAgo: 0,
|
|
playerId: 0,
|
|
playerName: '',
|
|
};
|
|
|
|
describe('share search payload resolution', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.authState.current = {
|
|
servers: [activeServer, sharedServer],
|
|
isLoggedIn: true,
|
|
activeServerId: 'active',
|
|
setActiveServer: vi.fn(),
|
|
};
|
|
mocks.getSongWithCredentials.mockResolvedValue(sharedSong);
|
|
mocks.getAlbumWithCredentials.mockResolvedValue({
|
|
album: { id: 'album-1', name: 'Shared Album', artist: 'Shared Artist' },
|
|
songs: [],
|
|
});
|
|
mocks.getArtistWithCredentials.mockResolvedValue({
|
|
artist: { id: 'artist-1', name: 'Shared Artist' },
|
|
albums: [],
|
|
});
|
|
mocks.getSong.mockResolvedValue(sharedSong);
|
|
mocks.songToTrack.mockImplementation(song => ({ id: song.id, title: song.title }));
|
|
mocks.orbitBulkGuard.mockResolvedValue(true);
|
|
});
|
|
|
|
it('resolves a shared track preview with explicit credentials without switching active server', async () => {
|
|
const result = await resolveShareSearchPayload({
|
|
srv: 'https://shared.example.com',
|
|
k: 'track',
|
|
id: 'song-1',
|
|
});
|
|
|
|
expect(result).toEqual({ type: 'ok', songs: [sharedSong], total: 1, skipped: 0 });
|
|
expect(mocks.getSongWithCredentials).toHaveBeenCalledWith(
|
|
sharedServer.url,
|
|
sharedServer.username,
|
|
sharedServer.password,
|
|
'song-1',
|
|
sharedServer,
|
|
);
|
|
expect(mocks.getSong).not.toHaveBeenCalled();
|
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves album and artist previews without switching active server', async () => {
|
|
await resolveShareSearchAlbum({ srv: 'https://shared.example.com', k: 'album', id: 'album-1' });
|
|
await resolveShareSearchArtist({ srv: 'https://shared.example.com', k: 'artist', id: 'artist-1' });
|
|
|
|
expect(mocks.getAlbumWithCredentials).toHaveBeenCalledWith(
|
|
sharedServer.url,
|
|
sharedServer.username,
|
|
sharedServer.password,
|
|
'album-1',
|
|
sharedServer,
|
|
);
|
|
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
|
sharedServer.url,
|
|
sharedServer.username,
|
|
sharedServer.password,
|
|
'artist-1',
|
|
sharedServer,
|
|
);
|
|
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
|
expect(mocks.getArtist).not.toHaveBeenCalled();
|
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves composer previews via artist credentials without switching active server', async () => {
|
|
const result = await resolveShareSearchArtist({
|
|
srv: 'https://shared.example.com',
|
|
k: 'composer',
|
|
id: 'composer-1',
|
|
});
|
|
|
|
expect(result.type).toBe('ok');
|
|
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
|
sharedServer.url,
|
|
sharedServer.username,
|
|
sharedServer.password,
|
|
'composer-1',
|
|
sharedServer,
|
|
);
|
|
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns not-logged-in without calling the API', async () => {
|
|
mocks.authState.current.isLoggedIn = false;
|
|
|
|
const result = await resolveShareSearchPayload({
|
|
srv: 'https://shared.example.com',
|
|
k: 'track',
|
|
id: 'song-1',
|
|
});
|
|
|
|
expect(result).toEqual({ type: 'not-logged-in' });
|
|
expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('activates the share server for confirmed enqueue actions', async () => {
|
|
const t = ((key: string) => key) as TFunction;
|
|
const ok = await enqueueShareSearchPayload({
|
|
srv: 'https://shared.example.com',
|
|
k: 'track',
|
|
id: 'song-1',
|
|
}, t);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
|
|
expect(mocks.getSong).toHaveBeenCalledWith('song-1');
|
|
expect(mocks.getSongWithCredentials).not.toHaveBeenCalled();
|
|
expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
|
|
});
|
|
|
|
it('aborts enqueue when orbitBulkGuard rejects the bulk add', async () => {
|
|
mocks.orbitBulkGuard.mockResolvedValue(false);
|
|
const t = ((key: string) => key) as TFunction;
|
|
|
|
const ok = await enqueueShareSearchPayload({
|
|
srv: 'https://shared.example.com',
|
|
k: 'track',
|
|
id: 'song-1',
|
|
}, t);
|
|
|
|
expect(ok).toBe(false);
|
|
expect(mocks.enqueue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports partial queue enqueue with a partial toast', async () => {
|
|
mocks.getSong.mockImplementation((id: string) =>
|
|
id === 'song-1' ? Promise.resolve(sharedSong) : Promise.resolve(null),
|
|
);
|
|
const t = ((key: string, opts?: Record<string, unknown>) =>
|
|
opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction;
|
|
|
|
const ok = await enqueueShareSearchPayload({
|
|
srv: 'https://shared.example.com',
|
|
k: 'queue',
|
|
ids: ['song-1', 'missing'],
|
|
}, t);
|
|
|
|
expect(ok).toBe(true);
|
|
expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true);
|
|
expect(mocks.showToast).toHaveBeenCalledWith(
|
|
expect.stringContaining('search.shareQueuedPartial'),
|
|
5000,
|
|
'info',
|
|
);
|
|
});
|
|
|
|
it('activateShareSearchServer switches server when lookup succeeds', () => {
|
|
const t = ((key: string) => key) as TFunction;
|
|
expect(activateShareSearchServer('https://shared.example.com', t)).toBe(true);
|
|
expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared');
|
|
expect(mocks.showToast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('activateShareSearchServer toasts when no matching server exists', () => {
|
|
const t = ((key: string) => key) as TFunction;
|
|
expect(activateShareSearchServer('https://unknown.example.com', t)).toBe(false);
|
|
expect(mocks.showToast).toHaveBeenCalledWith(
|
|
'sharePaste.noMatchingServer',
|
|
6000,
|
|
'error',
|
|
);
|
|
});
|
|
});
|