From 4fee231a7bff70c0f1fa741fb4d9923262fb2d3f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Mar 2025 19:02:43 +0000 Subject: [PATCH] test(musiccast): Add tests for basic connectivity --- package-lock.json | 9 + package.json | 1 + .../infrastructure/config/source/musiccast.ts | 2 +- src/backend/sources/MusicCastSource.ts | 18 +- src/backend/tests/musiccast/musiccast.test.ts | 229 ++++++++++++++++++ src/backend/utils/NetworkUtils.ts | 38 +++ 6 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 src/backend/tests/musiccast/musiccast.test.ts diff --git a/package-lock.json b/package-lock.json index f5fbe3e0..82d964f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@foxxmd/logging": "^0.2.2", "@foxxmd/regex-buddy-core": "^0.1.2", "@foxxmd/string-sameness": "^0.4.0", + "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", "@lukehagar/plexjs": "^0.32.1", @@ -1136,6 +1137,14 @@ "npm": ">=9.3.0" } }, + "node_modules/@gr2m/net-interceptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@gr2m/net-interceptor/-/net-interceptor-1.0.0.tgz", + "integrity": "sha512-qLTp3npmB5SDiEIh1Jg1v8a09dlnfYV1kcSyLmsyucvZBT89nM+6L1QdlrCC/W1cTvdTpvI+Vleit94UVX7/Cg==", + "engines": { + "node": ">= 14" + } + }, "node_modules/@homebridge/long": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/@homebridge/long/-/long-5.2.1.tgz", diff --git a/package.json b/package.json index 796c661f..e241a8a0 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@foxxmd/logging": "^0.2.2", "@foxxmd/regex-buddy-core": "^0.1.2", "@foxxmd/string-sameness": "^0.4.0", + "@gr2m/net-interceptor": "^1.0.0", "@jellyfin/sdk": "^0.11.0", "@kenyip/backoff-strategies": "^1.0.4", "@lukehagar/plexjs": "^0.32.1", diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts index 5b4a0904..23e0f868 100644 --- a/src/backend/common/infrastructure/config/source/musiccast.ts +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -35,7 +35,7 @@ export interface PlayInfoNetResponse extends PlayInfoCDResponse { input: string } -const MusicCastResponseCodes = new Map([ +export const MusicCastResponseCodes = new Map([ [0, 'Success'], [1, 'Initializing'], [2, 'Internal Error'], diff --git a/src/backend/sources/MusicCastSource.ts b/src/backend/sources/MusicCastSource.ts index e9e9376b..5ff1cbc1 100644 --- a/src/backend/sources/MusicCastSource.ts +++ b/src/backend/sources/MusicCastSource.ts @@ -8,8 +8,8 @@ import { PlayerStateData, SINGLE_USER_PLATFORM_ID, } from "../common/infrastructure/Atomic.js"; -import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; -import { DeviceInfoResponse, DeviceStatusResponse, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; +import { isPortReachable, isPortReachableConnect, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { DeviceInfoResponse, DeviceStatusResponse, MusicCastResponseCodes, MusicCastSourceConfig, playbackToReportedStatus, PlayInfoCDResponse, PlayInfoNetResponse } from "../common/infrastructure/config/source/musiccast.js"; import request, { Request, Response } from 'superagent'; @@ -51,7 +51,7 @@ export class MusicCastSource extends MemoryPositionalSource { protected async doCheckConnection(): Promise { try { - await isPortReachable(this.urlData.port, { host: this.urlData.url.hostname }); + await isPortReachableConnect(1130, { host: this.urlData.url.hostname }); this.logger.verbose(`${this.urlData.url.hostname}:${this.urlData.port} is reachable.`); const resp = await request.get(joinedUrl(this.urlData.url, 'system/getDeviceInfo').toString()) @@ -73,7 +73,11 @@ export class MusicCastSource extends MemoryPositionalSource { try { const netResp = await request.get(joinedUrl(this.urlData.url, '/netusb/getPlayInfo').toString()); if (netResp.body !== undefined && typeof netResp.body === 'object') { - return netResp.body as PlayInfoNetResponse + const resp = netResp.body as PlayInfoNetResponse + if(resp.response_code !== 0) { + throw new Error(`netusb source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); + } + return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from netusb getPlayInfo but will continue', {cause: e})); @@ -82,7 +86,11 @@ export class MusicCastSource extends MemoryPositionalSource { try { const cdResp = await request.get(joinedUrl(this.urlData.url, '/cd/getPlayInfo').toString()); if (cdResp.body !== undefined && typeof cdResp.body === 'object') { - return cdResp.body as PlayInfoCDResponse; + const resp = cdResp.body as PlayInfoCDResponse; + if(resp.response_code !== 0) { + throw new Error(`cd source is unexpected status: ${resp.response_code} (${MusicCastResponseCodes.get(resp.response_code) ?? 'Unknown'})`); + } + return resp; } } catch (e) { this.logger.warn(new Error('Not OK response from cd getPlayInfo but will continue', {cause: e})); diff --git a/src/backend/tests/musiccast/musiccast.test.ts b/src/backend/tests/musiccast/musiccast.test.ts new file mode 100644 index 00000000..9ca984aa --- /dev/null +++ b/src/backend/tests/musiccast/musiccast.test.ts @@ -0,0 +1,229 @@ +import { loggerTest, loggerDebug } from "@foxxmd/logging"; +import { assert, expect } from 'chai'; +import EventEmitter from "events"; +import dayjs from "dayjs"; +import { describe, it, before, after } from 'mocha'; +import { http, HttpResponse } from "msw"; +import { withRequestInterception } from "../utils/networking.js"; +import { MusicCastData } from "../../common/infrastructure/config/source/musiccast.js"; +import { MusicCastSource } from "../../sources/MusicCastSource.js"; +import netInterceptor from "@gr2m/net-interceptor"; +import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js"; + +const TEST_IP = '192.168.10.101'; + +const createSource = (data: MusicCastData = { url: TEST_IP }): MusicCastSource => { + const source = new MusicCastSource('Test', { + data, + options: {} + }, { localUrl: new URL('http://test'), configDir: 'test', logger: loggerTest, version: 'test' }, new EventEmitter()); + return source; +} + +describe('MusicCast Startup', function () { + + after(() => { + netInterceptor.stop(); + }); + it('tests for device info correctly', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/system/getDeviceInfo`, () => { + return HttpResponse.json({ + model_name: 'test', + device_id: 'testid', + system_version: '1234', + version: '1test', + response_code: 0 + }, { status: 200 }); + }) + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + + netInterceptor.start(); + + netInterceptor.on("connection", (socket) => { + socket.write("Hello there."); + netInterceptor.stop(); + }); + await source.checkConnection(); + expect(source.connectionOK).to.be.true; + } + )); +}); + +describe('MusicCast State Handling', function() { + + it('Handles standby mode', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'standby', + response_code: 0 + }, { status: 200 }); + }) + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(0); + } + )); + + it('Handles valid netusb device playing state', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + input: 'av1', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.play.data.album).to.eq('Test Album'); + expect(playerState.play.data.track).to.eq('Cool Track'); + expect(playerState.play.data.duration).to.eq(60); + expect(playerState.play.meta.trackProgressPosition).to.eq(10); + expect(playerState.play.meta.deviceId).to.eq('av1'); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); + } + )); + + it('Handles valid cd device playing state', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + response_code: 100 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.play.data.album).to.eq('Test Album'); + expect(playerState.play.data.track).to.eq('Cool Track'); + expect(playerState.play.data.duration).to.eq(60); + expect(playerState.play.meta.trackProgressPosition).to.eq(10); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.playing); + } + )); + + it('Handles non 200 status from getPlayInfo', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + response_code: 100 + }, { status: 500 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/cd/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'play', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + } + )); + + it('Handles stopped player', withRequestInterception( + [ + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/main/getStatus`, () => { + return HttpResponse.json({ + power: 'on', + response_code: 0 + }, { status: 200 }); + }), + http.get(`http://${TEST_IP}/YamahaExtendedControl/v1/netusb/getPlayInfo`, () => { + return HttpResponse.json({ + device_status: 'ready', + playback: 'stop', + play_time: 10, + total_time: 60, + artist: 'Test Artist', + album: 'Test Album', + track: 'Cool Track', + input: 'av1', + response_code: 0 + }, { status: 200 }); + }), + ], + async function () { + const source = createSource(); + + await source.buildInitData(); + source.connectionOK = true; + await source.getRecentlyPlayed(); + expect(source.players.size).to.eq(1); + const playerState = source.players.get(source.players.keys().next().value).getApiState(); + expect(playerState.status.reported).to.eq(REPORTED_PLAYER_STATUSES.stopped); + } + )); + +}); \ No newline at end of file diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index ff83306f..6d247216 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -47,6 +47,44 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) => } } +/** Test if a host:port is reachable via TCP + * + * Need to use net.connect instead of new.Socket() because the popular mocking libraries don't mock Socket + * + * https://github.com/gr2m/node-net-interceptor/issues/2 + * https://github.com/moll/node-mitm/issues/42 + * + */ +export const isPortReachableConnect = async (port: number, opts: PortReachableOpts) => { + const {host, timeout = 1000} = opts; + + const promise = new Promise(((resolve, reject) => { + const client = net.connect({ + timeout, + port, + host + }, () => { + client.end(); + resolve(true); + }); + + client.on('error', (err) => { + client.destroy(); + reject(err); + }); + client.on('timeout', () => { + reject(new Error(`Connection timed out after ${timeout}ms`)); + }); + })); + + try { + await promise; + return true; + } catch (e) { + throw e; + } +} + const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); export const normalizeWebAddress = (val: string, options: {defaultPath?: string} = {}): URLData => { -- 2.51.2