/** * Unit tests for formatScopedHardwareId (TECH-1394) — the pure formatter * behind instance.generateScopedHardwareId. What matters: the exact output * format per scope, the flat 64-char user budget, the colon reservation, the * throw-never-fallback contract for missing scope ids, determinism, and that * every output satisfies phyhub's contract (^[a-zA-Z0-9._:-]{3,100}$). */ import { describe, expect, it } from 'bun:test'; import { formatScopedHardwareId, HARDWARE_ID_USER_PART_MAX_LENGTH } from '../scoped-hardware-id'; const SPACE_ID = '68a1f0abcdef1234567890ab'; const DEVICE_UUID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; const SERVER_CONTRACT = /^[a-zA-Z0-9._:-]{3,100}$/; describe('formatScopedHardwareId', () => { it('global: prefixes g: and keeps the id verbatim', () => { expect(formatScopedHardwareId('hue-0f8e4c22-aa1b', 'global')).toBe('g:hue-0f8e4c22-aa1b'); }); it('space: embeds the raw 24-hex spaceId', () => { expect(formatScopedHardwareId('light-zone-row-a', 'space', SPACE_ID)).toBe(`s-${SPACE_ID}:light-zone-row-a`); }); it('device: embeds the deviceId UUID with dashes stripped (32 hex — fits the 100-char limit)', () => { expect(formatScopedHardwareId('epson-tm88', 'device', DEVICE_UUID)).toBe( 'd-f47ac10b58cc4372a5670e02b2c3d479:epson-tm88', ); }); it('trims the user id before validating', () => { expect(formatScopedHardwareId(' tts-main ', 'global')).toBe('g:tts-main'); }); it('every scope stays within the server contract at the full 64-char user budget', () => { const maxUserId = 'a'.repeat(HARDWARE_ID_USER_PART_MAX_LENGTH); const globalId = formatScopedHardwareId(maxUserId, 'global'); const spaceId = formatScopedHardwareId(maxUserId, 'space', SPACE_ID); const deviceId = formatScopedHardwareId(maxUserId, 'device', DEVICE_UUID); for (const hardwareId of [globalId, spaceId, deviceId]) { expect(hardwareId).toMatch(SERVER_CONTRACT); expect(hardwareId.length).toBeLessThanOrEqual(100); } }); it('throws on an empty or whitespace-only id', () => { expect(() => formatScopedHardwareId('', 'global')).toThrow('must not be empty'); expect(() => formatScopedHardwareId(' ', 'global')).toThrow('must not be empty'); }); it('throws on an id over 64 chars instead of truncating or hashing', () => { const oversizedId = 'a'.repeat(HARDWARE_ID_USER_PART_MAX_LENGTH + 1); expect(() => formatScopedHardwareId(oversizedId, 'global')).toThrow('exceeds 64 chars'); }); it('throws on a colon in the user id — reserved as the prefix separator', () => { expect(() => formatScopedHardwareId('dog:go2', 'global')).toThrow('reserved as the scope separator'); }); it('throws on charset violations (spaces, slashes) instead of normalizing', () => { expect(() => formatScopedHardwareId('row a lights', 'space', SPACE_ID)).toThrow('outside [a-zA-Z0-9._-]'); expect(() => formatScopedHardwareId('rtsp://cam/1', 'space', SPACE_ID)).toThrow('outside [a-zA-Z0-9._-]'); }); it('throws when the scope id is missing — never silently falls back to unscoped', () => { expect(() => formatScopedHardwareId('light-zone-row-a', 'space')).toThrow('spaceId'); expect(() => formatScopedHardwareId('light-zone-row-a', 'space', '')).toThrow('spaceId'); expect(() => formatScopedHardwareId('epson-tm88', 'device', undefined)).toThrow('deviceId'); }); it('throws on a scope id that cannot form a valid segment (too long, bad charset)', () => { expect(() => formatScopedHardwareId('x-1', 'device', 'a'.repeat(40))).toThrow('deviceId'); expect(() => formatScopedHardwareId('x-1', 'space', 'space id with spaces')).toThrow('spaceId'); }); it('is deterministic — same inputs, same output', () => { const first = formatScopedHardwareId('camera-a1b2c3d4e5f6a7b8', 'space', SPACE_ID); const second = formatScopedHardwareId('camera-a1b2c3d4e5f6a7b8', 'space', SPACE_ID); expect(first).toBe(second); }); });