@@ -30,19 +30,24 @@ function now() {
3030/** Create a mock ChannelAdapter for testing. */
3131function createMockAdapter (
3232 channelType : string ,
33- ) : ChannelAdapter & { delivered : OutboundMessage [ ] ; inbound : InboundMessage [ ] } {
33+ instance ?: string ,
34+ ) : ChannelAdapter & { delivered : OutboundMessage [ ] ; inbound : InboundMessage [ ] ; setupTimes : number [ ] } {
3435 const delivered : OutboundMessage [ ] = [ ] ;
3536 const inbound : InboundMessage [ ] = [ ] ;
37+ const setupTimes : number [ ] = [ ] ;
3638 let setupConfig : ChannelSetup | null = null ;
3739
3840 return {
39- name : channelType ,
41+ name : instance ?? channelType ,
4042 channelType,
43+ instance,
4144 supportsThreads : false ,
4245 delivered,
4346 inbound,
47+ setupTimes,
4448
4549 async setup ( config : ChannelSetup ) {
50+ setupTimes . push ( Date . now ( ) ) ;
4651 setupConfig = config ;
4752 } ,
4853
@@ -117,6 +122,117 @@ describe('channel registry', () => {
117122 } ) ;
118123} ) ;
119124
125+ describe ( 'channel registry — instance keying' , ( ) => {
126+ // Fresh module per test: the registry and activeAdapters maps are
127+ // module-level, and these arms register conflicting same-channelType
128+ // adapters that must not leak across tests.
129+ beforeEach ( ( ) => {
130+ vi . resetModules ( ) ;
131+ } ) ;
132+
133+ afterEach ( async ( ) => {
134+ const { teardownChannelAdapters } = await import ( './channel-registry.js' ) ;
135+ await teardownChannelAdapters ( ) ;
136+ // Drop this test's registrations so later describe blocks (which import
137+ // the registry without resetting) start from an empty registry instead
138+ // of inheriting same-channelType pairs.
139+ vi . resetModules ( ) ;
140+ } ) ;
141+
142+ const mockSetup = ( ) => ( {
143+ onInbound : ( ) => { } ,
144+ onInboundEvent : ( ) => { } ,
145+ onMetadata : ( ) => { } ,
146+ onAction : ( ) => { } ,
147+ } ) ;
148+
149+ it ( 'keys two same-channelType adapters by instance — both resolvable' , async ( ) => {
150+ const reg = await import ( './channel-registry.js' ) ;
151+ const worker = createMockAdapter ( 'slack' , 'slack-worker' ) ;
152+ const tester = createMockAdapter ( 'slack' , 'slack-tester' ) ;
153+ reg . registerChannelAdapter ( 'slack-worker' , { factory : ( ) => worker } ) ;
154+ reg . registerChannelAdapter ( 'slack-tester' , { factory : ( ) => tester } ) ;
155+
156+ await reg . initChannelAdapters ( mockSetup ) ;
157+
158+ expect ( reg . getChannelAdapter ( 'slack-worker' ) ) . toBe ( worker ) ;
159+ expect ( reg . getChannelAdapter ( 'slack-tester' ) ) . toBe ( tester ) ;
160+ expect ( reg . getActiveAdapters ( ) ) . toHaveLength ( 2 ) ;
161+ } ) ;
162+
163+ it ( 'resolves channelType to the default-instance adapter when one exists, else first-registered' , async ( ) => {
164+ const reg = await import ( './channel-registry.js' ) ;
165+ const named = createMockAdapter ( 'slack' , 'slack-tester' ) ;
166+ const unnamed = createMockAdapter ( 'slack' ) ;
167+ reg . registerChannelAdapter ( 'slack-tester' , { factory : ( ) => named } ) ;
168+ reg . registerChannelAdapter ( 'slack' , { factory : ( ) => unnamed } ) ;
169+
170+ await reg . initChannelAdapters ( mockSetup ) ;
171+
172+ // Exact key (default instance keyed by channelType) beats the fallback
173+ // scan, even though the named sibling registered first.
174+ expect ( reg . getChannelAdapter ( 'slack' ) ) . toBe ( unnamed ) ;
175+
176+ // With ONLY named instances active, channelType still resolves —
177+ // deterministic first-registered fallback.
178+ await reg . teardownChannelAdapters ( ) ;
179+ vi . resetModules ( ) ;
180+ const reg2 = await import ( './channel-registry.js' ) ;
181+ const first = createMockAdapter ( 'slack' , 'slack-tester' ) ;
182+ const second = createMockAdapter ( 'slack' , 'slack-worker' ) ;
183+ reg2 . registerChannelAdapter ( 'slack-tester' , { factory : ( ) => first } ) ;
184+ reg2 . registerChannelAdapter ( 'slack-worker' , { factory : ( ) => second } ) ;
185+ await reg2 . initChannelAdapters ( mockSetup ) ;
186+ expect ( reg2 . getChannelAdapter ( 'slack' ) ) . toBe ( first ) ;
187+ } ) ;
188+
189+ it ( 'does NOT reroute default-instance outbound through a named sibling when the default adapter is missing' , async ( ) => {
190+ // The default Slack app is offline (token rotated, factory returned
191+ // null, …) while a named sibling boots fine. Outbound for the default
192+ // instance must get the offline-adapter handling (drop into the retry
193+ // path) — NEVER a cross-identity send through the sibling bot.
194+ const reg = await import ( './channel-registry.js' ) ;
195+ const tester = createMockAdapter ( 'slack' , 'slack-tester' ) ;
196+ reg . registerChannelAdapter ( 'slack-tester' , { factory : ( ) => tester } ) ;
197+ reg . registerChannelAdapter ( 'slack' , { factory : ( ) => null } ) ;
198+
199+ await reg . initChannelAdapters ( mockSetup ) ;
200+
201+ // Exact lookup (delivery/typing path): the default key resolves nothing.
202+ expect ( reg . getChannelAdapterExact ( 'slack' ) ) . toBeUndefined ( ) ;
203+ // Fallback-capable lookup (channelType-only callers) still resolves.
204+ expect ( reg . getChannelAdapter ( 'slack' ) ) . toBe ( tester ) ;
205+
206+ // The delivery bridge dispatches by exact key: a default-instance
207+ // message (instance === channelType after backfill) is dropped, not
208+ // delivered through the sibling's identity.
209+ const bridge = reg . createChannelDeliveryAdapter ( ) ;
210+ const result = await bridge . deliver (
211+ 'slack' ,
212+ 'slack:C1' ,
213+ null ,
214+ 'chat' ,
215+ JSON . stringify ( { text : 'to the default bot' } ) ,
216+ undefined ,
217+ 'slack' ,
218+ ) ;
219+ expect ( result ) . toBeUndefined ( ) ;
220+ expect ( tester . delivered ) . toHaveLength ( 0 ) ;
221+
222+ // Sanity: the same bridge DOES deliver when the exact instance is live.
223+ await bridge . deliver (
224+ 'slack' ,
225+ 'slack:C1' ,
226+ null ,
227+ 'chat' ,
228+ JSON . stringify ( { text : 'to the tester bot' } ) ,
229+ undefined ,
230+ 'slack-tester' ,
231+ ) ;
232+ expect ( tester . delivered ) . toHaveLength ( 1 ) ;
233+ } ) ;
234+ } ) ;
235+
120236describe ( 'channel + router integration' , ( ) => {
121237 beforeEach ( async ( ) => {
122238 if ( fs . existsSync ( TEST_DIR ) ) fs . rmSync ( TEST_DIR , { recursive : true } ) ;
0 commit comments