Files
Psychotoxical-psysonic/src/lib/server/serverEndpoint.test.ts
T
cucadmuh 96530f244e feat(servers): connect and run fully behind a custom-header gate (#1273)
* fix(servers): probe gated servers over native reqwest so custom headers connect

Adding a server behind a header gate (Cloudflare Access, Pangolin service
tokens) failed at the connect step (#1216). The connect probe ran in the
WebView (axios); a non-safelisted header such as Authorization / CF-Access-*
makes the browser send a CORS preflight OPTIONS first, and that preflight
carries no token — so the gate rejects it and the real request never leaves.
Streaming and covers already worked because they go through Rust (reqwest),
which never preflights.

Route the header-bearing connect probe through a new Tauri command
`probe_server_connection` that runs the Subsonic ping over native reqwest with
the per-server header context (endpoints + apply rule), reusing the same
resolver as the data plane. Header-less servers keep the lightweight WebView
path unchanged.

- Rust: add `probe_server_connection` (+ `ServerProbeResult`) in app_api/network,
  register in collect_commands!/generate_handler!, regenerate specta bindings.
- Frontend: `pingWithCredentialsForProfile` calls the command when the profile
  has custom headers; add `serverHttpContextWireForProbe` helper.
- Tests: SubsonicClient sends the gate header on ping via http_context (and
  misses the gated matcher without it); frontend probe routing + WebView
  fallback; result mapping unit tests.

* docs(changelog): note gated-server connect fix (#1272)

* fix(servers): keep add form open and surface the reason when connect fails

The add-server flow closed the form the instant you pressed Add and, on
failure, left only a small status dot — so a bad password, a rejected gate
header, or an unreachable host all looked the same ("nothing happened").

- The connect probe now returns a short failure reason (the server's own
  error message, an HTTP status, or a transport error) via ServerProbeResult
  and the axios fallback; no secrets or header values are included.
- ServersTab keeps the Add form open until the connect test succeeds and
  shows the reason in a toast on failure (edit flow surfaces it too).
- AddServerForm validates the server address and username before probing,
  with clear per-field messages instead of a silent no-op.
- New i18n keys (serverUrlRequired, serverUsernameRequired,
  serverConnectFailedReason) added across all shipped locales.

* fix(servers): route all WebView Subsonic REST through native proxy for gated servers

Once a header-gated server (Cloudflare Access, Pangolin, …) connected, every
view that still fetched over axios in the WebView came up empty — Main stage,
New Releases, Random Albums, Statistics, search, genres — because a
non-safelisted gate header makes the WebView send a CORS preflight the gate
rejects. Only the Rust-routed paths (streaming, covers, ping) worked.

- Add a generic `subsonic_proxy_request` Tauri command backed by
  `SubsonicClient::send_raw`: it runs the request natively (no preflight),
  applies the per-server gate header via ServerHttpContext, and returns the
  untouched JSON body for the WebView to parse as it does an axios response.
  Supports GET and form-POST (OpenSubsonic formPost) with a clamped timeout.
- `api`, `apiWithCredentials`, `apiPostFormWithCredentials` (and thus
  `apiForServer`/`apiPostFormForServer`) now switch to the native proxy exactly
  when a request would carry gate headers; header-less servers keep the
  lightweight WebView axios path unchanged.
- Tests: wiremock coverage for `send_raw` (GET gate header + form-POST body);
  frontend routing tests that gated `api()` calls hit the proxy and header-less
  ones stay on axios; updated the OpenSubsonic extensions test for the new path.

* fix(servers): apply gate header to media paths via URL fallback

Streaming, prefetch and artist-info fetches returned 403 on a gated
server: apply_for_http_url resolved custom headers strictly by
server_ref and attached nothing when the caller's ref (e.g. the audio
engine's playback server id) didn't match the registry key. Add a
resolve_context helper that falls back to matching the request URL
against the server's registered endpoints — the same fallback covers
and Navidrome browse already relied on — and route the audio engine,
apply_for_http_url and apply_optional_registry_headers through it.
Endpoint matching only hits a configured gated server and still honours
the apply-to LAN/public rule, so non-gated servers stay untouched.

Also pool the native proxy's reqwest clients by timeout bucket so the
extra gated-server browse traffic reuses keep-alive connections instead
of opening a fresh pool per request (which surfaced as spurious
timeouts / 499s on fast endpoints).

* fix(servers): register gate headers before probe/bind so native paths work

Root cause of the persistent 403s behind a header gate: the native
ServerHttpRegistry was populated only at the end of bindIndexedServer,
after ensureConnectUrlResolved + the bind session. When that probe was
slow, hung, or reported the server briefly offline, the sync never ran,
leaving the registry empty — so every Rust-initiated request (stream,
cover, prefetch, fanart getArtistInfo2, Navidrome /auth/login) resolved
no header and the gate returned 403. The inline-context proxy path kept
working, which is why browse succeeded while media/covers failed.

Register the header context up front: before the reachability probe in
bindIndexedServer, and authoritatively for all servers at the start of
bootstrapAllIndexedServers (once React has mounted and IPC is ready).
Add concise sync/sync_all diagnostics (endpoint URLs + header count,
never values) so gated-server registration is observable in logs.

* fix(servers): retry gated-server covers that 403'd during header-registry gap

Covers fetched while the native gate-header registry was momentarily empty
(e.g. a dev restart before the header sync landed) got a 403, which the cover
fetcher classified as a permanent "cover missing" and wrote a 30-minute
`.fetch-failed` marker for — so on a gated server most artwork stayed blank
long after the gate started answering 200.

- Treat a gate-style 401/403 (plus 408/425/429) on cover art as a transient,
  retryable hiccup rather than a permanent miss, so the short retry loop can
  ride out a brief registry gap. Genuine 404/410/400 stay permanent.
- On (re)bind of a gated server, once its header is registered, clear that
  server's stale `.fetch-failed` markers and kick a backfill pass so the
  covers re-download — same rationale as the URL-change retry.

* refactor(servers): funnel gate-header application through one helper

Collapse the remaining bespoke header-application variants onto the single
`apply_optional_registry_headers` / `resolve_context` entry point so gated-server
header logic lives in exactly one place:

- cover_cache fetch and Navidrome `/auth/login` used older
  `apply_for_http_url` / `get_for_server_url` + `apply_server_headers_for_http_url`
  combos; both now call the shared helper.
- The audio engine's `apply_playback_request_headers` delegates to it instead of
  re-implementing the resolve+apply.
- Drop the now-unused `ServerHttpRegistry::apply_for_http_url` /
  `apply_for_base_url` methods.

Behaviour is unchanged: a non-gated server (no registry match) leaves every
request untouched, exactly as before. This is purely removing duplicate ways to
do the same thing so new native paths have one obvious hook.

* chore(servers): drop server-http registry debug logging

Remove the `[server-http] sync/sync_all` eprintln diagnostics added while
tracing the header-registry timing bug. They printed to stderr on every sync
(startup + each persist-rehydrate) and echoed endpoint URLs; the behaviour is
verified and covered by tests now, so the noise is no longer warranted. The
dev-gated `[connect-probe]`/`[subsonic-proxy]` failure logs stay.

* docs(changelog): point gated-server entry at PR #1273

* fix(servers): await gate-header sync before probe/bind

bindIndexedServer registered the per-server gate headers with a fire-and-forget
`void syncServerHttpContextForProfile(...)`, so a direct bind (add / enable a
single server) could run the native reachability probe and bind session while
the header IPC was still in flight — the registry was empty and native paths
403'd behind the gate, the exact race this branch fixes. Await the sync before
probing/binding; the stale-cover retry stays off the critical path.

* fix(servers): reclaim LAN from a sticky public endpoint

For a dual-address profile, the first endpoint that answered after launch stuck
for the whole session: a public sticky endpoint was always tried first and kept
answering, so the LAN-first order was never re-run and the connection never
upgraded back to LAN. pickReachableBaseUrl now makes a short, no-retry, bounded
quick-probe of the higher-priority LAN endpoint before honouring a public sticky
entry, so a laptop returning to the LAN upgrades on the next reachability tick
while staying remote costs only one bounded probe (never the full retry cushion).

* fix(servers): refresh LAN/public badge on active-server switch; credit #1273

The connection badge read the endpoint kind from the last probe and never
re-probed when the active server changed, so switching between a LAN-only and a
public server left the badge stuck on the old server's classification until the
120-s tick. useConnectionStatus now resets the kind and re-probes when
activeServerId changes (mount still deferred to the polling effect). Also adds
the settings credits line for the gated-server work (PR #1273).

* fix(servers): LAN reclaim uses the probe's own timeout, not a 3s race

The reclaim quick-probe raced pingWithCredentialsForProfile against a fixed 3s
timer, but the underlying probe (native reqwest for gated servers, axios
otherwise) runs on a 15s timeout — so a LAN endpoint answering between 3s and
15s was wrongly treated as unreachable and the session stayed pinned to the
public sticky endpoint. Reclaim now does a single, no-retry probe on the LAN
endpoint using the normal ping timeout: a slow-but-reachable LAN still upgrades,
while a dead one still costs only one attempt (not the full retry cushion)
before falling through to the sticky sequence.
2026-07-11 16:17:17 +03:00

615 lines
22 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/api/subsonic', () => ({
pingWithCredentials: vi.fn(),
pingWithCredentialsForProfile: vi.fn(),
}));
import { pingWithCredentialsForProfile } from '@/lib/api/subsonic';
import {
allNormalizedAddresses,
ensureConnectUrlResolved,
getCachedConnectBaseUrl,
invalidateReachableEndpointCache,
isLanUrl,
normalizeServerBaseUrl,
pickReachableBaseUrl,
serverAddressEndpoints,
serverShareBaseUrl,
subscribeConnectCache,
} from '@/lib/server/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 };
}
/** Initial connect ping + 2 retries (`serverEndpoint.ts`). */
const CONNECT_PROBE_ATTEMPTS = 3;
function mockDualAddressLanFailPublicOk() {
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => {
if (url === 'http://192.168.0.10') return pingFail();
return pingOk();
});
}
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(pingWithCredentialsForProfile).mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns the single endpoint when it pings ok and caches it', async () => {
vi.mocked(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
});
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
vi.mocked(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
});
it('falls through to the public endpoint when LAN ping fails', async () => {
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const promise = pickReachableBaseUrl(
makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
}),
);
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
});
it('retries a flaky endpoint before declaring it unreachable', async () => {
vi.useFakeTimers();
vi.mocked(pingWithCredentialsForProfile)
.mockResolvedValueOnce(pingFail())
.mockResolvedValueOnce(pingOk());
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync();
const result = await promise;
expect(result.ok).toBe(true);
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('returns unreachable and clears cache when every endpoint fails', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
// Seed a stale cache entry first.
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
vi.mocked(pingWithCredentialsForProfile).mockReset();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
vi.useFakeTimers();
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
await vi.runAllTimersAsync();
const result = await unreachablePromise;
expect(result).toEqual({ ok: false, reason: 'unreachable' });
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
});
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(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).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(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
expect(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
// LAN now fails; public answers.
vi.mocked(pingWithCredentialsForProfile).mockClear();
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const fallbackPromise = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
const result = await fallbackPromise;
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');
});
it('reclaims LAN from a sticky public endpoint when LAN answers again', async () => {
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
// Boot off-LAN: LAN fails, public answers → public becomes sticky.
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const boot = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
await boot;
vi.useRealTimers();
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
// Back on the LAN: the quick reclaim probe against the LAN endpoint answers
// first, so the connection upgrades without falling back to public.
vi.mocked(pingWithCredentialsForProfile).mockReset();
vi.mocked(pingWithCredentialsForProfile).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(result.endpoint.kind).toBe('local');
}
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
});
it('keeps the sticky public endpoint when the LAN reclaim probe still fails', async () => {
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const boot = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
await boot;
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
// Still off-LAN: reclaim probe fails fast, sticky public keeps answering.
vi.mocked(pingWithCredentialsForProfile).mockClear();
const stayPromise = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
const result = await stayPromise;
vi.useRealTimers();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.baseUrl).toBe('https://music.example.com');
expect(result.endpoint.kind).toBe('public');
}
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
});
});
describe('invalidateReachableEndpointCache', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentialsForProfile).mockReset();
});
it('clears a specific profile', async () => {
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
vi.mocked(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
invalidateReachableEndpointCache();
expect(getCachedConnectBaseUrl('a')).toBeNull();
});
});
describe('subscribeConnectCache — connect-URL flip notifications', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(1);
// LAN drops, public answers → cached URL flips → another notification.
vi.useFakeTimers();
mockDualAddressLanFailPublicOk();
const flipPromise = pickReachableBaseUrl(profile);
await vi.runAllTimersAsync();
await flipPromise;
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(pingWithCredentialsForProfile).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(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
it('notifies on explicit cache invalidation when an entry existed', async () => {
vi.mocked(pingWithCredentialsForProfile).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');
});
});