@@ -1,5 +1,5 @@ 1 1 import { afterEach , describe , expect , test } from 'bun:test' 2 - import { BlueskyPublishingDriver } from '../../storage/framework/core/socials/src/drivers/bluesky' 2 + import { BlueskyPublishingDriver , detectFacetCandidates } from '../../storage/framework/core/socials/src/drivers/bluesky' 3 3 4 4 const realFetch = globalThis . fetch 5 5 ⋯ 9 lines Show the 9 lines above this hunk @@ -15,6 +15,22 @@ interface CapturedRequest { 15 15 function mockBlueskyApi ( captured : CapturedRequest [ ] ) : void { 16 16 globalThis . fetch = ( async ( input : any , init ? : any ) => { 17 17 const url = String ( input ) 18 + 19 + if ( url . includes ( 'resolveHandle' ) ) { 20 + captured . push ( { url , body : undefined } ) 21 + const handle = new URL ( url ) . searchParams . get ( 'handle' ) 22 + if ( handle === 'ghost.example.com' ) 23 + return new Response ( JSON . stringify ( { error : 'not found' } ) , { status : 400 } ) 24 + return new Response ( JSON . stringify ( { did : `did:plc:${handle}` } ) , { status : 200 } ) 25 + } 26 + 27 + if ( url . includes ( 'uploadBlob' ) ) { 28 + captured . push ( { url , body : { mimeType : init ? . headers ? . [ 'content-type' ] , size : init ? . body ? . length } } ) 29 + return new Response ( JSON . stringify ( { 30 + blob : { $type : 'blob' , ref : { $link : `blob${captured.length}` } , mimeType : init ? . headers ? . [ 'content-type' ] , size : init ? . body ? . length } , 31 + } ) , { status : 200 , headers : { 'content-type' : 'application/json' } } ) 32 + } 33 + 18 34 const body = init ? . body ? JSON . parse ( String ( init . body ) ) : undefined 19 35 captured . push ( { url , body } ) 20 36 return new Response ( JSON . stringify ( { ⋯ 3 lines Show the 3 lines above this hunk @@ -24,6 +40,10 @@ function mockBlueskyApi(captured: CapturedRequest[]): void { 24 40 } ) as typeof fetch 25 41 } 26 42 43 + function lastRecord ( captured : CapturedRequest [ ] ) : any { 44 + return captured . find ( request => request . url . includes ( 'createRecord' ) ) ? . body ? . record 45 + } 46 + 27 47 const identity = { 28 48 handle : 'tester.bsky.social' , 29 49 did : 'did:plc:test' , ⋯ 64 lines Show the 64 lines above this hunk @@ -94,4 +114,87 @@ describe('BlueskyPublishingDriver.publish', () => { 94 114 await expect ( driver . publish ( { handle : 'tester.bsky.social' } , { text : 'hi' } ) ) . rejects . toThrow ( / access token / ) 95 115 expect ( captured ) . toHaveLength ( 0 ) 96 116 } ) 117 + 118 + test ( 'auto-builds link, tag, and mention facets with resolved DIDs' , async ( ) => { 119 + const captured : CapturedRequest [ ] = [ ] 120 + mockBlueskyApi ( captured ) 121 + 122 + const driver = new BlueskyPublishingDriver ( ) 123 + await driver . publish ( identity , { text : 'Read https://example.com/x. Thanks @alice.bsky.social #stacks' } ) 124 + 125 + const record = lastRecord ( captured ) 126 + expect ( record . facets ) . toHaveLength ( 3 ) 127 + const features = record . facets . map ( ( facet : any ) => facet . features [ 0 ] ) 128 + expect ( features [ 0 ] ) . toEqual ( { $type : 'app.bsky.richtext.facet#link' , uri : 'https://example.com/x' } ) 129 + expect ( features [ 1 ] ) . toEqual ( { $type : 'app.bsky.richtext.facet#mention' , did : 'did:plc:alice.bsky.social' } ) 130 + expect ( features [ 2 ] ) . toEqual ( { $type : 'app.bsky.richtext.facet#tag' , tag : 'stacks' } ) 131 + } ) 132 + 133 + test ( 'drops mention facets whose handle does not resolve' , async ( ) => { 134 + const captured : CapturedRequest [ ] = [ ] 135 + mockBlueskyApi ( captured ) 136 + 137 + const driver = new BlueskyPublishingDriver ( ) 138 + await driver . publish ( identity , { text : 'cc @ghost.example.com' } ) 139 + 140 + expect ( lastRecord ( captured ) . facets ) . toBeUndefined ( ) 141 + } ) 142 + 143 + test ( 'uploads image bytes and embeds them, overriding the link card' , async ( ) => { 144 + const captured : CapturedRequest [ ] = [ ] 145 + mockBlueskyApi ( captured ) 146 + 147 + const driver = new BlueskyPublishingDriver ( ) 148 + await driver . publish ( identity , { 149 + text : 'photo time' , 150 + external : { uri : 'https://example.com' , title : 'Example' } , 151 + media : [ { bytes : new Uint8Array ( [ 1 , 2 , 3 ] ) , mimeType : 'image/png' , altText : 'a chart' } ] , 152 + } ) 153 + 154 + const upload = captured . find ( request => request . url . includes ( 'uploadBlob' ) ) 155 + expect ( upload ? . body . mimeType ) . toBe ( 'image/png' ) 156 + const record = lastRecord ( captured ) 157 + expect ( record . embed . $type ) . toBe ( 'app.bsky.embed.images' ) 158 + expect ( record . embed . images [ 0 ] . alt ) . toBe ( 'a chart' ) 159 + expect ( record . embed . images [ 0 ] . image . ref . $link ) . toBeDefined ( ) 160 + } ) 161 + 162 + test ( 'uploadBlob rejects images over 1MB' , async ( ) => { 163 + const captured : CapturedRequest [ ] = [ ] 164 + mockBlueskyApi ( captured ) 165 + 166 + const driver = new BlueskyPublishingDriver ( ) 167 + await expect ( driver . uploadBlob ( identity , new Uint8Array ( 1 _000_001 ) , 'image/png' ) ) . rejects . toThrow ( / 1 MB / ) 168 + expect ( captured ) . toHaveLength ( 0 ) 169 + } ) 170 + } ) 171 + 172 + describe ( 'detectFacetCandidates' , ( ) => { 173 + test ( 'computes UTF-8 byte offsets, not character offsets' , ( ) => { 174 + const text = '🦋 fly to https://bsky.app now' 175 + const [ link ] = detectFacetCandidates ( text ) 176 + 177 + expect ( link . byteStart ) . toBe ( 12 ) 178 + expect ( link . byteEnd ) . toBe ( 12 + 'https://bsky.app' . length ) 179 + expect ( link . value ) . toBe ( 'https://bsky.app' ) 180 + } ) 181 + 182 + test ( 'trims trailing punctuation from links' , ( ) => { 183 + const [ link ] = detectFacetCandidates ( 'see https://example.com/docs.' ) 184 + expect ( link . value ) . toBe ( 'https://example.com/docs' ) 185 + } ) 186 + 187 + test ( 'skips numeric-only hashtags and tags inside links' , ( ) => { 188 + const candidates = detectFacetCandidates ( '#2024 https://example.com/#anchor #real' ) 189 + expect ( candidates . map ( candidate => `${candidate.type}:${candidate.value}` ) ) . toEqual ( [ 190 + 'link:https://example.com/#anchor' , 191 + 'tag:real' , 192 + ] ) 193 + } ) 194 + 195 + test ( 'mentions require a domain-shaped handle' , ( ) => { 196 + const candidates = detectFacetCandidates ( 'hey @nodot and @real.handle.social' ) 197 + expect ( candidates ) . toHaveLength ( 1 ) 198 + expect ( candidates [ 0 ] ) . toMatchObject ( { type : 'mention' , value : 'real.handle.social' } ) 199 + } ) 97 200 } )