mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
47832632fd
* fix(cover): follow connect-URL flips in library cover backfill The native cover backfill was configured once with a snapshot of the runtime connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped the sticky connect URL to the public address (playback/UI covers rebuild it per request and followed it), but backfill kept fetching from the now-unreachable local address — flooding the log with "error sending request" failures. Make the connect cache observable (notify on effective flips) and have the backfill hook reconfigure when the resolved URL changes, forcing a pass so the .fetch-failed backoff from the stale address is cleared and those covers retry on the reachable endpoint. * docs(changelog): note cover backfill endpoint-switch fix (PR #952) * fix(cover): abort stale backfill pass + rerun on connect-URL flip Frontend reconfigure alone didn't fully cover the boot case: at startup the first backfill pass starts on the primary (LAN) URL before the reachability probe resolves, so when the probe flips to public the forced rerun was dropped by the pass_running guard and the slow LAN pass (every cover timing out) ran to completion with nothing re-running on the reachable address. set_session now bumps a session generation; the running pass checks it on every focus gate and abandons promptly when the URL flips (same server_index_key, new rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight and drains it once the abandoned pass returns, so a fresh forced pass runs on the new address. * fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue The backfill worklist no longer carries a URL. Each cover fetch reads the current reachable address live from a single worker cell, so a LAN↔public flip is honoured even by the pass already in flight — its remaining covers download against the new endpoint without aborting/rebuilding the worklist. - Drop rest_base_url from CoverBackfillSession; add live base_url cell read in ensure_one. Remove the session-generation abort machinery (no longer needed). - New lightweight library_cover_backfill_set_base_url command pushes the URL on every connect-cache flip; a real change clears the stale .fetch-failed backoff and runs a forced pass so covers that timed out on the old address retry. - Split useLibraryCoverBackfill into a configure effect (server/creds/strategy) and a flip effect that only pushes the URL. - Keep a single rerun_pending flag for the boot case (flip mid-pass), since the finished pass re-arms the idle gate.
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../api/subsonic', () => ({
|
|
pingWithCredentials: vi.fn(),
|
|
}));
|
|
|
|
import { pingWithCredentials } from '../../api/subsonic';
|
|
import {
|
|
allNormalizedAddresses,
|
|
ensureConnectUrlResolved,
|
|
getCachedConnectBaseUrl,
|
|
invalidateReachableEndpointCache,
|
|
isLanUrl,
|
|
normalizeServerBaseUrl,
|
|
pickReachableBaseUrl,
|
|
serverAddressEndpoints,
|
|
serverShareBaseUrl,
|
|
subscribeConnectCache,
|
|
} from './serverEndpoint';
|
|
import type { ServerProfile } from '../../store/authStoreTypes';
|
|
|
|
function makeProfile(overrides: Partial<ServerProfile> = {}): ServerProfile {
|
|
return {
|
|
id: 'profile-1',
|
|
name: 'Test',
|
|
url: 'https://music.example.com',
|
|
username: 'u',
|
|
password: 'p',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function pingOk(overrides: Partial<{ type: string; serverVersion: string; openSubsonic: boolean }> = {}) {
|
|
return {
|
|
ok: true as const,
|
|
type: 'navidrome',
|
|
serverVersion: '0.55.0',
|
|
openSubsonic: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
function pingFail() {
|
|
return { ok: false as const };
|
|
}
|
|
|
|
describe('normalizeServerBaseUrl', () => {
|
|
it('strips a single trailing slash', () => {
|
|
expect(normalizeServerBaseUrl('https://music.example.com/')).toBe(
|
|
'https://music.example.com',
|
|
);
|
|
});
|
|
|
|
it('prefixes http:// for a bare host', () => {
|
|
expect(normalizeServerBaseUrl('music.example.com')).toBe('http://music.example.com');
|
|
});
|
|
|
|
it('returns empty for empty input', () => {
|
|
expect(normalizeServerBaseUrl('')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('isLanUrl — IPv4', () => {
|
|
it.each([
|
|
'http://localhost',
|
|
'http://localhost:4533',
|
|
'http://musicbox.local',
|
|
'http://127.0.0.1',
|
|
'http://127.5.6.7',
|
|
'http://10.0.0.5',
|
|
'http://192.168.1.10',
|
|
'http://172.16.0.1',
|
|
'http://172.31.255.255',
|
|
])('classifies %s as LAN', url => {
|
|
expect(isLanUrl(url)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'http://172.15.0.1',
|
|
'http://172.32.0.1',
|
|
'https://example.com',
|
|
'https://music.example.com',
|
|
'http://8.8.8.8',
|
|
])('classifies %s as public', url => {
|
|
expect(isLanUrl(url)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isLanUrl — IPv6', () => {
|
|
it.each([
|
|
'http://[::1]',
|
|
'http://[::1]:4533',
|
|
'http://[fe80::1]',
|
|
'http://[fe80::abcd:1]',
|
|
'http://[fc00::1]',
|
|
'http://[fd12:3456:789a::1]',
|
|
'http://[::ffff:127.0.0.1]',
|
|
'http://[::ffff:192.168.0.1]',
|
|
])('classifies %s as LAN', url => {
|
|
expect(isLanUrl(url)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
'http://[2001:db8::1]',
|
|
'http://[::ffff:8.8.8.8]',
|
|
'http://[2606:4700:4700::1111]',
|
|
])('classifies %s as public', url => {
|
|
expect(isLanUrl(url)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isLanUrl — edge cases', () => {
|
|
it('handles bare hosts without scheme', () => {
|
|
expect(isLanUrl('192.168.0.1')).toBe(true);
|
|
expect(isLanUrl('example.com')).toBe(false);
|
|
});
|
|
|
|
it('returns false on empty / malformed', () => {
|
|
expect(isLanUrl('')).toBe(false);
|
|
expect(isLanUrl('not a url at all ')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('allNormalizedAddresses', () => {
|
|
it('returns single entry for profile with only url', () => {
|
|
expect(
|
|
allNormalizedAddresses({ url: 'https://music.example.com' }),
|
|
).toEqual(['https://music.example.com']);
|
|
});
|
|
|
|
it('returns both addresses preserving order', () => {
|
|
expect(
|
|
allNormalizedAddresses({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10:4533',
|
|
}),
|
|
).toEqual(['https://music.example.com', 'http://192.168.0.10:4533']);
|
|
});
|
|
|
|
it('dedupes identical normalized addresses', () => {
|
|
expect(
|
|
allNormalizedAddresses({
|
|
url: 'https://music.example.com/',
|
|
alternateUrl: 'https://music.example.com',
|
|
}),
|
|
).toEqual(['https://music.example.com']);
|
|
});
|
|
|
|
it('drops empty alternateUrl', () => {
|
|
expect(
|
|
allNormalizedAddresses({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: '',
|
|
}),
|
|
).toEqual(['https://music.example.com']);
|
|
});
|
|
});
|
|
|
|
describe('serverAddressEndpoints', () => {
|
|
it('returns a single local endpoint for a LAN-only profile', () => {
|
|
expect(
|
|
serverAddressEndpoints({ url: 'http://192.168.0.10' }),
|
|
).toEqual([{ url: 'http://192.168.0.10', kind: 'local' }]);
|
|
});
|
|
|
|
it('puts LAN before public when public is primary', () => {
|
|
expect(
|
|
serverAddressEndpoints({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
).toEqual([
|
|
{ url: 'http://192.168.0.10', kind: 'local' },
|
|
{ url: 'https://music.example.com', kind: 'public' },
|
|
]);
|
|
});
|
|
|
|
it('keeps LAN-first when LAN is already primary', () => {
|
|
expect(
|
|
serverAddressEndpoints({
|
|
url: 'http://192.168.0.10',
|
|
alternateUrl: 'https://music.example.com',
|
|
}),
|
|
).toEqual([
|
|
{ url: 'http://192.168.0.10', kind: 'local' },
|
|
{ url: 'https://music.example.com', kind: 'public' },
|
|
]);
|
|
});
|
|
|
|
it('preserves original order among endpoints of the same kind', () => {
|
|
expect(
|
|
serverAddressEndpoints({
|
|
url: 'http://10.0.0.5',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
).toEqual([
|
|
{ url: 'http://10.0.0.5', kind: 'local' },
|
|
{ url: 'http://192.168.0.10', kind: 'local' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('pickReachableBaseUrl', () => {
|
|
beforeEach(() => {
|
|
invalidateReachableEndpointCache();
|
|
vi.mocked(pingWithCredentials).mockReset();
|
|
});
|
|
|
|
it('returns the single endpoint when it pings ok and caches it', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
|
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.baseUrl).toBe('https://music.example.com');
|
|
expect(result.endpoint).toEqual({ url: 'https://music.example.com', kind: 'public' });
|
|
expect(result.ping.ok).toBe(true);
|
|
expect(result.ping.type).toBe('navidrome');
|
|
}
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
|
const result = await pickReachableBaseUrl(
|
|
makeProfile({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
|
});
|
|
|
|
it('falls through to the public endpoint when LAN ping fails', async () => {
|
|
vi.mocked(pingWithCredentials)
|
|
.mockResolvedValueOnce(pingFail())
|
|
.mockResolvedValueOnce(pingOk());
|
|
const result = await pickReachableBaseUrl(
|
|
makeProfile({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(2);
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
|
});
|
|
|
|
it('returns unreachable and clears cache when every endpoint fails', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
|
// Seed a stale cache entry first.
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
|
|
|
vi.mocked(pingWithCredentials).mockReset();
|
|
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
|
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
|
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
|
|
});
|
|
|
|
it('returns unreachable when the profile has no usable url', async () => {
|
|
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
|
|
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
|
expect(pingWithCredentials).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
|
|
const profile = makeProfile({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
});
|
|
// First call: LAN responds ok, becomes cached.
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
|
|
|
// Second call: cached URL is tried first; sole ping happens against it.
|
|
vi.mocked(pingWithCredentials).mockClear();
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
const result = await pickReachableBaseUrl(profile);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
|
});
|
|
|
|
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
|
|
// Two callers in the same tick must observe the same promise — without
|
|
// the in-flight map both would ping every endpoint and race on the
|
|
// cache write, with last-write-wins potentially clobbering the correct
|
|
// LAN sticky a millisecond after it was set.
|
|
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
|
|
vi.mocked(pingWithCredentials).mockReturnValueOnce(
|
|
new Promise(r => {
|
|
resolvePing = r;
|
|
}),
|
|
);
|
|
const profile = makeProfile({ url: 'http://192.168.0.10' });
|
|
const p1 = pickReachableBaseUrl(profile);
|
|
const p2 = pickReachableBaseUrl(profile);
|
|
|
|
// Both calls saw a pending probe — only one ping should have been fired.
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
|
|
|
resolvePing!(pingOk());
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
|
|
expect(r1.ok).toBe(true);
|
|
expect(r2.ok).toBe(true);
|
|
if (r1.ok && r2.ok) {
|
|
expect(r1.baseUrl).toBe(r2.baseUrl);
|
|
}
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
|
});
|
|
|
|
it('starts a fresh probe after the in-flight one settles', async () => {
|
|
// Once the previous probe resolves, the in-flight slot is freed and
|
|
// the next call hits the network again (subject to the sticky cache).
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
|
|
|
vi.mocked(pingWithCredentials).mockClear();
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
|
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('falls back to the natural order if the cached endpoint stops answering', async () => {
|
|
const profile = makeProfile({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
});
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
|
|
|
// LAN now fails; public answers.
|
|
vi.mocked(pingWithCredentials).mockClear();
|
|
vi.mocked(pingWithCredentials)
|
|
.mockResolvedValueOnce(pingFail())
|
|
.mockResolvedValueOnce(pingOk());
|
|
const result = await pickReachableBaseUrl(profile);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
|
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
|
});
|
|
});
|
|
|
|
describe('invalidateReachableEndpointCache', () => {
|
|
beforeEach(() => {
|
|
invalidateReachableEndpointCache();
|
|
vi.mocked(pingWithCredentials).mockReset();
|
|
});
|
|
|
|
it('clears a specific profile', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
|
|
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
|
|
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
|
|
|
|
invalidateReachableEndpointCache('a');
|
|
expect(getCachedConnectBaseUrl('a')).toBeNull();
|
|
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
|
|
});
|
|
|
|
it('clears everything when called with no argument', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
|
invalidateReachableEndpointCache();
|
|
expect(getCachedConnectBaseUrl('a')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
|
beforeEach(() => {
|
|
invalidateReachableEndpointCache();
|
|
vi.mocked(pingWithCredentials).mockReset();
|
|
});
|
|
|
|
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeConnectCache(listener);
|
|
const profile = makeProfile({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
});
|
|
|
|
// First probe: LAN answers → cache set → one notification.
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
// LAN drops, public answers → cached URL flips → another notification.
|
|
vi.mocked(pingWithCredentials)
|
|
.mockResolvedValueOnce(pingFail())
|
|
.mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
expect(listener).toHaveBeenCalledTimes(2);
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it('does not notify when the sticky endpoint is unchanged', async () => {
|
|
const profile = makeProfile({ url: 'http://192.168.0.10' });
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeConnectCache(listener);
|
|
// Re-probe, same endpoint answers → cache value identical → no notification.
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(profile);
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it('notifies on explicit cache invalidation when an entry existed', async () => {
|
|
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
|
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
|
|
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeConnectCache(listener);
|
|
invalidateReachableEndpointCache('a');
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
// No-op invalidation (nothing cached) must stay silent.
|
|
invalidateReachableEndpointCache('a');
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
describe('serverShareBaseUrl', () => {
|
|
it('returns the single address for a single-URL profile', () => {
|
|
expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe(
|
|
'https://music.example.com',
|
|
);
|
|
});
|
|
|
|
it('falls back to a normalized url even when empty', () => {
|
|
// Defensive — never throws; downstream consumers tolerate the empty string.
|
|
expect(serverShareBaseUrl({ url: '' })).toBe('');
|
|
});
|
|
|
|
it('prefers the public address by default when both are set', () => {
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
).toBe('https://music.example.com');
|
|
});
|
|
|
|
it('still prefers public when the LAN address is the primary', () => {
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'http://192.168.0.10',
|
|
alternateUrl: 'https://music.example.com',
|
|
}),
|
|
).toBe('https://music.example.com');
|
|
});
|
|
|
|
it('returns the LAN address when shareUsesLocalUrl is true', () => {
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
shareUsesLocalUrl: true,
|
|
}),
|
|
).toBe('http://192.168.0.10');
|
|
});
|
|
|
|
it('falls back to the first endpoint when no LAN exists and flag is set', () => {
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'https://music-alt.example.com',
|
|
shareUsesLocalUrl: true,
|
|
}),
|
|
).toBe('https://music.example.com');
|
|
});
|
|
|
|
it('falls back to the first endpoint when both are LAN and flag is off', () => {
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'http://10.0.0.5',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
}),
|
|
).toBe('http://10.0.0.5');
|
|
});
|
|
|
|
it('returns the first LAN endpoint when both are LAN and flag is on', () => {
|
|
// Two LAN addresses + flag set: spec §5.1 says "local ?? endpoints[0]".
|
|
// `find(isLanUrl)` returns the first LAN, which is endpoints[0] either
|
|
// way — pin the test so future refactors don't accidentally drift.
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'http://10.0.0.5',
|
|
alternateUrl: 'http://192.168.0.10',
|
|
shareUsesLocalUrl: true,
|
|
}),
|
|
).toBe('http://10.0.0.5');
|
|
});
|
|
|
|
it('returns the first endpoint when both are public and flag is off', () => {
|
|
// Reverse case: two publics, no LAN exists, flag default → publicEndpoint
|
|
// matches the first one. Identity, but locks the rule down.
|
|
expect(
|
|
serverShareBaseUrl({
|
|
url: 'https://music.example.com',
|
|
alternateUrl: 'https://backup.example.com',
|
|
}),
|
|
).toBe('https://music.example.com');
|
|
});
|
|
});
|