@@ -1551,3 +1551,239 @@ it("blocks Lark IME and duplicate writes and aborts on brand switch", async () =
15511551 expect ( host . textContent ) . not . toContain ( "lark-new" ) ;
15521552 expect ( host . textContent ) . toContain ( "channels.accountsTitle" ) ;
15531553} ) ;
1554+
1555+ function bindingTypesFixture (
1556+ types : unknown = [ "shared" , "private" ] ,
1557+ lark = false ,
1558+ ) {
1559+ if ( lark ) larkFixture ( ) ;
1560+ else multiBotFixture ( ) ;
1561+ const original = request . getMockImplementation ( ) ! ;
1562+ request . mockImplementation ( async ( ep , path , init ) => {
1563+ if ( path === "/capabilities" )
1564+ return { ...( await original ( ep , path , init ) ) , botBindingTypes : types } ;
1565+ if ( path === "/feishu/bindings" ) return pending ;
1566+ if ( path . endsWith ( "/retry" ) ) return { ...pending , status : "BOUND" , appId : "bot-new" } ;
1567+ return original ( ep , path , init ) ;
1568+ } ) ;
1569+ }
1570+ const typeInput = ( value : string ) =>
1571+ host . querySelector < HTMLInputElement > ( `input[type="radio"][value="${ value } "]` ) ;
1572+ async function chooseType ( value : string ) {
1573+ expect ( typeInput ( value ) ) . not . toBeNull ( ) ;
1574+ await act ( async ( ) => typeInput ( value ) ! . click ( ) ) ;
1575+ }
1576+ async function submitCredentials ( ) {
1577+ await selectMethod ( "manual" ) ;
1578+ await fillCredential ( "appId" , " bot-new " ) ;
1579+ await fillCredential ( "secret" , "fixture-secret" ) ;
1580+ await act ( async ( ) =>
1581+ host
1582+ . querySelector ( "form[data-channel-binding]" ) !
1583+ . dispatchEvent ( new Event ( "submit" , { bubbles : true , cancelable : true } ) ) ,
1584+ ) ;
1585+ }
1586+ function lastBody ( path : string ) {
1587+ const calls = request . mock . calls . filter (
1588+ ( [ , value , init ] ) => value === path && init ?. method === "POST" ,
1589+ ) ;
1590+ expect ( calls . length ) . toBeGreaterThan ( 0 ) ;
1591+ return JSON . parse ( String ( calls . at ( - 1 ) ! [ 2 ] ! . body ) ) ;
1592+ }
1593+
1594+ it . each ( [ "shared" , "private" ] ) (
1595+ "sends the selected %s type for QR and manual binding" ,
1596+ async ( type ) => {
1597+ bindingTypesFixture ( ) ;
1598+ await render ( ) ;
1599+ await click ( "channels.addAccount" ) ;
1600+ expect ( typeInput ( "shared" ) ?. checked ) . toBe ( true ) ;
1601+ await chooseType ( type ) ;
1602+ expect ( host . textContent ) . toContain ( `channels.bindingTypeHelp_${ type } ` ) ;
1603+ await click ( "channels.bind" ) ;
1604+ expect ( lastBody ( "/feishu/bindings" ) ) . toEqual ( { bindingType : type } ) ;
1605+ expect ( typeInput ( "shared" ) ?. disabled ) . toBe ( true ) ;
1606+ expect ( typeInput ( "private" ) ?. disabled ) . toBe ( true ) ;
1607+ await submitCredentials ( ) ;
1608+ expect ( lastBody ( "/feishu/bindings/manual" ) ) . toEqual ( {
1609+ appId : "bot-new" ,
1610+ appSecret : "fixture-secret" ,
1611+ bindingType : type ,
1612+ } ) ;
1613+ expect ( localStorage . setItem ) . not . toHaveBeenCalled ( ) ;
1614+ } ,
1615+ ) ;
1616+
1617+ it . each ( [ undefined , [ ] , [ "future" ] , null , "private" ] ) (
1618+ "keeps legacy payloads when types are unsupported: %j" ,
1619+ async ( types ) => {
1620+ bindingTypesFixture ( types === undefined ? [ ] : types ) ;
1621+ if ( types === undefined ) {
1622+ const original = request . getMockImplementation ( ) ! ;
1623+ request . mockImplementation ( async ( ep , path , init ) => {
1624+ const data = await original ( ep , path , init ) ;
1625+ if ( path === "/capabilities" ) delete data . botBindingTypes ;
1626+ return data ;
1627+ } ) ;
1628+ }
1629+ await render ( ) ;
1630+ await click ( "channels.addAccount" ) ;
1631+ expect ( typeInput ( "private" ) ) . toBeNull ( ) ;
1632+ expect ( typeInput ( "shared" ) ) . toBeNull ( ) ;
1633+ await click ( "channels.bind" ) ;
1634+ expect ( lastBody ( "/feishu/bindings" ) ) . toEqual ( { } ) ;
1635+ await submitCredentials ( ) ;
1636+ expect ( lastBody ( "/feishu/bindings/manual" ) ) . toEqual ( {
1637+ appId : "bot-new" ,
1638+ appSecret : "fixture-secret" ,
1639+ } ) ;
1640+ } ,
1641+ ) ;
1642+
1643+ it . each ( [ [ "private" ] , [ "future" , "private" ] , [ "private" , "private" ] ] ) (
1644+ "only offers advertised known types: %j" ,
1645+ async ( ...types ) => {
1646+ bindingTypesFixture ( types ) ;
1647+ await render ( ) ;
1648+ await click ( "channels.addAccount" ) ;
1649+ expect ( typeInput ( "shared" ) ) . toBeNull ( ) ;
1650+ expect ( typeInput ( "private" ) ?. checked ) . toBe ( true ) ;
1651+ expect ( host . querySelectorAll ( 'input[value="private"]' ) ) . toHaveLength ( 1 ) ;
1652+ await click ( "channels.bind" ) ;
1653+ expect ( lastBody ( "/feishu/bindings" ) ) . toEqual ( { bindingType : "private" } ) ;
1654+ } ,
1655+ ) ;
1656+
1657+ it ( "preserves private type when retrying a durable failed QR binding" , async ( ) => {
1658+ bindingTypesFixture ( ) ;
1659+ const original = request . getMockImplementation ( ) ! ;
1660+ request . mockImplementation ( async ( ep , path , init ) =>
1661+ path === "/feishu/bindings"
1662+ ? { ...pending , status : "FAILED" , lastErrorCode : "REGISTRATION_FAILED" }
1663+ : original ( ep , path , init ) ,
1664+ ) ;
1665+ await render ( ) ;
1666+ await click ( "channels.addAccount" ) ;
1667+ await chooseType ( "private" ) ;
1668+ await click ( "channels.bind" ) ;
1669+ expect ( typeInput ( "shared" ) ?. disabled ) . toBe ( true ) ;
1670+ await chooseType ( "shared" ) ;
1671+ expect ( typeInput ( "private" ) ?. checked ) . toBe ( true ) ;
1672+ await click ( "common.retry" ) ;
1673+ expect ( lastBody ( "/feishu/bindings/binding-1/retry" ) ) . toEqual ( {
1674+ bindingType : "private" ,
1675+ } ) ;
1676+ } ) ;
1677+
1678+ it ( "locks selection while sending and preserves it after a recoverable error" , async ( ) => {
1679+ bindingTypesFixture ( ) ;
1680+ const original = request . getMockImplementation ( ) ! ;
1681+ let reject ! : ( reason : unknown ) => void ;
1682+ request . mockImplementation ( ( ep , path , init ) =>
1683+ path === "/feishu/bindings"
1684+ ? new Promise ( ( _resolve , fail ) => {
1685+ reject = fail ;
1686+ } )
1687+ : original ( ep , path , init ) ,
1688+ ) ;
1689+ await render ( ) ;
1690+ await click ( "channels.addAccount" ) ;
1691+ await chooseType ( "private" ) ;
1692+ await click ( "channels.bind" ) ;
1693+ expect ( typeInput ( "shared" ) ?. disabled ) . toBe ( true ) ;
1694+ await act ( async ( ) => reject ( new ChannelApiError ( 502 ) ) ) ;
1695+ expect ( host . textContent ) . toContain ( "channels.requestFailed" ) ;
1696+ expect ( typeInput ( "private" ) ?. checked ) . toBe ( true ) ;
1697+ expect ( typeInput ( "shared" ) ?. disabled ) . toBe ( false ) ;
1698+ } ) ;
1699+
1700+ it ( "resets type when cancelling Add bot or changing Runtime and ignores late capabilities" , async ( ) => {
1701+ bindingTypesFixture ( ) ;
1702+ await render ( ) ;
1703+ await click ( "channels.addAccount" ) ;
1704+ await chooseType ( "private" ) ;
1705+ await click ( "channels.cancelAddAccount" ) ;
1706+ await click ( "channels.addAccount" ) ;
1707+ expect ( typeInput ( "shared" ) ?. checked ) . toBe ( true ) ;
1708+ await chooseType ( "private" ) ;
1709+ const original = request . getMockImplementation ( ) ! ;
1710+ let finish ! : ( value : unknown ) => void ;
1711+ let signal : AbortSignal | null | undefined ;
1712+ request . mockImplementation ( ( ep , path , init ) =>
1713+ ep . runtimeId === "runtime-slow" && path === "/capabilities"
1714+ ? new Promise ( ( resolve ) => {
1715+ finish = resolve ;
1716+ signal = init ?. signal ;
1717+ } )
1718+ : original ( ep , path , init ) ,
1719+ ) ;
1720+ await act ( async ( ) =>
1721+ root . render (
1722+ < RuntimeChannels runtimeId = "runtime-slow" region = "cn-beijing" /> ,
1723+ ) ,
1724+ ) ;
1725+ expect ( typeInput ( "private" ) ) . toBeNull ( ) ;
1726+ await act ( async ( ) =>
1727+ root . render (
1728+ < RuntimeChannels runtimeId = "runtime-new" region = "cn-beijing" /> ,
1729+ ) ,
1730+ ) ;
1731+ expect ( signal ?. aborted ) . toBe ( true ) ;
1732+ await act ( async ( ) =>
1733+ finish ( {
1734+ serverSideBinding : true ,
1735+ bindingReady : true ,
1736+ botBindingTypes : [ "private" ] ,
1737+ } ) ,
1738+ ) ;
1739+ await click ( "channels.addAccount" ) ;
1740+ expect ( typeInput ( "shared" ) ?. checked ) . toBe ( true ) ;
1741+ expect (
1742+ request . mock . calls . filter ( ( [ , , init ] ) => init ?. method === "POST" ) ,
1743+ ) . toHaveLength ( 0 ) ;
1744+ } ) ;
1745+
1746+ it ( "does not expose an editable type for existing accounts or other providers" , async ( ) => {
1747+ bindingTypesFixture ( ) ;
1748+ await render ( ) ;
1749+ await selectBot ( "bot-a" ) ;
1750+ expect ( typeInput ( "private" ) ) . toBeNull ( ) ;
1751+ await click ( "channels.provider_wecom" ) ;
1752+ expect ( typeInput ( "private" ) ) . toBeNull ( ) ;
1753+ await click ( "channels.provider_dingtalk" ) ;
1754+ expect ( typeInput ( "private" ) ) . toBeNull ( ) ;
1755+ } ) ;
1756+
1757+ it ( "retains Lark domain alongside private type in QR, retry and manual requests" , async ( ) => {
1758+ bindingTypesFixture ( [ "shared" , "private" ] , true ) ;
1759+ const original = request . getMockImplementation ( ) ! ;
1760+ request . mockImplementation ( async ( ep , path , init ) =>
1761+ path === "/feishu/bindings"
1762+ ? { ...pending , status : "FAILED" , lastErrorCode : "REGISTRATION_FAILED" }
1763+ : original ( ep , path , init ) ,
1764+ ) ;
1765+ await renderLark ( ) ;
1766+ await click ( "channels.addAccount" ) ;
1767+ await chooseType ( "private" ) ;
1768+ await click ( "channels.bind" ) ;
1769+ expect ( lastBody ( "/feishu/bindings" ) ) . toEqual ( {
1770+ domain : "lark" ,
1771+ bindingType : "private" ,
1772+ } ) ;
1773+ await click ( "common.retry" ) ;
1774+ expect ( lastBody ( "/feishu/bindings/binding-1/retry" ) ) . toEqual ( {
1775+ domain : "lark" ,
1776+ bindingType : "private" ,
1777+ } ) ;
1778+ // The successful retry closes the add panel; a new binding chooses its own type.
1779+ await click ( "channels.addAccount" ) ;
1780+ expect ( typeInput ( "shared" ) ?. checked ) . toBe ( true ) ;
1781+ await chooseType ( "private" ) ;
1782+ await submitCredentials ( ) ;
1783+ expect ( lastBody ( "/feishu/bindings/manual" ) ) . toEqual ( {
1784+ appId : "bot-new" ,
1785+ appSecret : "fixture-secret" ,
1786+ domain : "lark" ,
1787+ bindingType : "private" ,
1788+ } ) ;
1789+ } ) ;
0 commit comments