diff --git a/packages/iios-messaging-ui/src/conformance.ts b/packages/iios-messaging-ui/src/conformance.ts new file mode 100644 index 0000000..8b397fb --- /dev/null +++ b/packages/iios-messaging-ui/src/conformance.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import type { MessagingAdapter } from './adapter'; +import type { MessageEvent } from './types'; + +export interface ConformanceOptions { + /** Build a fresh, isolated adapter per test. */ + makeAdapter: () => Promise | MessagingAdapter; + /** A thread id that exists in the fixture. */ + seededThreadId: string; + /** Participant ids openThread can legally be called with. */ + openWith: string[]; +} + +/** + * The definition of a correct MessagingAdapter. Every adapter runs this. + * + * Imports vitest, so it ships from the './conformance' subpath ONLY and is never + * reachable from the main barrel. Consumers supply their own vitest. + * + * Usage from another package: + * import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'; + * runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] }); + */ +export function runAdapterConformance(opts: ConformanceOptions): void { + const make = async () => await opts.makeAdapter(); + + describe('MessagingAdapter conformance', () => { + it('lists conversations', async () => { + const a = await make(); + const list = await a.listConversations(); + expect(Array.isArray(list)).toBe(true); + }); + + it('returns history for a seeded thread', async () => { + const a = await make(); + const msgs = await a.history(opts.seededThreadId); + expect(Array.isArray(msgs)).toBe(true); + }); + + it('send resolves with a message carrying the sent text and a stable id', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'conformance hello'); + expect(m.text).toBe('conformance hello'); + expect(typeof m.id).toBe('string'); + expect(m.id.length).toBeGreaterThan(0); + }); + + it('a sent message is attributed to the current actor', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'whose is this'); + expect(m.actorId).toBe(a.currentActorId()); + }); + + it('currentActorId is known BEFORE any message is sent', async () => { + // The bug this SDK exists to kill: identity must come from auth, never be + // inferred from history. A fresh adapter already knows who you are. + const a = await make(); + expect(a.currentActorId()).not.toBeNull(); + }); + + it('a sent message appears in history', async () => { + const a = await make(); + await a.send(opts.seededThreadId, 'persist me'); + const msgs = await a.history(opts.seededThreadId); + expect(msgs.some((m) => m.text === 'persist me')).toBe(true); + }); + + it('subscribe delivers a message event on send', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + await a.send(opts.seededThreadId, 'live one'); + off(); + const msgs = seen.filter((e) => e.kind === 'message'); + expect(msgs.length).toBeGreaterThan(0); + }); + + it('unsubscribe stops delivery', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + off(); + await a.send(opts.seededThreadId, 'should not be heard'); + expect(seen).toHaveLength(0); + }); + + it('unsubscribe is idempotent', async () => { + const a = await make(); + const off = a.subscribe(opts.seededThreadId, () => {}); + off(); + expect(() => off()).not.toThrow(); + }); + + it('openThread returns a thread id', async () => { + const a = await make(); + const { threadId } = await a.openThread({ participantIds: opts.openWith }); + expect(typeof threadId).toBe('string'); + expect(threadId.length).toBeGreaterThan(0); + }); + + it('markRead resolves', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'read me'); + await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined(); + }); + + it('sendTyping does not throw', async () => { + const a = await make(); + expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow(); + }); + }); +}