diff --git a/packages/host-chat/src/codec/attachment.spec.ts b/packages/host-chat/src/codec/attachment.spec.ts index 168c986e..20857d4e 100644 --- a/packages/host-chat/src/codec/attachment.spec.ts +++ b/packages/host-chat/src/codec/attachment.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { FileMeta, FileVariant, GeneralFileMeta, ImageFileMeta, P2PMixnetFile, VideoFileMeta } from './attachment.js'; +import { + FileMeta, + FileVariant, + GeneralFileMeta, + ImageFileMeta, + NodeEndpoint, + P2PMixnetFile, + VideoFileMeta, +} from './attachment.js'; describe('attachment codecs', () => { describe('GeneralFileMeta', () => { @@ -13,21 +21,33 @@ describe('attachment codecs', () => { }); describe('ImageFileMeta', () => { - it('round-trips', () => { + it('round-trips without thumbnail', () => { const original = { general: { mimeType: 'image/jpeg', fileSize: 500_000 }, width: 1920, height: 1080, - thumbnail: new TextEncoder().encode('LEHV6nWB2yk8pyo0adR*.7kCMdnj'), + thumbnail: undefined, }; const encoded = ImageFileMeta.enc(original); const decoded = ImageFileMeta.dec(encoded); expect(decoded).toEqual(original); }); + + it('round-trips with blurhash thumbnail', () => { + const original = { + general: { mimeType: 'image/jpeg', fileSize: 500_000 }, + width: 1920, + height: 1080, + thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'), + }; + const encoded = ImageFileMeta.enc(original); + const decoded = ImageFileMeta.dec(encoded); + expect(decoded.thumbnail).toEqual(original.thumbnail); + }); }); describe('VideoFileMeta', () => { - it('round-trips', () => { + it('round-trips without thumbnail', () => { const original = { general: { mimeType: 'video/mp4', fileSize: 10_000_000 }, duration: 120, @@ -37,6 +57,17 @@ describe('attachment codecs', () => { const decoded = VideoFileMeta.dec(encoded); expect(decoded).toEqual(original); }); + + it('round-trips with blurhash thumbnail', () => { + const original = { + general: { mimeType: 'video/mp4', fileSize: 10_000_000 }, + duration: 120, + thumbnail: new TextEncoder().encode('LKO2?U%2Tw=w]~RBVZRi};RPxuwH'), + }; + const encoded = VideoFileMeta.enc(original); + const decoded = VideoFileMeta.dec(encoded); + expect(decoded.thumbnail).toEqual(original.thumbnail); + }); }); describe('FileMeta', () => { @@ -54,7 +85,11 @@ describe('attachment codecs', () => { }, { tag: 'video' as const, - value: { general: { mimeType: 'video/mp4', fileSize: 4096 }, duration: 60, thumbnail: undefined }, + value: { + general: { mimeType: 'video/mp4', fileSize: 4096 }, + duration: 60, + thumbnail: new TextEncoder().encode('blurhashstring'), + }, }, ]; @@ -66,19 +101,28 @@ describe('attachment codecs', () => { }); }); + describe('NodeEndpoint', () => { + it('round-trips wssUrl', () => { + const original = { tag: 'wssUrl' as const, value: { url: 'wss://hop-a.example/chat' } }; + const encoded = NodeEndpoint.enc(original); + const decoded = NodeEndpoint.dec(encoded); + expect(decoded).toEqual(original); + }); + }); + describe('P2PMixnetFile', () => { - it('round-trips', () => { + it('round-trips with nodeEndpoint and image thumbnail', () => { const original = { identifier: new Uint8Array(32).fill(0xaa), claimTicket: new Uint8Array(32).fill(0xbb), - nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/ws' } }, + nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://bulletin.example/hop' } }, meta: { tag: 'image' as const, value: { general: { mimeType: 'image/jpeg', fileSize: 500_000 }, width: 1920, height: 1080, - thumbnail: undefined, + thumbnail: new TextEncoder().encode('L6PZfSi_.AyE_3t7t7R**0o#DgR4'), }, }, }; @@ -98,7 +142,7 @@ describe('attachment codecs', () => { value: { identifier: new Uint8Array(32).fill(0x11), claimTicket: new Uint8Array(32).fill(0x22), - nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/ws' } }, + nodeEndpoint: { tag: 'wssUrl' as const, value: { url: 'wss://hop.example/path' } }, meta: { tag: 'general' as const, value: { mimeType: 'text/plain', fileSize: 10 } }, }, }; diff --git a/packages/host-chat/src/codec/message.spec.ts b/packages/host-chat/src/codec/message.spec.ts new file mode 100644 index 00000000..60a50e7b --- /dev/null +++ b/packages/host-chat/src/codec/message.spec.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { + CoinagePaymentContent, + DataChannelAnswerContent, + DataChannelClosedContent, + DataChannelIceCandidateContent, + DataChannelOfferContent, + MessageContent, +} from './message.js'; + +describe('DataChannel content codecs', () => { + it('DataChannelOfferContent round-trips with AUDIO_CALL purpose', () => { + const original = { + sdp: new TextEncoder().encode('v=0\r\no=- 4611...\r\n'), + purpose: 'AUDIO_CALL' as const, + }; + const decoded = DataChannelOfferContent.dec(DataChannelOfferContent.enc(original)); + expect(decoded.purpose).toBe('AUDIO_CALL'); + expect(decoded.sdp).toEqual(original.sdp); + }); + + it('DataChannelAnswerContent round-trips', () => { + const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xaa, 0xbb, 0xcc]) }; + const decoded = DataChannelAnswerContent.dec(DataChannelAnswerContent.enc(original)); + expect(decoded).toEqual(original); + }); + + it('DataChannelIceCandidateContent round-trips', () => { + const original = { offerMessageId: 'offer-1', sdp: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) }; + const decoded = DataChannelIceCandidateContent.dec(DataChannelIceCandidateContent.enc(original)); + expect(decoded).toEqual(original); + }); + + it('DataChannelClosedContent round-trips', () => { + const original = { offerMessageId: 'offer-1' }; + const decoded = DataChannelClosedContent.dec(DataChannelClosedContent.enc(original)); + expect(decoded).toEqual(original); + }); +}); + +describe('CoinagePaymentContent', () => { + it('round-trips with a small Balance and a couple of coin keys', () => { + // scale-ts `compact` decodes to `number` for values ≤ 2^30 and `bigint` + // for larger ones; normalise to BigInt before asserting. + const original = { + totalValue: 1_234_567, + coinKeys: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x22)], + }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(1_234_567n); + expect(decoded.coinKeys).toHaveLength(2); + expect(decoded.coinKeys[0]).toEqual(original.coinKeys[0]); + expect(decoded.coinKeys[1]).toEqual(original.coinKeys[1]); + }); + + it('round-trips a large Balance (forces bigint path)', () => { + const huge = 2n ** 100n; + const original = { + totalValue: huge, + coinKeys: [new Uint8Array(32).fill(0x33)], + }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(huge); + }); + + it('round-trips with an empty coin-keys list', () => { + const original = { totalValue: 0, coinKeys: [] }; + const decoded = CoinagePaymentContent.dec(CoinagePaymentContent.enc(original)); + expect(BigInt(decoded.totalValue)).toBe(0n); + expect(decoded.coinKeys).toEqual([]); + }); +}); + +describe('MessageContent enum discriminants', () => { + it('coinagePayment lands on discriminant 16 and round-trips inside MessageContent', () => { + const value = { + tag: 'coinagePayment' as const, + value: { totalValue: 42, coinKeys: [new Uint8Array(32).fill(0xab)] }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(16); + const decoded = MessageContent.dec(encoded); + expect(decoded.tag).toBe('coinagePayment'); + if (decoded.tag !== 'coinagePayment') throw new Error('unreachable'); + expect(BigInt(decoded.value.totalValue)).toBe(42n); + expect(decoded.value.coinKeys[0]).toEqual(value.value.coinKeys[0]); + }); + + it('dataChannelClosed lands on discriminant 11 and round-trips inside MessageContent', () => { + const value = { + tag: 'dataChannelClosed' as const, + value: { offerMessageId: 'offer-x' }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(11); + const decoded = MessageContent.dec(encoded); + expect(decoded.tag).toBe('dataChannelClosed'); + if (decoded.tag !== 'dataChannelClosed') throw new Error('unreachable'); + expect(decoded.value.offerMessageId).toBe('offer-x'); + }); + + it('dataChannelOffer lands on discriminant 8 and round-trips inside MessageContent', () => { + const value = { + tag: 'dataChannelOffer' as const, + value: { sdp: new Uint8Array([1, 2, 3]), purpose: 'VIDEO_CALL' as const }, + }; + const encoded = MessageContent.enc(value); + expect(encoded[0]).toBe(8); + const decoded = MessageContent.dec(encoded); + if (decoded.tag !== 'dataChannelOffer') throw new Error('unreachable'); + expect(decoded.value.purpose).toBe('VIDEO_CALL'); + expect(decoded.value.sdp).toEqual(value.value.sdp); + }); +}); diff --git a/packages/host-papp/src/identity/rpcAdapter.ts b/packages/host-papp/src/identity/rpcAdapter.ts index b4a11186..d8b99aa5 100644 --- a/packages/host-papp/src/identity/rpcAdapter.ts +++ b/packages/host-papp/src/identity/rpcAdapter.ts @@ -22,19 +22,28 @@ function decodeRawIdentity( ): Identity | null { if (!raw) return null; + // Runtime metadata may expose fields in snake_case (V1) or camelCase (V2 + // multi-device). The .papi descriptor only types snake_case, so widen here + // and read defensively — otherwise the V2 multi-device runtime decodes to an + // empty username (the "Unknown user" regression). + const wide = raw as unknown as Record & typeof raw; + const fullUsername = (wide.full_username as Uint8Array | undefined) ?? (wide.fullUsername as Uint8Array | undefined); + const liteUsername = (wide.lite_username as Uint8Array | undefined) ?? (wide.liteUsername as Uint8Array | undefined); + const credibility: Credibility = raw.credibility.type === 'Lite' ? { type: 'Lite' } : { type: 'Person', alias: raw.credibility.value.alias as HexString, - lastUpdate: raw.credibility.value.last_update.toString(), + lastUpdate: ((raw.credibility.value as Record).last_update ?? + (raw.credibility.value as Record).lastUpdate)!.toString(), }; return { accountId, - fullUsername: raw.full_username ? textDecoder.decode(raw.full_username) : null, - liteUsername: textDecoder.decode(raw.lite_username), + fullUsername: fullUsername ? textDecoder.decode(fullUsername) : null, + liteUsername: liteUsername ? textDecoder.decode(liteUsername) : '', credibility, }; }