atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

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

at main 439 lines 11 kB view raw
1import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2import { mkdtempSync, rmSync } from "node:fs"; 3import { tmpdir } from "node:os"; 4import { join } from "node:path"; 5import Database from "better-sqlite3"; 6import { PolicyEngine } from "../../policy/engine.js"; 7import { mutualAid } from "../../policy/presets.js"; 8import { SyncStorage } from "../sync-storage.js"; 9import { ChallengeStorage } from "./challenge-storage.js"; 10import { ChallengeScheduler } from "./challenge-scheduler.js"; 11import type { ChallengeTransport } from "./transport.js"; 12import type { 13 StorageChallenge, 14 StorageChallengeResponse, 15} from "./types.js"; 16import type { BlockStore } from "../../ipfs.js"; 17import type { BlockMap } from "@atproto/repo"; 18 19// ============================================ 20// Mock transport 21// ============================================ 22 23class MockTransport implements ChallengeTransport { 24 challenges: StorageChallenge[] = []; 25 26 async sendChallenge( 27 _target: string, 28 challenge: StorageChallenge, 29 ): Promise<StorageChallengeResponse> { 30 this.challenges.push(challenge); 31 return { 32 challengeId: challenge.id, 33 responderDid: challenge.targetDid, 34 respondedAt: new Date().toISOString(), 35 mstProofs: 36 challenge.challengeType !== "block-sample" ? [] : undefined, 37 blockResults: 38 challenge.challengeType !== "mst-proof" 39 ? challenge.blockCids?.map((cid) => ({ 40 cid, 41 available: false, 42 })) 43 : undefined, 44 }; 45 } 46 47 onChallenge( 48 _handler: ( 49 challenge: StorageChallenge, 50 ) => Promise<StorageChallengeResponse>, 51 ): void {} 52} 53 54// ============================================ 55// Mock blockstore 56// ============================================ 57 58class MockBlockStore implements BlockStore { 59 private blocks = new Map<string, Uint8Array>(); 60 61 async putBlock(cidStr: string, bytes: Uint8Array): Promise<void> { 62 this.blocks.set(cidStr, bytes); 63 } 64 65 async getBlock(cidStr: string): Promise<Uint8Array | null> { 66 return this.blocks.get(cidStr) ?? null; 67 } 68 69 async hasBlock(cidStr: string): Promise<boolean> { 70 return this.blocks.has(cidStr); 71 } 72 73 async putBlocks(_blocks: BlockMap): Promise<void> {} 74 75 async deleteBlock(_cidStr: string): Promise<void> {} 76} 77 78// ============================================ 79// Tests 80// ============================================ 81 82describe("ChallengeScheduler", () => { 83 let tmpDir: string; 84 let db: InstanceType<typeof Database>; 85 let syncStorage: SyncStorage; 86 let challengeStorage: ChallengeStorage; 87 let transport: MockTransport; 88 let blockStore: MockBlockStore; 89 90 beforeEach(() => { 91 tmpDir = mkdtempSync(join(tmpdir(), "scheduler-test-")); 92 db = new Database(join(tmpDir, "test.db")); 93 syncStorage = new SyncStorage(db); 94 syncStorage.initSchema(); 95 challengeStorage = new ChallengeStorage(db); 96 challengeStorage.initSchema(); 97 transport = new MockTransport(); 98 blockStore = new MockBlockStore(); 99 }); 100 101 afterEach(() => { 102 db.close(); 103 rmSync(tmpDir, { recursive: true, force: true }); 104 }); 105 106 describe("deriveSchedules", () => { 107 it("returns empty when no policy engine", () => { 108 const scheduler = new ChallengeScheduler( 109 "did:plc:self", 110 null, 111 syncStorage, 112 challengeStorage, 113 blockStore, 114 transport, 115 ); 116 117 expect(scheduler.deriveSchedules()).toEqual([]); 118 }); 119 120 it("derives schedule from mutual-aid policy", () => { 121 const peerDids = [ 122 "did:plc:alice", 123 "did:plc:bob", 124 "did:plc:carol", 125 ]; 126 const engine = new PolicyEngine({ 127 version: 1, 128 policies: [mutualAid({ peerDids, minCopies: 3 })], 129 }); 130 131 // Set up sync state for a tracked DID 132 syncStorage.upsertState({ 133 did: "did:plc:alice", 134 pdsEndpoint: "https://alice.pds.example", 135 }); 136 137 const scheduler = new ChallengeScheduler( 138 "did:plc:self", 139 engine, 140 syncStorage, 141 challengeStorage, 142 blockStore, 143 transport, 144 ); 145 146 const schedules = scheduler.deriveSchedules(); 147 148 expect(schedules.length).toBe(1); 149 expect(schedules[0]!.subjectDid).toBe("did:plc:alice"); 150 expect(schedules[0]!.targetPeers).toEqual(peerDids); 151 expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1 152 expect(schedules[0]!.challengeType).toBe("mst-proof"); // priority 50 < 70 153 expect(schedules[0]!.intervalMs).toBe(600 * 2 * 1000); // 2x sync interval 154 }); 155 156 it("uses combined type for high-priority policies", () => { 157 const engine = new PolicyEngine({ 158 version: 1, 159 policies: [ 160 { 161 id: "saas-with-peers", 162 name: "SaaS with peers", 163 target: { 164 type: "list", 165 dids: ["did:plc:customer"], 166 }, 167 replication: { 168 minCopies: 3, 169 preferredPeers: [ 170 "did:plc:peer1", 171 "did:plc:peer2", 172 ], 173 }, 174 sync: { intervalSec: 60 }, 175 retention: { maxAgeSec: 0, keepHistory: true }, 176 priority: 80, 177 enabled: true, 178 }, 179 ], 180 }); 181 182 syncStorage.upsertState({ 183 did: "did:plc:customer", 184 pdsEndpoint: "https://customer.pds.example", 185 }); 186 187 const scheduler = new ChallengeScheduler( 188 "did:plc:self", 189 engine, 190 syncStorage, 191 challengeStorage, 192 blockStore, 193 transport, 194 ); 195 196 const schedules = scheduler.deriveSchedules(); 197 198 expect(schedules.length).toBe(1); 199 expect(schedules[0]!.challengeType).toBe("combined"); // priority 80 >= 70 200 expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1 201 expect(schedules[0]!.intervalMs).toBe(60 * 2 * 1000); // 2x 60s 202 }); 203 204 it("skips DIDs without preferred peers", () => { 205 const engine = new PolicyEngine({ 206 version: 1, 207 policies: [ 208 { 209 id: "no-peers", 210 name: "No peers", 211 target: { 212 type: "list", 213 dids: ["did:plc:lonely"], 214 }, 215 replication: { minCopies: 1 }, 216 sync: { intervalSec: 300 }, 217 retention: { maxAgeSec: 0, keepHistory: false }, 218 priority: 50, 219 enabled: true, 220 }, 221 ], 222 }); 223 224 syncStorage.upsertState({ 225 did: "did:plc:lonely", 226 pdsEndpoint: "https://lonely.pds.example", 227 }); 228 229 const scheduler = new ChallengeScheduler( 230 "did:plc:self", 231 engine, 232 syncStorage, 233 challengeStorage, 234 blockStore, 235 transport, 236 ); 237 238 const schedules = scheduler.deriveSchedules(); 239 expect(schedules.length).toBe(0); 240 }); 241 }); 242 243 describe("ChallengeStorage", () => { 244 it("records results and updates reliability", () => { 245 challengeStorage.recordResult( 246 "did:plc:challenger", 247 "did:plc:target", 248 "did:plc:subject", 249 "mst-proof", 250 { 251 challengeId: "challenge-1", 252 passed: true, 253 verifiedAt: new Date().toISOString(), 254 durationMs: 100, 255 }, 256 ); 257 258 const history = challengeStorage.getHistory("did:plc:target"); 259 expect(history.length).toBe(1); 260 expect(history[0]!.passed).toBe(1); 261 262 const reliability = challengeStorage.getReliability( 263 "did:plc:target", 264 "did:plc:subject", 265 ); 266 expect(reliability.length).toBe(1); 267 expect(reliability[0]!.total_challenges).toBe(1); 268 expect(reliability[0]!.successful_challenges).toBe(1); 269 expect(reliability[0]!.reliability).toBe(1.0); 270 }); 271 272 it("tracks declining reliability", () => { 273 // First challenge: pass 274 challengeStorage.recordResult( 275 "did:plc:challenger", 276 "did:plc:target", 277 "did:plc:subject", 278 "mst-proof", 279 { 280 challengeId: "challenge-1", 281 passed: true, 282 verifiedAt: new Date().toISOString(), 283 durationMs: 100, 284 }, 285 ); 286 287 // Second challenge: fail 288 challengeStorage.recordResult( 289 "did:plc:challenger", 290 "did:plc:target", 291 "did:plc:subject", 292 "mst-proof", 293 { 294 challengeId: "challenge-2", 295 passed: false, 296 verifiedAt: new Date().toISOString(), 297 durationMs: 200, 298 }, 299 ); 300 301 const reliability = challengeStorage.getReliability( 302 "did:plc:target", 303 "did:plc:subject", 304 ); 305 expect(reliability[0]!.total_challenges).toBe(2); 306 expect(reliability[0]!.successful_challenges).toBe(1); 307 expect(reliability[0]!.reliability).toBe(0.5); 308 }); 309 310 it("getHistory filters by subject DID", () => { 311 challengeStorage.recordResult( 312 "did:plc:challenger", 313 "did:plc:target", 314 "did:plc:subject-a", 315 "mst-proof", 316 { 317 challengeId: "c-1", 318 passed: true, 319 verifiedAt: new Date().toISOString(), 320 durationMs: 50, 321 }, 322 ); 323 324 challengeStorage.recordResult( 325 "did:plc:challenger", 326 "did:plc:target", 327 "did:plc:subject-b", 328 "mst-proof", 329 { 330 challengeId: "c-2", 331 passed: false, 332 verifiedAt: new Date().toISOString(), 333 durationMs: 60, 334 }, 335 ); 336 337 const allHistory = challengeStorage.getHistory("did:plc:target"); 338 expect(allHistory.length).toBe(2); 339 340 const filteredHistory = challengeStorage.getHistory( 341 "did:plc:target", 342 "did:plc:subject-a", 343 ); 344 expect(filteredHistory.length).toBe(1); 345 expect(filteredHistory[0]!.subject_did).toBe( 346 "did:plc:subject-a", 347 ); 348 }); 349 350 it("getAllReliability returns sorted results", () => { 351 // High reliability peer 352 challengeStorage.recordResult( 353 "did:plc:c", 354 "did:plc:good-peer", 355 "did:plc:subject", 356 "mst-proof", 357 { 358 challengeId: "c-good", 359 passed: true, 360 verifiedAt: new Date().toISOString(), 361 durationMs: 50, 362 }, 363 ); 364 365 // Low reliability peer 366 challengeStorage.recordResult( 367 "did:plc:c", 368 "did:plc:bad-peer", 369 "did:plc:subject", 370 "mst-proof", 371 { 372 challengeId: "c-bad", 373 passed: false, 374 verifiedAt: new Date().toISOString(), 375 durationMs: 50, 376 }, 377 ); 378 379 const all = challengeStorage.getAllReliability(); 380 expect(all.length).toBe(2); 381 expect(all[0]!.peer_did).toBe("did:plc:good-peer"); 382 expect(all[0]!.reliability).toBe(1.0); 383 expect(all[1]!.peer_did).toBe("did:plc:bad-peer"); 384 expect(all[1]!.reliability).toBe(0.0); 385 }); 386 }); 387 388 describe("SyncStorage record paths", () => { 389 it("tracks and retrieves record paths", () => { 390 syncStorage.upsertState({ 391 did: "did:plc:test", 392 pdsEndpoint: "https://test.example", 393 }); 394 395 syncStorage.trackRecordPaths("did:plc:test", [ 396 "app.bsky.feed.post/abc", 397 "app.bsky.feed.post/def", 398 ]); 399 400 const paths = syncStorage.getRecordPaths("did:plc:test"); 401 expect(paths).toContain("app.bsky.feed.post/abc"); 402 expect(paths).toContain("app.bsky.feed.post/def"); 403 expect(paths.length).toBe(2); 404 }); 405 406 it("ignores duplicate record paths", () => { 407 syncStorage.upsertState({ 408 did: "did:plc:test", 409 pdsEndpoint: "https://test.example", 410 }); 411 412 syncStorage.trackRecordPaths("did:plc:test", [ 413 "app.bsky.feed.post/abc", 414 ]); 415 syncStorage.trackRecordPaths("did:plc:test", [ 416 "app.bsky.feed.post/abc", 417 "app.bsky.feed.post/def", 418 ]); 419 420 const paths = syncStorage.getRecordPaths("did:plc:test"); 421 expect(paths.length).toBe(2); 422 }); 423 424 it("clears record paths", () => { 425 syncStorage.upsertState({ 426 did: "did:plc:test", 427 pdsEndpoint: "https://test.example", 428 }); 429 430 syncStorage.trackRecordPaths("did:plc:test", [ 431 "app.bsky.feed.post/abc", 432 ]); 433 syncStorage.clearRecordPaths("did:plc:test"); 434 435 const paths = syncStorage.getRecordPaths("did:plc:test"); 436 expect(paths.length).toBe(0); 437 }); 438 }); 439});