|
4 | 4 | * Tests use manual mocking of the SubsonicAPI to avoid real network calls. |
5 | 5 | */ |
6 | 6 |
|
7 | | -import { describe, it, expect } from 'bun:test'; |
8 | | -import { SubsonicAdapter } from './subsonic.js'; |
| 7 | +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; |
| 8 | +import { SubsonicAdapter, SubsonicConnectionError } from './subsonic.js'; |
9 | 9 | import type { SubsonicAdapterConfig } from './subsonic.js'; |
10 | 10 | import type { Child, AlbumWithSongsID3 } from 'subsonic-api'; |
11 | 11 | import { replayGainToSoundcheck } from '../sync/soundcheck.js'; |
@@ -522,3 +522,178 @@ describe('SubsonicAdapter artwork presence detection', () => { |
522 | 522 | expect(t2.hasArtwork).toBe(false); |
523 | 523 | }); |
524 | 524 | }); |
| 525 | + |
| 526 | +// ============================================================================= |
| 527 | +// Connection Retry Tests |
| 528 | +// ============================================================================= |
| 529 | + |
| 530 | +describe('SubsonicAdapter connection retries', () => { |
| 531 | + const originalFetch = globalThis.fetch; |
| 532 | + |
| 533 | + afterEach(() => { |
| 534 | + globalThis.fetch = originalFetch; |
| 535 | + }); |
| 536 | + |
| 537 | + /** |
| 538 | + * Create an adapter with a mocked globalThis.fetch. |
| 539 | + * The mock must be installed BEFORE creating the adapter because |
| 540 | + * createRetryFetch captures globalThis.fetch at construction time. |
| 541 | + */ |
| 542 | + function createAdapterWithMockedFetch(mockFetch: typeof globalThis.fetch) { |
| 543 | + globalThis.fetch = mockFetch; |
| 544 | + return new SubsonicAdapter({ |
| 545 | + url: 'https://music.example.com', |
| 546 | + username: 'testuser', |
| 547 | + password: 'testpass', |
| 548 | + }); |
| 549 | + } |
| 550 | + |
| 551 | + it('retries connection errors up to 3 times then throws SubsonicConnectionError', async () => { |
| 552 | + let fetchCount = 0; |
| 553 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 554 | + fetchCount++; |
| 555 | + throw new TypeError('fetch failed'); |
| 556 | + }); |
| 557 | + |
| 558 | + const error = await adapter.connect().catch((e) => e); |
| 559 | + expect(error).toBeInstanceOf(SubsonicConnectionError); |
| 560 | + expect(fetchCount).toBe(3); |
| 561 | + }); |
| 562 | + |
| 563 | + it('error message includes the server URL', async () => { |
| 564 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 565 | + throw new TypeError('fetch failed'); |
| 566 | + }); |
| 567 | + |
| 568 | + const error = await adapter.connect().catch((e) => e); |
| 569 | + expect(error.message).toContain('https://music.example.com'); |
| 570 | + }); |
| 571 | + |
| 572 | + it('error message includes retry count and diagnostic hints', async () => { |
| 573 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 574 | + throw new TypeError('fetch failed'); |
| 575 | + }); |
| 576 | + |
| 577 | + const error = await adapter.connect().catch((e) => e); |
| 578 | + expect(error.message).toContain('after 3 attempts'); |
| 579 | + expect(error.message).toContain('Check that the server is running'); |
| 580 | + expect(error.message).toContain('Docker'); |
| 581 | + }); |
| 582 | + |
| 583 | + it('SubsonicConnectionError has url property', async () => { |
| 584 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 585 | + throw new TypeError('fetch failed'); |
| 586 | + }); |
| 587 | + |
| 588 | + const error = await adapter.connect().catch((e) => e); |
| 589 | + expect(error).toBeInstanceOf(SubsonicConnectionError); |
| 590 | + expect(error.url).toBe('https://music.example.com'); |
| 591 | + }); |
| 592 | + |
| 593 | + it('retries on DNS resolution failure (ENOTFOUND)', async () => { |
| 594 | + let fetchCount = 0; |
| 595 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 596 | + fetchCount++; |
| 597 | + const err = new Error('getaddrinfo ENOTFOUND music.example.com'); |
| 598 | + throw err; |
| 599 | + }); |
| 600 | + |
| 601 | + await expect(adapter.connect()).rejects.toBeInstanceOf(SubsonicConnectionError); |
| 602 | + expect(fetchCount).toBe(3); |
| 603 | + }); |
| 604 | + |
| 605 | + it('retries on connection refused (ECONNREFUSED)', async () => { |
| 606 | + let fetchCount = 0; |
| 607 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 608 | + fetchCount++; |
| 609 | + throw new Error('connect ECONNREFUSED 192.168.1.100:4533'); |
| 610 | + }); |
| 611 | + |
| 612 | + await expect(adapter.connect()).rejects.toBeInstanceOf(SubsonicConnectionError); |
| 613 | + expect(fetchCount).toBe(3); |
| 614 | + }); |
| 615 | + |
| 616 | + it('retries on timeout (ETIMEDOUT)', async () => { |
| 617 | + let fetchCount = 0; |
| 618 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 619 | + fetchCount++; |
| 620 | + throw new Error('connect ETIMEDOUT 10.0.0.1:443'); |
| 621 | + }); |
| 622 | + |
| 623 | + await expect(adapter.connect()).rejects.toBeInstanceOf(SubsonicConnectionError); |
| 624 | + expect(fetchCount).toBe(3); |
| 625 | + }); |
| 626 | + |
| 627 | + it('succeeds on retry after transient connection failure', async () => { |
| 628 | + let fetchCount = 0; |
| 629 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 630 | + fetchCount++; |
| 631 | + if (fetchCount < 3) { |
| 632 | + throw new TypeError('fetch failed'); |
| 633 | + } |
| 634 | + // Return a successful Subsonic ping response |
| 635 | + return new Response( |
| 636 | + JSON.stringify({ |
| 637 | + 'subsonic-response': { status: 'ok', version: '1.16.1' }, |
| 638 | + }), |
| 639 | + { status: 200, headers: { 'content-type': 'application/json' } } |
| 640 | + ); |
| 641 | + }); |
| 642 | + |
| 643 | + // Should not throw — succeeds on 3rd attempt |
| 644 | + await adapter.connect(); |
| 645 | + expect(fetchCount).toBe(3); |
| 646 | + }); |
| 647 | + |
| 648 | + it('does not retry on non-connection errors', async () => { |
| 649 | + let fetchCount = 0; |
| 650 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 651 | + fetchCount++; |
| 652 | + // A non-connection error (e.g., thrown by middleware) |
| 653 | + throw new Error('some other error'); |
| 654 | + }); |
| 655 | + |
| 656 | + // Should fail immediately without retrying |
| 657 | + // The error wrapping in connect() catches it as a generic connection failure |
| 658 | + await expect(adapter.connect()).rejects.toThrow(/Failed to connect/); |
| 659 | + expect(fetchCount).toBe(1); |
| 660 | + }); |
| 661 | + |
| 662 | + it('does not retry when server returns HTTP 401 (authentication failure)', async () => { |
| 663 | + let fetchCount = 0; |
| 664 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 665 | + fetchCount++; |
| 666 | + // HTTP 401 is returned as a Response, not thrown — fetch() resolves for HTTP errors. |
| 667 | + // The subsonic-api library parses the response and may throw its own error, |
| 668 | + // but the fetch layer itself succeeds. We verify fetch is called only once. |
| 669 | + return new Response( |
| 670 | + JSON.stringify({ |
| 671 | + 'subsonic-response': { |
| 672 | + status: 'failed', |
| 673 | + error: { code: 40, message: 'Wrong username or password' }, |
| 674 | + }, |
| 675 | + }), |
| 676 | + { status: 200, headers: { 'content-type': 'application/json' } } |
| 677 | + ); |
| 678 | + }); |
| 679 | + |
| 680 | + // The adapter wraps the "status: failed" response into an error |
| 681 | + await expect(adapter.connect()).rejects.toThrow(/Failed to connect/); |
| 682 | + // fetch was called exactly once — no retries for auth failures |
| 683 | + expect(fetchCount).toBe(1); |
| 684 | + }); |
| 685 | + |
| 686 | + it('does not retry when server returns HTTP 403 (forbidden)', async () => { |
| 687 | + let fetchCount = 0; |
| 688 | + const adapter = createAdapterWithMockedFetch(async () => { |
| 689 | + fetchCount++; |
| 690 | + return new Response('Forbidden', { |
| 691 | + status: 403, |
| 692 | + headers: { 'content-type': 'text/plain' }, |
| 693 | + }); |
| 694 | + }); |
| 695 | + |
| 696 | + await expect(adapter.connect()).rejects.toThrow(/Failed to connect/); |
| 697 | + expect(fetchCount).toBe(1); |
| 698 | + }); |
| 699 | +}); |
0 commit comments