A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 13 kB View raw
1import { describe, expect, it, vi } from 'vitest'; 2import type { Agent } from '@atproto/api'; 3import { 4 listPublications, 5 listAllPublications, 6 createPublication, 7 updatePublication, 8 deletePublication, 9} from './publications'; 10import { SITE_BASE } from './records'; 11import { THEME_PRESETS } from './themes'; 12import type { BlobRefJson } from '../media/blob'; 13 14const DID = 'did:plc:me'; 15 16function pubRecord( rkey: string, url: string, extra: Record< string, unknown > = {} ) { 17 return { 18 uri: `at://${ DID }/site.standard.publication/${ rkey }`, 19 cid: `bafy-${ rkey }`, 20 value: { $type: 'site.standard.publication', url, name: `Pub ${ rkey }`, ...extra }, 21 }; 22} 23function docRecord( rkey: string, site: string, extra: Record< string, unknown > = {} ) { 24 return { 25 uri: `at://${ DID }/site.standard.document/${ rkey }`, 26 cid: `bafy-${ rkey }`, 27 value: { $type: 'site.standard.document', site, title: `Doc ${ rkey }`, ...extra }, 28 }; 29} 30 31/** A mock Agent whose `com.atproto.repo` records the calls made against it. */ 32function mockAgent( byCollection: Record< string, unknown[] > = {} ) { 33 const created: Array< { collection: string; rkey?: string; record: Record< string, unknown > } > = []; 34 const put: Array< { collection: string; rkey: string; record: Record< string, unknown > } > = []; 35 const deleted: Array< { collection: string; rkey: string } > = []; 36 const agent = { 37 com: { 38 atproto: { 39 repo: { 40 listRecords: vi.fn( async ( { collection }: { collection: string } ) => ( { 41 data: { records: byCollection[ collection ] ?? [] }, 42 } ) ), 43 createRecord: vi.fn( 44 async ( { 45 collection, 46 rkey, 47 record, 48 }: { 49 collection: string; 50 rkey?: string; 51 record: Record< string, unknown >; 52 } ) => { 53 const id = rkey ?? 'generated'; 54 created.push( { collection, rkey, record } ); 55 return { data: { uri: `at://${ DID }/${ collection }/${ id }`, cid: 'bafy-new' } }; 56 } 57 ), 58 putRecord: vi.fn( 59 async ( { 60 collection, 61 rkey, 62 record, 63 }: { 64 collection: string; 65 rkey: string; 66 record: Record< string, unknown >; 67 } ) => { 68 put.push( { collection, rkey, record } ); 69 return { data: { uri: `at://${ DID }/${ collection }/${ rkey }`, cid: 'bafy-put' } }; 70 } 71 ), 72 deleteRecord: vi.fn( 73 async ( { collection, rkey }: { collection: string; rkey: string } ) => { 74 deleted.push( { collection, rkey } ); 75 return {}; 76 } 77 ), 78 }, 79 }, 80 }, 81 } as unknown as Agent; 82 return { agent, created, put, deleted }; 83} 84 85describe( 'listPublications', () => { 86 it( 'returns only SkyPress-origin publications, mapped with their slug', async () => { 87 const { agent } = mockAgent( { 88 'site.standard.publication': [ 89 pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog`, { description: 'Hi' } ), 90 pubRecord( 'b', 'https://leaflet.pub/lish/did:plc:me/xyz' ), // foreign → excluded 91 pubRecord( 'c', `${ SITE_BASE }/@me.bsky.social` ), // slugless → excluded 92 ], 93 } ); 94 const pubs = await listPublications( agent, DID ); 95 expect( pubs ).toHaveLength( 1 ); 96 expect( pubs[ 0 ] ).toMatchObject( { 97 uri: `at://${ DID }/site.standard.publication/a`, 98 rkey: 'a', 99 slug: 'my-blog', 100 name: 'Pub a', 101 description: 'Hi', 102 } ); 103 } ); 104 105 it( 'normalises an icon the @atproto/api client deserialised into a BlobRef', async () => { 106 // The live agent runs `jsonToLex` over responses, turning the stored icon blob into a 107 // BlobRef instance whose `ref` is a CID OBJECT (no `$link`) — not the JSON shape. The 108 // dashboard reads `icon.ref.$link`, so an un-normalised icon yields `cid=undefined`. 109 const cidObject = { toString: () => 'bafyrealcid' }; // stands in for a multiformats CID 110 const { agent } = mockAgent( { 111 'site.standard.publication': [ 112 pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog`, { 113 icon: { $type: 'blob', ref: cidObject, mimeType: 'image/png', size: 42 }, 114 } ), 115 ], 116 } ); 117 const pubs = await listPublications( agent, DID ); 118 expect( pubs[ 0 ].icon ).toEqual( { 119 $type: 'blob', 120 ref: { $link: 'bafyrealcid' }, 121 mimeType: 'image/png', 122 size: 42, 123 } ); 124 } ); 125 126 it( 'keeps an icon already stored in the JSON ($link) shape', async () => { 127 const { agent } = mockAgent( { 128 'site.standard.publication': [ 129 pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog`, { 130 icon: { $type: 'blob', ref: { $link: 'bafyjson' }, mimeType: 'image/png', size: 7 }, 131 } ), 132 ], 133 } ); 134 const pubs = await listPublications( agent, DID ); 135 expect( pubs[ 0 ].icon?.ref.$link ).toBe( 'bafyjson' ); 136 } ); 137} ); 138 139describe( 'listAllPublications', () => { 140 it( 'partitions owned publications from foreign ones', async () => { 141 const { agent } = mockAgent( { 142 'site.standard.publication': [ 143 pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog` ), 144 pubRecord( 'b', 'https://leaflet.pub/lish/did:plc:me/xyz' ), 145 ], 146 } ); 147 const { owned, foreign } = await listAllPublications( agent, DID ); 148 expect( owned ).toHaveLength( 1 ); 149 expect( owned[ 0 ] ).toMatchObject( { rkey: 'a', slug: 'my-blog' } ); 150 expect( foreign ).toHaveLength( 1 ); 151 expect( foreign[ 0 ] ).toMatchObject( { 152 uri: `at://${ DID }/site.standard.publication/b`, 153 name: 'Pub b', 154 hostname: 'leaflet.pub', 155 url: 'https://leaflet.pub/lish/did:plc:me/xyz', 156 } ); 157 } ); 158 159 it( 'tags each foreign publication with its detected provider', async () => { 160 const { agent } = mockAgent( { 161 'site.standard.publication': [ 162 pubRecord( 'a', 'https://jehervecom.leaflet.pub' ), 163 pubRecord( 'b', 'https://my-domain.example', { theme: { $type: 'blog.pckt.theme' } } ), 164 pubRecord( 'c', 'https://unknown.example' ), 165 ], 166 } ); 167 const { foreign } = await listAllPublications( agent, DID ); 168 const byRkey = Object.fromEntries( foreign.map( ( p ) => [ p.uri.split( '/' ).pop(), p.provider ] ) ); 169 expect( byRkey ).toEqual( { a: 'leaflet', b: 'pckt', c: null } ); 170 } ); 171 172 it( 'drops a slugless SkyPress-origin record from BOTH buckets', async () => { 173 const { agent } = mockAgent( { 174 'site.standard.publication': [ pubRecord( 'c', `${ SITE_BASE }/@me.bsky.social` ) ], 175 } ); 176 const { owned, foreign } = await listAllPublications( agent, DID ); 177 expect( owned ).toHaveLength( 0 ); 178 expect( foreign ).toHaveLength( 0 ); 179 } ); 180 181 it( 'excludes records with a missing or non-http(s) url from foreign', async () => { 182 const { agent } = mockAgent( { 183 'site.standard.publication': [ 184 pubRecord( 'd', 'at://did:plc:other/site.standard.publication/d' ), 185 { 186 uri: `at://${ DID }/site.standard.publication/e`, 187 cid: 'bafy-e', 188 value: { $type: 'site.standard.publication', name: 'No URL' }, 189 }, 190 ], 191 } ); 192 const { owned, foreign } = await listAllPublications( agent, DID ); 193 expect( owned ).toHaveLength( 0 ); 194 expect( foreign ).toHaveLength( 0 ); 195 } ); 196 197 it( 'normalises a foreign icon to the portable $link shape', async () => { 198 const cidObject = { toString: () => 'bafyforeign' }; 199 const { agent } = mockAgent( { 200 'site.standard.publication': [ 201 pubRecord( 'f', 'https://leaflet.pub/lish/did:plc:me/abc', { 202 icon: { $type: 'blob', ref: cidObject, mimeType: 'image/png', size: 9 }, 203 } ), 204 ], 205 } ); 206 const { foreign } = await listAllPublications( agent, DID ); 207 expect( foreign[ 0 ].icon ).toEqual( { 208 $type: 'blob', 209 ref: { $link: 'bafyforeign' }, 210 mimeType: 'image/png', 211 size: 9, 212 } ); 213 } ); 214} ); 215 216describe( 'createPublication', () => { 217 it( 'derives the slug from the name and writes the record under a fresh rkey', async () => { 218 const { agent, created } = mockAgent(); 219 const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'My Blog' } ); 220 expect( pub.slug ).toBe( 'my-blog' ); 221 expect( created ).toHaveLength( 1 ); 222 expect( created[ 0 ].collection ).toBe( 'site.standard.publication' ); 223 expect( created[ 0 ].rkey ).toBeTruthy(); // rkey generated up front 224 expect( created[ 0 ].record.url ).toBe( `${ SITE_BASE }/@me.bsky.social/my-blog` ); 225 } ); 226 227 it( 'de-duplicates the slug against the writer’s existing publications', async () => { 228 const { agent } = mockAgent( { 229 'site.standard.publication': [ pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/my-blog` ) ], 230 } ); 231 const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'My Blog' } ); 232 expect( pub.slug ).toBe( 'my-blog-2' ); 233 } ); 234 235 it( 'falls back to pub-{rkey} for an unslugifiable name', async () => { 236 const { agent, created } = mockAgent(); 237 const pub = await createPublication( agent, DID, 'me.bsky.social', { name: '🎉' } ); 238 expect( pub.slug ).toBe( `pub-${ created[ 0 ].rkey }` ); 239 expect( pub.slug ).toMatch( /^pub-/ ); 240 } ); 241 242 it( 'stores the icon blob when provided', async () => { 243 const { agent, created } = mockAgent(); 244 const icon: BlobRefJson = { 245 $type: 'blob', 246 ref: { $link: 'bafyicon' }, 247 mimeType: 'image/png', 248 size: 10, 249 }; 250 await createPublication( agent, DID, 'me.bsky.social', { name: 'Logo Blog', icon } ); 251 expect( created[ 0 ].record.icon ).toEqual( icon ); 252 } ); 253 254 it( 'writes and returns basicTheme when provided', async () => { 255 const { agent, created } = mockAgent(); 256 const pub = await createPublication( agent, DID, 'me.bsky.social', { 257 name: 'Themed Blog', 258 basicTheme: THEME_PRESETS[ 4 ].colors, // twilight 259 } ); 260 expect( created[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 261 expect( pub.basicTheme ).toEqual( THEME_PRESETS[ 4 ].colors ); 262 } ); 263 264 it( 'omits basicTheme when none is chosen', async () => { 265 const { agent, created } = mockAgent(); 266 const pub = await createPublication( agent, DID, 'me.bsky.social', { name: 'Plain Blog' } ); 267 expect( 'basicTheme' in created[ 0 ].record ).toBe( false ); 268 expect( pub.basicTheme ).toBeUndefined(); 269 } ); 270} ); 271 272describe( 'updatePublication', () => { 273 it( 'preserves the frozen slug while updating name/description', async () => { 274 const { agent, put } = mockAgent(); 275 const existing = { 276 uri: `at://${ DID }/site.standard.publication/a`, 277 cid: 'bafy-a', 278 rkey: 'a', 279 slug: 'my-blog', 280 name: 'My Blog', 281 }; 282 const updated = await updatePublication( agent, DID, 'me.bsky.social', existing, { 283 name: 'Renamed Blog', 284 description: 'Now with a description', 285 } ); 286 expect( put ).toHaveLength( 1 ); 287 expect( put[ 0 ].rkey ).toBe( 'a' ); 288 // URL (and thus slug) unchanged despite the rename. 289 expect( put[ 0 ].record.url ).toBe( `${ SITE_BASE }/@me.bsky.social/my-blog` ); 290 expect( updated.slug ).toBe( 'my-blog' ); 291 expect( updated.name ).toBe( 'Renamed Blog' ); 292 expect( updated.description ).toBe( 'Now with a description' ); 293 } ); 294 295 it( 'writes and returns a newly chosen basicTheme', async () => { 296 const { agent, put } = mockAgent(); 297 const existing = { 298 uri: `at://${ DID }/site.standard.publication/a`, 299 cid: 'bafy-a', 300 rkey: 'a', 301 slug: 'my-blog', 302 name: 'My Blog', 303 }; 304 const updated = await updatePublication( agent, DID, 'me.bsky.social', existing, { 305 name: 'My Blog', 306 basicTheme: THEME_PRESETS[ 1 ].colors, // noon 307 } ); 308 expect( put[ 0 ].record.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 309 expect( updated.basicTheme ).toEqual( THEME_PRESETS[ 1 ].colors ); 310 } ); 311} ); 312 313describe( 'toPublication basicTheme', () => { 314 it( 'surfaces a valid stored basicTheme and drops a malformed one', async () => { 315 const good = THEME_PRESETS[ 2 ].colors; // dusk 316 const { agent } = mockAgent( { 317 'site.standard.publication': [ 318 pubRecord( 'a', `${ SITE_BASE }/@me.bsky.social/a`, { basicTheme: good } ), 319 pubRecord( 'b', `${ SITE_BASE }/@me.bsky.social/b`, { 320 basicTheme: { background: { r: 999 } }, 321 } ), 322 ], 323 } ); 324 const pubs = await listPublications( agent, DID ); 325 expect( pubs.find( ( p ) => p.slug === 'a' )?.basicTheme ).toEqual( good ); 326 expect( pubs.find( ( p ) => p.slug === 'b' )?.basicTheme ).toBeUndefined(); 327 } ); 328} ); 329 330describe( 'deletePublication', () => { 331 it( 'cascades: deletes the publication’s documents, their posts, then the record', async () => { 332 const pubUri = `at://${ DID }/site.standard.publication/a`; 333 const { agent, deleted } = mockAgent( { 334 'site.standard.document': [ 335 docRecord( 'd1', pubUri, { 336 bskyPostRef: { uri: `at://${ DID }/app.bsky.feed.post/p1`, cid: 'c' }, 337 } ), 338 docRecord( 'd2', pubUri ), // no companion post 339 docRecord( 'd3', `at://${ DID }/site.standard.publication/other` ), // other pub → untouched 340 ], 341 } ); 342 const result = await deletePublication( agent, DID, { uri: pubUri, rkey: 'a' } ); 343 expect( result ).toEqual( { deletedArticles: 2, deletedPosts: 1 } ); 344 345 const collectionsDeleted = deleted.map( ( d ) => `${ d.collection }/${ d.rkey }` ); 346 expect( collectionsDeleted ).toContain( 'site.standard.document/d1' ); 347 expect( collectionsDeleted ).toContain( 'site.standard.document/d2' ); 348 expect( collectionsDeleted ).toContain( 'app.bsky.feed.post/p1' ); 349 expect( collectionsDeleted ).toContain( 'site.standard.publication/a' ); 350 expect( collectionsDeleted ).not.toContain( 'site.standard.document/d3' ); 351 } ); 352} );