| @@ -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 | |
| @@ -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({ |
| @@ -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', |
| @@ -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(/1MB/) |
| 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 | }) |