|
1 | 1 | import type { ServiceType } from './interfaces.js' |
2 | 2 |
|
| 3 | +import { Buffer } from 'node:buffer' |
3 | 4 | import { EventEmitter } from 'node:events' |
4 | 5 |
|
5 | 6 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
6 | 7 |
|
7 | | -import { createConnection } from './eventedHttpClient/index.js' |
8 | | -import { HapMonitor } from './monitor.js' |
| 8 | +import realHttpParser from './eventedHttpClient/httpParser.js' |
| 9 | +import { createConnection, parseMessage } from './eventedHttpClient/index.js' |
| 10 | +import { findMessageBoundary, HapMonitor } from './monitor.js' |
9 | 11 |
|
10 | 12 | // Mock the eventedHttpClient module |
11 | 13 | vi.mock('./eventedHttpClient/index.js', () => { |
@@ -165,3 +167,106 @@ describe('hapMonitor', () => { |
165 | 167 | }) |
166 | 168 | }) |
167 | 169 | }) |
| 170 | + |
| 171 | +describe('findMessageBoundary', () => { |
| 172 | + function build(body: string, headers: Record<string, string> = {}): string { |
| 173 | + const headerLines = ['EVENT/1.0 200 OK', 'Content-Type: application/hap+json', `Content-Length: ${Buffer.byteLength(body, 'utf8')}`] |
| 174 | + for (const [k, v] of Object.entries(headers)) { |
| 175 | + headerLines.push(`${k}: ${v}`) |
| 176 | + } |
| 177 | + return `${headerLines.join('\r\n')}\r\n\r\n${body}` |
| 178 | + } |
| 179 | + |
| 180 | + it('returns -1 for an empty buffer', () => { |
| 181 | + expect(findMessageBoundary('')).toBe(-1) |
| 182 | + }) |
| 183 | + |
| 184 | + it('returns -1 when headers are not yet terminated', () => { |
| 185 | + expect(findMessageBoundary('EVENT/1.0 200 OK\r\nContent-Length: 5')).toBe(-1) |
| 186 | + }) |
| 187 | + |
| 188 | + it('returns the message length when one complete message is buffered', () => { |
| 189 | + const msg = build('{"a":1}') |
| 190 | + expect(findMessageBoundary(msg)).toBe(msg.length) |
| 191 | + }) |
| 192 | + |
| 193 | + it('returns the FIRST message length when two complete messages are buffered', () => { |
| 194 | + const first = build('{"a":1}') |
| 195 | + const second = build('{"b":2}') |
| 196 | + const combined = first + second |
| 197 | + |
| 198 | + const boundary = findMessageBoundary(combined) |
| 199 | + expect(boundary).toBe(first.length) |
| 200 | + // The remainder is the start of the second message. |
| 201 | + expect(combined.slice(boundary)).toBe(second) |
| 202 | + }) |
| 203 | + |
| 204 | + it('returns -1 when the body is shorter than Content-Length', () => { |
| 205 | + const fullBody = '{"characteristics":[{"aid":1,"iid":10,"value":true}]}' |
| 206 | + const headers = `EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: ${fullBody.length}\r\n\r\n` |
| 207 | + const truncated = headers + fullBody.slice(0, fullBody.length - 5) |
| 208 | + |
| 209 | + expect(findMessageBoundary(truncated)).toBe(-1) |
| 210 | + }) |
| 211 | +}) |
| 212 | + |
| 213 | +describe('hapMonitor data handling - multiple messages per chunk', () => { |
| 214 | + let monitor: HapMonitor |
| 215 | + const username = 'AA:BB:CC:DD:EE:FF' |
| 216 | + |
| 217 | + beforeEach(() => { |
| 218 | + vi.clearAllMocks() |
| 219 | + // Use the real HTTP parser for this scenario so we can assert how the |
| 220 | + // monitor splits and dispatches multi-message TCP chunks. |
| 221 | + vi.mocked(parseMessage).mockImplementation((msg: any) => realHttpParser(msg)) |
| 222 | + |
| 223 | + const services = [buildService(username)] |
| 224 | + monitor = new HapMonitor(null, vi.fn(), '031-45-154', services) |
| 225 | + }) |
| 226 | + |
| 227 | + afterEach(() => { |
| 228 | + monitor.finish() |
| 229 | + vi.mocked(parseMessage).mockReset() |
| 230 | + vi.mocked(parseMessage).mockImplementation(() => ({ statusCode: 200, protocol: 'HTTP' } as any)) |
| 231 | + }) |
| 232 | + |
| 233 | + function buildEvent(value: boolean): string { |
| 234 | + const body = JSON.stringify({ characteristics: [{ aid: 1, iid: 2, value }] }) |
| 235 | + return [ |
| 236 | + 'EVENT/1.0 200 OK', |
| 237 | + 'Content-Type: application/hap+json', |
| 238 | + `Content-Length: ${Buffer.byteLength(body, 'utf8')}`, |
| 239 | + '', |
| 240 | + body, |
| 241 | + ].join('\r\n') |
| 242 | + } |
| 243 | + |
| 244 | + it('should emit service-update for every message in a single TCP chunk', () => { |
| 245 | + const updates: any[][] = [] |
| 246 | + monitor.on('service-update', services => updates.push(services)) |
| 247 | + |
| 248 | + const socket = (monitor as any).evInstances[0].socket |
| 249 | + const combined = buildEvent(true) + buildEvent(false) + buildEvent(true) |
| 250 | + |
| 251 | + socket.emit('data', Buffer.from(combined, 'utf8')) |
| 252 | + |
| 253 | + // Without the fix only the first message was parsed and emitted; the |
| 254 | + // second and third silently fell off the floor. |
| 255 | + expect(updates).toHaveLength(3) |
| 256 | + }) |
| 257 | + |
| 258 | + it('should buffer a message split across two data events and emit once when complete', () => { |
| 259 | + const updates: any[][] = [] |
| 260 | + monitor.on('service-update', services => updates.push(services)) |
| 261 | + |
| 262 | + const socket = (monitor as any).evInstances[0].socket |
| 263 | + const full = buildEvent(true) |
| 264 | + const splitAt = Math.floor(full.length / 2) |
| 265 | + |
| 266 | + socket.emit('data', Buffer.from(full.slice(0, splitAt), 'utf8')) |
| 267 | + expect(updates).toHaveLength(0) // not enough data yet |
| 268 | + |
| 269 | + socket.emit('data', Buffer.from(full.slice(splitAt), 'utf8')) |
| 270 | + expect(updates).toHaveLength(1) |
| 271 | + }) |
| 272 | +}) |
0 commit comments