mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating first then rating fallback, numeric-string coercion, undefined for null / NaN / non-numeric), libraryFilterParams (empty without active server, empty on "all" filter, returns musicFolderId on specific filter), getClient (throws without a server, returns baseUrl + auth params, rotates token + salt across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback without active server, no ephemeral salt embedded -- stays cacheable), buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1 c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special character ids encoded once not twice), buildCoverArtUrl (default size=256), buildDownloadUrl (download.view path), trailing-slash + scheme handling on base URL. resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream (first priority wins even when later sources also have the track), forwards trackId + serverId to both stores. getPlaybackSourceKind for offline / hot / stream / engine-preload-hint cases. streamUrlTrackId parser (id from stream.view query, null for non-stream URLs / no query / missing id, decodes URL-encoded ids, manual-query fallback for relative paths). copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that round-trips, returns false without an active server, returns false on empty / whitespace id, trims surrounding whitespace before encoding, propagates clipboard-failure return. Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) + src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out -- the URL-builder + parser surface this PR covers is the structural part; the async API endpoints need axios mocking, deferred to a follow-up. authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments updated to reflect F2 + F1 actuals.
This commit is contained in:
committed by
GitHub
parent
ae23bf61eb
commit
d2898ebaf6
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `copyEntityShareLink` composition tests (Phase F3).
|
||||
*
|
||||
* Thin wrapper around `getBaseUrl` + `encodeSharePayload` +
|
||||
* `copyTextToClipboard` — tests pin its trimming + guard semantics so a
|
||||
* refactor doesn't silently make empty-server / empty-id calls write
|
||||
* garbage to the clipboard.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { copyEntityShareLink } from './copyEntityShareLink';
|
||||
import {
|
||||
decodeSharePayloadFromText,
|
||||
PSYSONIC_SHARE_PREFIX,
|
||||
} from './shareLink';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
// The clipboard mock from src/test/mocks/browser.ts is installed globally;
|
||||
// each test starts with a clean writeText state via resetBrowserMocks.
|
||||
vi.mocked(navigator.clipboard.writeText).mockResolvedValue();
|
||||
});
|
||||
|
||||
describe('copyEntityShareLink', () => {
|
||||
it('writes a psysonic2-prefixed payload that round-trips to the original entity', async () => {
|
||||
const sid = useAuthStore.getState().addServer({
|
||||
name: 'Home', url: 'https://music.example.com', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(sid);
|
||||
|
||||
const ok = await copyEntityShareLink('track', 'tr-1');
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
const written = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] as string;
|
||||
expect(written.startsWith(PSYSONIC_SHARE_PREFIX)).toBe(true);
|
||||
expect(decodeSharePayloadFromText(written)).toEqual({
|
||||
srv: 'https://music.example.com',
|
||||
k: 'track',
|
||||
id: 'tr-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when no server is active (empty base URL)', async () => {
|
||||
// No addServer / setActiveServer → getBaseUrl() returns '' → bail.
|
||||
const ok = await copyEntityShareLink('album', 'al-1');
|
||||
expect(ok).toBe(false);
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false on empty or whitespace-only id', async () => {
|
||||
const sid = useAuthStore.getState().addServer({
|
||||
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(sid);
|
||||
|
||||
expect(await copyEntityShareLink('artist', '')).toBe(false);
|
||||
expect(await copyEntityShareLink('artist', ' ')).toBe(false);
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the id before encoding', async () => {
|
||||
const sid = useAuthStore.getState().addServer({
|
||||
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(sid);
|
||||
|
||||
await copyEntityShareLink('composer', ' co-9 ');
|
||||
|
||||
const written = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] as string;
|
||||
expect(decodeSharePayloadFromText(written)).toEqual({
|
||||
srv: 'https://x.test',
|
||||
k: 'composer',
|
||||
id: 'co-9',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates a clipboard-failure return value (false)', async () => {
|
||||
const sid = useAuthStore.getState().addServer({
|
||||
name: 'Home', url: 'https://x.test', username: 'u', password: 'p',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(sid);
|
||||
|
||||
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('blocked'));
|
||||
// execCommand fallback also fails.
|
||||
const originalExec = document.execCommand;
|
||||
document.execCommand = vi.fn(() => false) as unknown as typeof document.execCommand;
|
||||
|
||||
const ok = await copyEntityShareLink('album', 'al-1');
|
||||
expect(ok).toBe(false);
|
||||
|
||||
document.execCommand = originalExec;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `resolvePlaybackUrl` precedence + `streamUrlTrackId` parser tests (Phase F3).
|
||||
*
|
||||
* Precedence pinned by the function: offline → hot cache → HTTP stream.
|
||||
* Refactors that reorder this break playback for users with offline /
|
||||
* hot-cache entries.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useOfflineStore } from '@/store/offlineStore';
|
||||
import { useHotCacheStore } from '@/store/hotCacheStore';
|
||||
|
||||
import {
|
||||
getPlaybackSourceKind,
|
||||
resolvePlaybackUrl,
|
||||
streamUrlTrackId,
|
||||
} from './resolvePlaybackUrl';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
// Reset the offline + hot-cache store getLocalUrl mocks before each test.
|
||||
vi.spyOn(useOfflineStore.getState(), 'getLocalUrl').mockReturnValue(null);
|
||||
vi.spyOn(useHotCacheStore.getState(), 'getLocalUrl').mockReturnValue(null);
|
||||
// Set up an active server so buildStreamUrl works.
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Test', url: 'https://music.example.com', username: 'alice', password: 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
});
|
||||
|
||||
describe('resolvePlaybackUrl — precedence', () => {
|
||||
it('returns the offline URL when present (1st priority)', () => {
|
||||
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/track-1.flac');
|
||||
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
|
||||
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://offline/track-1.flac');
|
||||
});
|
||||
|
||||
it('falls through to the hot-cache URL when offline is absent (2nd priority)', () => {
|
||||
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue(null);
|
||||
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
|
||||
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://hot/track-1.flac');
|
||||
});
|
||||
|
||||
it('falls through to the HTTP stream URL when neither local source is present', () => {
|
||||
const url = resolvePlaybackUrl('track-1', 'srv-1');
|
||||
expect(url).toMatch(/^https:\/\/music\.example\.com\/rest\/stream\.view\?/);
|
||||
expect(url).toContain('id=track-1');
|
||||
});
|
||||
|
||||
it('forwards trackId + serverId to both stores so per-server entries scope correctly', () => {
|
||||
resolvePlaybackUrl('track-7', 'srv-3');
|
||||
expect(useOfflineStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
|
||||
expect(useHotCacheStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlaybackSourceKind', () => {
|
||||
it('returns "offline" when the offline store has the track', () => {
|
||||
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/t1.flac');
|
||||
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('offline');
|
||||
});
|
||||
|
||||
it('returns "hot" when only the hot-cache has the track', () => {
|
||||
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/t1.flac');
|
||||
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('hot');
|
||||
});
|
||||
|
||||
it('returns "stream" when neither has the track and no engine preload hint matches', () => {
|
||||
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('stream');
|
||||
});
|
||||
|
||||
it('returns "hot" when the engine reported a preload for this trackId (RAM-loaded)', () => {
|
||||
expect(getPlaybackSourceKind('t1', 'srv-1', 't1')).toBe('hot');
|
||||
});
|
||||
|
||||
it('returns "stream" when the engine preload hint is for a different track', () => {
|
||||
expect(getPlaybackSourceKind('t1', 'srv-1', 'other-track')).toBe('stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamUrlTrackId', () => {
|
||||
it('extracts the id query param from a stream.view URL', () => {
|
||||
const url = 'https://music.example.com/rest/stream.view?id=track-1&u=alice&t=hash';
|
||||
expect(streamUrlTrackId(url)).toBe('track-1');
|
||||
});
|
||||
|
||||
it('returns null for URLs that are not stream.view', () => {
|
||||
expect(streamUrlTrackId('https://music.example.com/rest/getCoverArt.view?id=cover')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the URL has no query string', () => {
|
||||
expect(streamUrlTrackId('https://music.example.com/rest/stream.view')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when stream.view URL lacks an id param', () => {
|
||||
expect(streamUrlTrackId('https://music.example.com/rest/stream.view?u=alice')).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes URL-encoded id values', () => {
|
||||
expect(streamUrlTrackId('https://x/rest/stream.view?id=AC%2FDC%20Back')).toBe('AC/DC Back');
|
||||
});
|
||||
|
||||
it('falls back to manual query parsing when URL constructor would throw', () => {
|
||||
// Relative path — `new URL(...)` requires a base, so the function's
|
||||
// manual fallback parses the query directly.
|
||||
const url = '/rest/stream.view?id=relative-track&u=u';
|
||||
expect(streamUrlTrackId(url)).toBe('relative-track');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user