A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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} );