mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {AtUri, BskyAgent} from '@atproto/api'
2import {TestBsky, TestNetwork} from '@atproto/dev-env'
3import fs from 'fs'
4import net from 'net'
5import path from 'path'
6
7export interface TestUser {
8 email: string
9 did: string
10 handle: string
11 password: string
12 agent: BskyAgent
13}
14
15export interface TestPDS {
16 pdsUrl: string
17 mocker: Mocker
18 close: () => Promise<void>
19}
20
21class StringIdGenerator {
22 _nextId = [0]
23 constructor(
24 public _chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
25 ) {}
26
27 next() {
28 const r = []
29 for (const char of this._nextId) {
30 r.unshift(this._chars[char])
31 }
32 this._increment()
33 return r.join('')
34 }
35
36 _increment() {
37 for (let i = 0; i < this._nextId.length; i++) {
38 const val = ++this._nextId[i]
39 if (val >= this._chars.length) {
40 this._nextId[i] = 0
41 } else {
42 return
43 }
44 }
45 this._nextId.push(0)
46 }
47
48 *[Symbol.iterator]() {
49 while (true) {
50 yield this.next()
51 }
52 }
53}
54
55const ids = new StringIdGenerator()
56
57export async function createServer(
58 {inviteRequired}: {inviteRequired: boolean} = {
59 inviteRequired: false,
60 },
61): Promise<TestPDS> {
62 const port = 3000
63 const port2 = await getPort(port + 1)
64 const port3 = await getPort(port2 + 1)
65 const pdsUrl = `http://localhost:${port}`
66 const id = ids.next()
67
68 const testNet = await TestNetwork.create({
69 pds: {
70 port,
71 hostname: 'localhost',
72 inviteRequired,
73 },
74 bsky: {
75 dbPostgresSchema: `bsky_${id}`,
76 port: port3,
77 publicUrl: 'http://localhost:2584',
78 },
79 plc: {port: port2},
80 })
81
82 // DISABLED - looks like dev-env added this and now it conflicts
83 // add the test mod authority
84 // const agent = new BskyAgent({service: pdsUrl})
85 // const res = await agent.api.com.atproto.server.createAccount({
86 // email: 'mod-authority@test.com',
87 // handle: 'mod-authority.test',
88 // password: 'hunter2',
89 // })
90 // agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
91 // await agent.api.app.bsky.actor.profile.create(
92 // {repo: res.data.did},
93 // {
94 // displayName: 'Dev-env Moderation',
95 // description: `The pretend version of mod.bsky.app`,
96 // },
97 // )
98
99 // await agent.api.app.bsky.labeler.service.create(
100 // {repo: res.data.did, rkey: 'self'},
101 // {
102 // policies: {
103 // labelValues: ['!hide', '!warn'],
104 // labelValueDefinitions: [],
105 // },
106 // createdAt: new Date().toISOString(),
107 // },
108 // )
109
110 const pic = fs.readFileSync(
111 path.join(__dirname, '..', 'assets', 'default-avatar.png'),
112 )
113
114 return {
115 pdsUrl,
116 mocker: new Mocker(testNet, pdsUrl, pic),
117 async close() {
118 await testNet.close()
119 },
120 }
121}
122
123class Mocker {
124 agent: BskyAgent
125 users: Record<string, TestUser> = {}
126
127 constructor(
128 public testNet: TestNetwork,
129 public service: string,
130 public pic: Uint8Array,
131 ) {
132 this.agent = new BskyAgent({service})
133 }
134
135 get pds() {
136 return this.testNet.pds
137 }
138
139 get bsky() {
140 return this.testNet.bsky
141 }
142
143 get plc() {
144 return this.testNet.plc
145 }
146
147 // NOTE
148 // deterministic date generator
149 // we use this to ensure the mock dataset is always the same
150 // which is very useful when testing
151 *dateGen() {
152 let start = 1657846031914
153 while (true) {
154 yield new Date(start).toISOString()
155 start += 1e3
156 }
157 }
158
159 async createUser(name: string) {
160 const agent = new BskyAgent({service: this.service})
161
162 const inviteRes = await agent.api.com.atproto.server.createInviteCode(
163 {useCount: 1},
164 {
165 headers: this.pds.adminAuthHeaders(),
166 encoding: 'application/json',
167 },
168 )
169
170 const email = `fake${Object.keys(this.users).length + 1}@fake.com`
171 const res = await agent.createAccount({
172 inviteCode: inviteRes.data.code,
173 email,
174 handle: name + '.test',
175 password: 'hunter2',
176 })
177 await agent.upsertProfile(async () => {
178 const blob = await agent.uploadBlob(this.pic, {
179 encoding: 'image/jpeg',
180 })
181 return {
182 displayName: name,
183 avatar: blob.data.blob,
184 }
185 })
186 this.users[name] = {
187 did: res.data.did,
188 email,
189 handle: name + '.test',
190 password: 'hunter2',
191 agent: agent,
192 }
193 }
194
195 async follow(a: string, b: string) {
196 await this.users[a].agent.follow(this.users[b].did)
197 }
198
199 async generateStandardGraph() {
200 await this.createUser('alice')
201 await this.createUser('bob')
202 await this.createUser('carla')
203
204 await this.users.alice.agent.upsertProfile(() => ({
205 displayName: 'Alice',
206 description: 'Test user 1',
207 }))
208
209 await this.users.bob.agent.upsertProfile(() => ({
210 displayName: 'Bob',
211 description: 'Test user 2',
212 }))
213
214 await this.users.carla.agent.upsertProfile(() => ({
215 displayName: 'Carla',
216 description: 'Test user 3',
217 }))
218
219 await this.follow('alice', 'bob')
220 await this.follow('alice', 'carla')
221 await this.follow('bob', 'alice')
222 await this.follow('bob', 'carla')
223 await this.follow('carla', 'alice')
224 await this.follow('carla', 'bob')
225 }
226
227 async createPost(user: string, text: string) {
228 const agent = this.users[user]?.agent
229 if (!agent) {
230 throw new Error(`Not a user: ${user}`)
231 }
232 return await agent.post({
233 text,
234 langs: ['en'],
235 createdAt: new Date().toISOString(),
236 })
237 }
238
239 async createImagePost(user: string, text: string) {
240 const agent = this.users[user]?.agent
241 if (!agent) {
242 throw new Error(`Not a user: ${user}`)
243 }
244 const blob = await agent.uploadBlob(this.pic, {
245 encoding: 'image/jpeg',
246 })
247 return await agent.post({
248 text,
249 langs: ['en'],
250 embed: {
251 $type: 'app.bsky.embed.images',
252 images: [{image: blob.data.blob, alt: ''}],
253 },
254 createdAt: new Date().toISOString(),
255 })
256 }
257
258 async createQuotePost(
259 user: string,
260 text: string,
261 {uri, cid}: {uri: string; cid: string},
262 ) {
263 const agent = this.users[user]?.agent
264 if (!agent) {
265 throw new Error(`Not a user: ${user}`)
266 }
267 return await agent.post({
268 text,
269 embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
270 langs: ['en'],
271 createdAt: new Date().toISOString(),
272 })
273 }
274
275 async createReply(
276 user: string,
277 text: string,
278 {uri, cid}: {uri: string; cid: string},
279 ) {
280 const agent = this.users[user]?.agent
281 if (!agent) {
282 throw new Error(`Not a user: ${user}`)
283 }
284 return await agent.post({
285 text,
286 reply: {root: {uri, cid}, parent: {uri, cid}},
287 langs: ['en'],
288 createdAt: new Date().toISOString(),
289 })
290 }
291
292 async like(user: string, {uri, cid}: {uri: string; cid: string}) {
293 const agent = this.users[user]?.agent
294 if (!agent) {
295 throw new Error(`Not a user: ${user}`)
296 }
297 return await agent.like(uri, cid)
298 }
299
300 async createFeed(user: string, rkey: string, posts: string[]) {
301 const agent = this.users[user]?.agent
302 if (!agent) {
303 throw new Error(`Not a user: ${user}`)
304 }
305 const fgUri = AtUri.make(
306 this.users[user].did,
307 'app.bsky.feed.generator',
308 rkey,
309 )
310 const fg1 = await this.testNet.createFeedGen({
311 [fgUri.toString()]: async () => {
312 return {
313 encoding: 'application/json',
314 body: {
315 feed: posts.slice(0, 30).map(uri => ({post: uri})),
316 },
317 }
318 },
319 })
320 const avatarRes = await agent.api.com.atproto.repo.uploadBlob(this.pic, {
321 encoding: 'image/png',
322 })
323 return await agent.api.app.bsky.feed.generator.create(
324 {repo: this.users[user].did, rkey},
325 {
326 did: fg1.did,
327 displayName: rkey,
328 description: 'all my fav stuff',
329 avatar: avatarRes.data.blob,
330 createdAt: new Date().toISOString(),
331 },
332 )
333 }
334
335 async createInvite(forAccount: string) {
336 const agent = new BskyAgent({service: this.service})
337 await agent.api.com.atproto.server.createInviteCode(
338 {useCount: 1, forAccount},
339 {
340 headers: this.pds.adminAuthHeaders(),
341 encoding: 'application/json',
342 },
343 )
344 }
345
346 async labelAccount(label: string, user: string) {
347 const did = this.users[user]?.did
348 if (!did) {
349 throw new Error(`Invalid user: ${user}`)
350 }
351 const ctx = this.bsky.ctx
352 if (!ctx) {
353 throw new Error('Invalid appview')
354 }
355 await createLabel(this.bsky, {
356 uri: did,
357 cid: '',
358 val: label,
359 })
360 }
361
362 async labelProfile(label: string, user: string) {
363 const agent = this.users[user]?.agent
364 const did = this.users[user]?.did
365 if (!did) {
366 throw new Error(`Invalid user: ${user}`)
367 }
368
369 const profile = await agent.app.bsky.actor.profile.get({
370 repo: user + '.test',
371 rkey: 'self',
372 })
373
374 const ctx = this.bsky.ctx
375 if (!ctx) {
376 throw new Error('Invalid appview')
377 }
378 await createLabel(this.bsky, {
379 uri: profile.uri,
380 cid: profile.cid,
381 val: label,
382 })
383 }
384
385 async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
386 const ctx = this.bsky.ctx
387 if (!ctx) {
388 throw new Error('Invalid appview')
389 }
390 await createLabel(this.bsky, {
391 uri,
392 cid,
393 val: label,
394 })
395 }
396
397 async createMuteList(user: string, name: string): Promise<string> {
398 const res = await this.users[user]?.agent.app.bsky.graph.list.create(
399 {repo: this.users[user]?.did},
400 {
401 purpose: 'app.bsky.graph.defs#modlist',
402 name,
403 createdAt: new Date().toISOString(),
404 },
405 )
406 await this.users[user]?.agent.app.bsky.graph.muteActorList({
407 list: res.uri,
408 })
409 return res.uri
410 }
411
412 async addToMuteList(owner: string, list: string, subject: string) {
413 await this.users[owner]?.agent.app.bsky.graph.listitem.create(
414 {repo: this.users[owner]?.did},
415 {
416 list,
417 subject,
418 createdAt: new Date().toISOString(),
419 },
420 )
421 }
422}
423
424const checkAvailablePort = (port: number) =>
425 new Promise(resolve => {
426 const server = net.createServer()
427 server.unref()
428 server.on('error', () => resolve(false))
429 server.listen({port}, () => {
430 server.close(() => {
431 resolve(true)
432 })
433 })
434 })
435
436async function getPort(start = 3000) {
437 for (let i = start; i < 65000; i++) {
438 if (await checkAvailablePort(i)) {
439 return i
440 }
441 }
442 throw new Error('Unable to find an available port')
443}
444
445const createLabel = async (
446 bsky: TestBsky,
447 opts: {uri: string; cid: string; val: string},
448) => {
449 await bsky.db.db
450 .insertInto('label')
451 .values({
452 uri: opts.uri,
453 cid: opts.cid,
454 val: opts.val,
455 cts: new Date().toISOString(),
456 neg: false,
457 src: 'did:example:labeler',
458 })
459 .execute()
460}