mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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,207 @@
|
||||
/**
|
||||
* Subsonic API contract tests (Phase F3).
|
||||
*
|
||||
* Pins the URL-builder shapes (`buildStreamUrl`, `buildCoverArtUrl`,
|
||||
* `buildDownloadUrl`, `coverArtCacheKey`) plus the small pure parsers
|
||||
* (`parseSubsonicEntityStarRating`, `libraryFilterParams`,
|
||||
* `getClient`) — the surface the rest of the frontend uses to talk to
|
||||
* Subsonic / Navidrome.
|
||||
*
|
||||
* Network-bound endpoints (`getAlbum`, `search`, etc.) require axios
|
||||
* mocking and are not in this PR.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildCoverArtUrl,
|
||||
buildDownloadUrl,
|
||||
buildStreamUrl,
|
||||
coverArtCacheKey,
|
||||
getClient,
|
||||
libraryFilterParams,
|
||||
parseSubsonicEntityStarRating,
|
||||
} from './subsonic';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { resetAuthStore } from '@/test/helpers/storeReset';
|
||||
|
||||
function setUpServer(overrides: { url?: string; username?: string; password?: string } = {}): string {
|
||||
const id = useAuthStore.getState().addServer({
|
||||
name: 'Test',
|
||||
url: overrides.url ?? 'https://music.example.com',
|
||||
username: overrides.username ?? 'alice',
|
||||
password: overrides.password ?? 'pw',
|
||||
});
|
||||
useAuthStore.getState().setActiveServer(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthStore();
|
||||
});
|
||||
|
||||
describe('parseSubsonicEntityStarRating', () => {
|
||||
it('reads userRating first, falls back to rating', () => {
|
||||
expect(parseSubsonicEntityStarRating({ userRating: 4 })).toBe(4);
|
||||
expect(parseSubsonicEntityStarRating({ rating: 3 })).toBe(3);
|
||||
expect(parseSubsonicEntityStarRating({ userRating: 5, rating: 2 })).toBe(5);
|
||||
});
|
||||
|
||||
it('coerces numeric strings', () => {
|
||||
expect(parseSubsonicEntityStarRating({ userRating: '4' as unknown as number })).toBe(4);
|
||||
expect(parseSubsonicEntityStarRating({ rating: '3.5' as unknown as number })).toBe(3.5);
|
||||
});
|
||||
|
||||
it('returns undefined for null / undefined / non-numeric values', () => {
|
||||
expect(parseSubsonicEntityStarRating({})).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ userRating: null as unknown as number })).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ rating: 'nope' as unknown as number })).toBeUndefined();
|
||||
expect(parseSubsonicEntityStarRating({ userRating: Number.NaN })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryFilterParams', () => {
|
||||
it('returns an empty object when no server is active', () => {
|
||||
expect(libraryFilterParams()).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an empty object when the filter is "all" or unset', () => {
|
||||
setUpServer();
|
||||
expect(libraryFilterParams()).toEqual({});
|
||||
});
|
||||
|
||||
it('returns { musicFolderId } when the active server has a specific filter', () => {
|
||||
const serverId = setUpServer();
|
||||
useAuthStore.setState({
|
||||
musicLibraryFilterByServer: { [serverId]: 'mf-7' },
|
||||
});
|
||||
expect(libraryFilterParams()).toEqual({ musicFolderId: 'mf-7' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClient', () => {
|
||||
it('throws when no server is configured', () => {
|
||||
expect(() => getClient()).toThrow(/no server configured/i);
|
||||
});
|
||||
|
||||
it('returns baseUrl + auth params shape from the active server', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const { baseUrl, params } = getClient();
|
||||
expect(baseUrl).toBe('https://music.example.com/rest');
|
||||
expect(params).toMatchObject({
|
||||
u: 'alice',
|
||||
v: '1.16.1',
|
||||
f: 'json',
|
||||
});
|
||||
expect(params.c).toMatch(/^psysonic\//);
|
||||
expect(typeof params.t).toBe('string');
|
||||
expect(params.t).toHaveLength(32); // md5 hex
|
||||
expect(typeof params.s).toBe('string');
|
||||
expect((params.s as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rotates token + salt across calls (random per request)', () => {
|
||||
setUpServer();
|
||||
const a = getClient();
|
||||
const b = getClient();
|
||||
expect(a.params.t).not.toBe(b.params.t);
|
||||
expect(a.params.s).not.toBe(b.params.s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverArtCacheKey', () => {
|
||||
it('uses serverId + entity id + size as a stable cache key', () => {
|
||||
const id = setUpServer();
|
||||
expect(coverArtCacheKey('cover-1')).toBe(`${id}:cover:cover-1:256`);
|
||||
expect(coverArtCacheKey('cover-1', 200)).toBe(`${id}:cover:cover-1:200`);
|
||||
});
|
||||
|
||||
it('falls back to "_" as the server-id segment when no server is active', () => {
|
||||
expect(coverArtCacheKey('cover-99')).toBe('_:cover:cover-99:256');
|
||||
});
|
||||
|
||||
it('does not embed the ephemeral salt or token — keys stay cacheable across calls', () => {
|
||||
setUpServer();
|
||||
const a = coverArtCacheKey('art-1', 256);
|
||||
const b = coverArtCacheKey('art-1', 256);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStreamUrl', () => {
|
||||
it('returns a /rest/stream.view URL on the configured base URL', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const url = new URL(buildStreamUrl('track-1'));
|
||||
expect(url.origin).toBe('https://music.example.com');
|
||||
expect(url.pathname).toBe('/rest/stream.view');
|
||||
});
|
||||
|
||||
it('carries the stable Subsonic auth params (id, u, t, s, v, c, f)', () => {
|
||||
setUpServer({ url: 'https://music.example.com', username: 'alice', password: 'pw' });
|
||||
const url = new URL(buildStreamUrl('track-1'));
|
||||
expect(url.searchParams.get('id')).toBe('track-1');
|
||||
expect(url.searchParams.get('u')).toBe('alice');
|
||||
expect(url.searchParams.get('v')).toBe('1.16.1');
|
||||
expect(url.searchParams.get('f')).toBe('json');
|
||||
expect(url.searchParams.get('c')?.startsWith('psysonic/')).toBe(true);
|
||||
expect(url.searchParams.get('t')).toHaveLength(32); // md5 hex
|
||||
expect(url.searchParams.get('s')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rotates t/s across calls — Rust matches playback identity by id, not by URL', () => {
|
||||
setUpServer();
|
||||
const a = new URL(buildStreamUrl('track-1'));
|
||||
const b = new URL(buildStreamUrl('track-1'));
|
||||
expect(a.searchParams.get('id')).toBe(b.searchParams.get('id'));
|
||||
expect(a.searchParams.get('t')).not.toBe(b.searchParams.get('t'));
|
||||
expect(a.searchParams.get('s')).not.toBe(b.searchParams.get('s'));
|
||||
});
|
||||
|
||||
it('URL-encodes ids with special characters once (not double-encoded)', () => {
|
||||
setUpServer();
|
||||
const id = 'AC/DC — Back in Black';
|
||||
const url = new URL(buildStreamUrl(id));
|
||||
expect(url.searchParams.get('id')).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCoverArtUrl', () => {
|
||||
it('returns a /rest/getCoverArt.view URL with size param', () => {
|
||||
setUpServer({ url: 'https://music.example.com' });
|
||||
const url = new URL(buildCoverArtUrl('cover-1', 512));
|
||||
expect(url.pathname).toBe('/rest/getCoverArt.view');
|
||||
expect(url.searchParams.get('id')).toBe('cover-1');
|
||||
expect(url.searchParams.get('size')).toBe('512');
|
||||
});
|
||||
|
||||
it('defaults size to 256 when omitted', () => {
|
||||
setUpServer();
|
||||
const url = new URL(buildCoverArtUrl('cover-1'));
|
||||
expect(url.searchParams.get('size')).toBe('256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownloadUrl', () => {
|
||||
it('returns a /rest/download.view URL with id + auth params', () => {
|
||||
setUpServer({ url: 'https://music.example.com' });
|
||||
const url = new URL(buildDownloadUrl('track-7'));
|
||||
expect(url.pathname).toBe('/rest/download.view');
|
||||
expect(url.searchParams.get('id')).toBe('track-7');
|
||||
expect(url.searchParams.get('v')).toBe('1.16.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL builders — trailing-slash + scheme handling on base URL', () => {
|
||||
it('strips trailing slash from base URL (getBaseUrl normalises)', () => {
|
||||
setUpServer({ url: 'https://music.example.com/' });
|
||||
const url = new URL(buildStreamUrl('t1'));
|
||||
expect(url.origin).toBe('https://music.example.com');
|
||||
// No `//rest` doubled-slash:
|
||||
expect(url.pathname).toBe('/rest/stream.view');
|
||||
});
|
||||
|
||||
it('prepends http:// when the configured URL lacks a scheme', () => {
|
||||
setUpServer({ url: 'music.local' });
|
||||
const url = new URL(buildStreamUrl('t1'));
|
||||
expect(url.protocol).toBe('http:');
|
||||
expect(url.host).toBe('music.local');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user