Barazo AppView backend barazo.forum
at main 761 lines 26 kB view raw
1import { describe, it, expect, vi, beforeEach } from 'vitest' 2import { createSetupService } from '../../../src/setup/service.js' 3import type { SetupService } from '../../../src/setup/service.js' 4import type { PlcDidService, GenerateDidResult } from '../../../src/services/plc-did.js' 5import type { Logger } from '../../../src/lib/logger.js' 6import { decrypt } from '../../../src/lib/encryption.js' 7 8// --------------------------------------------------------------------------- 9// Mock helpers 10// --------------------------------------------------------------------------- 11 12function createMockDb() { 13 // Select chain: db.select().from().where() -> Promise<rows[]> 14 const whereSelectFn = vi.fn<() => Promise<unknown[]>>() 15 const fromFn = vi.fn<() => { where: typeof whereSelectFn }>().mockReturnValue({ 16 where: whereSelectFn, 17 }) 18 const selectFn = vi.fn<() => { from: typeof fromFn }>().mockReturnValue({ 19 from: fromFn, 20 }) 21 22 // Upsert chain: db.insert().values().onConflictDoUpdate().returning() -> Promise<rows[]> 23 // Also supports: db.insert().values().onConflictDoNothing() -> Promise<rows[]> 24 const returningFn = vi.fn<() => Promise<unknown[]>>() 25 const onConflictDoUpdateFn = vi.fn<() => { returning: typeof returningFn }>().mockReturnValue({ 26 returning: returningFn, 27 }) 28 const onConflictDoNothingFn = vi.fn<() => Promise<unknown[]>>().mockResolvedValue([]) 29 const valuesFn = vi 30 .fn< 31 () => { 32 onConflictDoUpdate: typeof onConflictDoUpdateFn 33 onConflictDoNothing: typeof onConflictDoNothingFn 34 } 35 >() 36 .mockReturnValue({ 37 onConflictDoUpdate: onConflictDoUpdateFn, 38 onConflictDoNothing: onConflictDoNothingFn, 39 }) 40 const insertFn = vi.fn<() => { values: typeof valuesFn }>().mockReturnValue({ 41 values: valuesFn, 42 }) 43 44 // Update chain: db.update().set().where() -> Promise<unknown[]> 45 const whereUpdateFn = vi.fn<() => Promise<unknown[]>>().mockResolvedValue([]) 46 const setFn = vi.fn<() => { where: typeof whereUpdateFn }>().mockReturnValue({ 47 where: whereUpdateFn, 48 }) 49 const updateFn = vi.fn<() => { set: typeof setFn }>().mockReturnValue({ 50 set: setFn, 51 }) 52 53 return { 54 db: { select: selectFn, insert: insertFn, update: updateFn }, 55 mocks: { 56 selectFn, 57 fromFn, 58 whereSelectFn, 59 insertFn, 60 valuesFn, 61 onConflictDoUpdateFn, 62 onConflictDoNothingFn, 63 returningFn, 64 updateFn, 65 setFn, 66 whereUpdateFn, 67 }, 68 } 69} 70 71function createMockLogger(): Logger { 72 return { 73 info: vi.fn(), 74 error: vi.fn(), 75 warn: vi.fn(), 76 debug: vi.fn(), 77 fatal: vi.fn(), 78 trace: vi.fn(), 79 child: vi.fn(), 80 silent: vi.fn(), 81 level: 'silent', 82 } as unknown as Logger 83} 84 85function createMockPlcDidService(): PlcDidService & { 86 generateDid: ReturnType<typeof vi.fn> 87} { 88 return { 89 generateDid: vi.fn<() => Promise<GenerateDidResult>>(), 90 } 91} 92 93// --------------------------------------------------------------------------- 94// Fixtures 95// --------------------------------------------------------------------------- 96 97const TEST_DID = 'did:plc:test123456789' 98const DEFAULT_COMMUNITY_NAME = 'Barazo Community' 99const TEST_HANDLE = 'community.barazo.forum' 100const TEST_SERVICE_ENDPOINT = 'https://community.barazo.forum' 101const TEST_COMMUNITY_DID = 'did:plc:communityabc123456' 102const TEST_SIGNING_KEY = 'a'.repeat(64) 103const TEST_ROTATION_KEY = 'b'.repeat(64) 104const TEST_ENCRYPTION_KEY = 'c'.repeat(32) 105 106// --------------------------------------------------------------------------- 107// Test suite 108// --------------------------------------------------------------------------- 109 110describe('SetupService', () => { 111 let service: SetupService 112 let mocks: ReturnType<typeof createMockDb>['mocks'] 113 let mockLogger: Logger 114 let mockPlcDidService: ReturnType<typeof createMockPlcDidService> 115 116 beforeEach(() => { 117 const { db, mocks: m } = createMockDb() 118 mocks = m 119 mockLogger = createMockLogger() 120 mockPlcDidService = createMockPlcDidService() 121 service = createSetupService(db as never, mockLogger, TEST_ENCRYPTION_KEY, mockPlcDidService) 122 }) 123 124 // ========================================================================= 125 // getStatus 126 // ========================================================================= 127 128 describe('getStatus()', () => { 129 it('returns { initialized: false } when no settings row exists', async () => { 130 mocks.whereSelectFn.mockResolvedValueOnce([]) 131 132 const result = await service.getStatus() 133 134 expect(result).toStrictEqual({ initialized: false }) 135 }) 136 137 it('returns { initialized: false } when settings exist but not initialized', async () => { 138 mocks.whereSelectFn.mockResolvedValueOnce([ 139 { 140 initialized: false, 141 communityName: 'Test Community', 142 }, 143 ]) 144 145 const result = await service.getStatus() 146 147 expect(result).toStrictEqual({ initialized: false }) 148 }) 149 150 it('returns { initialized: true, communityName } when initialized', async () => { 151 mocks.whereSelectFn.mockResolvedValueOnce([ 152 { 153 initialized: true, 154 communityName: 'My Forum', 155 }, 156 ]) 157 158 const result = await service.getStatus() 159 160 expect(result).toStrictEqual({ 161 initialized: true, 162 communityName: 'My Forum', 163 }) 164 }) 165 166 it('propagates database errors', async () => { 167 mocks.whereSelectFn.mockRejectedValueOnce(new Error('Connection lost')) 168 169 await expect(service.getStatus()).rejects.toThrow('Connection lost') 170 }) 171 }) 172 173 // ========================================================================= 174 // initialize (basic, without PLC DID) 175 // ========================================================================= 176 177 describe('initialize() without PLC DID', () => { 178 it('returns success for first authenticated user when no row exists', async () => { 179 mocks.returningFn.mockResolvedValueOnce([ 180 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null }, 181 ]) 182 183 const result = await service.initialize({ did: TEST_DID }) 184 185 expect(result).toStrictEqual({ 186 initialized: true, 187 adminDid: TEST_DID, 188 communityName: DEFAULT_COMMUNITY_NAME, 189 }) 190 expect(mocks.insertFn).toHaveBeenCalled() 191 expect(mockPlcDidService.generateDid).not.toHaveBeenCalled() 192 }) 193 194 it('returns success when row exists but not initialized', async () => { 195 mocks.returningFn.mockResolvedValueOnce([ 196 { communityName: 'Existing Name', communityDid: null }, 197 ]) 198 199 const result = await service.initialize({ did: TEST_DID }) 200 201 expect(result).toStrictEqual({ 202 initialized: true, 203 adminDid: TEST_DID, 204 communityName: 'Existing Name', 205 }) 206 expect(mocks.insertFn).toHaveBeenCalled() 207 }) 208 209 it('returns conflict error when already initialized', async () => { 210 mocks.returningFn.mockResolvedValueOnce([]) 211 212 const result = await service.initialize({ did: TEST_DID }) 213 214 expect(result).toStrictEqual({ alreadyInitialized: true }) 215 }) 216 217 it('accepts optional communityName', async () => { 218 mocks.returningFn.mockResolvedValueOnce([ 219 { communityName: 'Custom Name', communityDid: null }, 220 ]) 221 222 const result = await service.initialize({ 223 did: TEST_DID, 224 communityName: 'Custom Name', 225 }) 226 227 expect(result).toStrictEqual({ 228 initialized: true, 229 adminDid: TEST_DID, 230 communityName: 'Custom Name', 231 }) 232 expect(mocks.insertFn).toHaveBeenCalled() 233 }) 234 235 it('preserves existing communityName when no override provided', async () => { 236 mocks.returningFn.mockResolvedValueOnce([ 237 { communityName: 'Keep This Name', communityDid: null }, 238 ]) 239 240 const result = await service.initialize({ did: TEST_DID }) 241 242 expect(result).toStrictEqual({ 243 initialized: true, 244 adminDid: TEST_DID, 245 communityName: 'Keep This Name', 246 }) 247 }) 248 249 it('propagates database errors', async () => { 250 mocks.returningFn.mockRejectedValueOnce(new Error('Connection lost')) 251 252 await expect(service.initialize({ did: TEST_DID })).rejects.toThrow('Connection lost') 253 }) 254 255 it('does not call PLC DID service when only handle is provided (no serviceEndpoint)', async () => { 256 mocks.returningFn.mockResolvedValueOnce([ 257 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null }, 258 ]) 259 260 await service.initialize({ 261 did: TEST_DID, 262 handle: TEST_HANDLE, 263 }) 264 265 expect(mockPlcDidService.generateDid).not.toHaveBeenCalled() 266 }) 267 268 it('does not call PLC DID service when only serviceEndpoint is provided (no handle)', async () => { 269 mocks.returningFn.mockResolvedValueOnce([ 270 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null }, 271 ]) 272 273 await service.initialize({ 274 did: TEST_DID, 275 serviceEndpoint: TEST_SERVICE_ENDPOINT, 276 }) 277 278 expect(mockPlcDidService.generateDid).not.toHaveBeenCalled() 279 }) 280 281 it('promotes initializing user to admin role in users table', async () => { 282 mocks.returningFn.mockResolvedValueOnce([{ communityName: 'Test Forum', communityDid: null }]) 283 284 const result = await service.initialize({ 285 did: TEST_DID, 286 communityName: 'Test Forum', 287 }) 288 289 expect(result).toStrictEqual({ 290 initialized: true, 291 adminDid: TEST_DID, 292 communityName: 'Test Forum', 293 }) 294 expect(mocks.updateFn).toHaveBeenCalledOnce() 295 }) 296 297 it('does not promote user when community is already initialized', async () => { 298 mocks.returningFn.mockResolvedValueOnce([]) 299 300 const result = await service.initialize({ did: TEST_DID }) 301 302 expect(result).toStrictEqual({ alreadyInitialized: true }) 303 expect(mocks.updateFn).not.toHaveBeenCalled() 304 }) 305 306 it('seeds platform:age_confirmation onboarding field after initialization', async () => { 307 mocks.returningFn.mockResolvedValueOnce([ 308 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 309 ]) 310 311 await service.initialize({ did: TEST_DID, communityDid: TEST_COMMUNITY_DID }) 312 313 // insert is called 6 times: settings, onboarding, pages, categories, topics, replies 314 expect(mocks.insertFn).toHaveBeenCalledTimes(6) 315 316 // The second insert's values call should contain the platform age field 317 const secondValuesCall = mocks.valuesFn.mock.calls[1]?.[0] as Record<string, unknown> 318 expect(secondValuesCall).toBeDefined() 319 expect(secondValuesCall.id).toBe('platform:age_confirmation') 320 expect(secondValuesCall.fieldType).toBe('age_confirmation') 321 expect(secondValuesCall.source).toBe('platform') 322 expect(secondValuesCall.isMandatory).toBe(true) 323 expect(secondValuesCall.sortOrder).toBe(-1) 324 expect(mocks.onConflictDoNothingFn).toHaveBeenCalled() 325 }) 326 327 it('does not seed platform fields when community is already initialized', async () => { 328 mocks.returningFn.mockResolvedValueOnce([]) 329 330 await service.initialize({ did: TEST_DID }) 331 332 // Only one insert call (the upsert attempt), no seeding 333 expect(mocks.insertFn).toHaveBeenCalledTimes(1) 334 }) 335 }) 336 337 // ========================================================================= 338 // initialize (with PLC DID generation) 339 // ========================================================================= 340 341 describe('initialize() with PLC DID', () => { 342 it('generates PLC DID when handle and serviceEndpoint are provided', async () => { 343 mockPlcDidService.generateDid.mockResolvedValueOnce({ 344 did: TEST_COMMUNITY_DID, 345 signingKey: TEST_SIGNING_KEY, 346 rotationKey: TEST_ROTATION_KEY, 347 }) 348 mocks.returningFn.mockResolvedValueOnce([ 349 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 350 ]) 351 352 const result = await service.initialize({ 353 did: TEST_DID, 354 handle: TEST_HANDLE, 355 serviceEndpoint: TEST_SERVICE_ENDPOINT, 356 }) 357 358 expect(mockPlcDidService.generateDid).toHaveBeenCalledOnce() 359 expect(mockPlcDidService.generateDid).toHaveBeenCalledWith({ 360 handle: TEST_HANDLE, 361 serviceEndpoint: TEST_SERVICE_ENDPOINT, 362 }) 363 364 expect(result).toStrictEqual({ 365 initialized: true, 366 adminDid: TEST_DID, 367 communityName: DEFAULT_COMMUNITY_NAME, 368 communityDid: TEST_COMMUNITY_DID, 369 }) 370 }) 371 372 it('includes communityDid in result when DID is generated', async () => { 373 mockPlcDidService.generateDid.mockResolvedValueOnce({ 374 did: TEST_COMMUNITY_DID, 375 signingKey: TEST_SIGNING_KEY, 376 rotationKey: TEST_ROTATION_KEY, 377 }) 378 mocks.returningFn.mockResolvedValueOnce([ 379 { communityName: 'My Forum', communityDid: TEST_COMMUNITY_DID }, 380 ]) 381 382 const result = await service.initialize({ 383 did: TEST_DID, 384 communityName: 'My Forum', 385 handle: TEST_HANDLE, 386 serviceEndpoint: TEST_SERVICE_ENDPOINT, 387 }) 388 389 expect(result).toHaveProperty('communityDid', TEST_COMMUNITY_DID) 390 }) 391 392 it('does not include communityDid in result when DID is null', async () => { 393 mocks.returningFn.mockResolvedValueOnce([ 394 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null }, 395 ]) 396 397 const result = await service.initialize({ did: TEST_DID }) 398 399 expect(result).not.toHaveProperty('communityDid') 400 }) 401 402 it('encrypts signing and rotation keys before storing in DB', async () => { 403 mockPlcDidService.generateDid.mockResolvedValueOnce({ 404 did: TEST_COMMUNITY_DID, 405 signingKey: TEST_SIGNING_KEY, 406 rotationKey: TEST_ROTATION_KEY, 407 }) 408 mocks.returningFn.mockResolvedValueOnce([ 409 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 410 ]) 411 412 await service.initialize({ 413 did: TEST_DID, 414 handle: TEST_HANDLE, 415 serviceEndpoint: TEST_SERVICE_ENDPOINT, 416 }) 417 418 // Extract the values passed to the DB insert 419 const callArgs = mocks.valuesFn.mock.calls[0] 420 expect(callArgs).toBeDefined() 421 const insertValues = (callArgs as unknown[][])[0] as Record<string, unknown> 422 423 // Keys should NOT be plaintext 424 expect(insertValues.signingKey).not.toBe(TEST_SIGNING_KEY) 425 expect(insertValues.rotationKey).not.toBe(TEST_ROTATION_KEY) 426 427 // Keys should be decryptable back to the originals 428 expect(decrypt(insertValues.signingKey as string, TEST_ENCRYPTION_KEY)).toBe(TEST_SIGNING_KEY) 429 expect(decrypt(insertValues.rotationKey as string, TEST_ENCRYPTION_KEY)).toBe( 430 TEST_ROTATION_KEY 431 ) 432 }) 433 434 it('propagates PLC DID generation errors', async () => { 435 mockPlcDidService.generateDid.mockRejectedValueOnce( 436 new Error('PLC directory returned 500: Internal Server Error') 437 ) 438 439 await expect( 440 service.initialize({ 441 did: TEST_DID, 442 handle: TEST_HANDLE, 443 serviceEndpoint: TEST_SERVICE_ENDPOINT, 444 }) 445 ).rejects.toThrow('PLC directory returned 500: Internal Server Error') 446 }) 447 448 it('logs info when generating PLC DID', async () => { 449 mockPlcDidService.generateDid.mockResolvedValueOnce({ 450 did: TEST_COMMUNITY_DID, 451 signingKey: TEST_SIGNING_KEY, 452 rotationKey: TEST_ROTATION_KEY, 453 }) 454 mocks.returningFn.mockResolvedValueOnce([ 455 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 456 ]) 457 458 await service.initialize({ 459 did: TEST_DID, 460 handle: TEST_HANDLE, 461 serviceEndpoint: TEST_SERVICE_ENDPOINT, 462 }) 463 464 const infoFn = mockLogger.info as ReturnType<typeof vi.fn> 465 expect(infoFn).toHaveBeenCalledWith( 466 expect.objectContaining({ 467 handle: TEST_HANDLE, 468 serviceEndpoint: TEST_SERVICE_ENDPOINT, 469 }) as Record<string, unknown>, 470 'Generating PLC DID during community setup' 471 ) 472 }) 473 }) 474 475 // ========================================================================= 476 // initialize (page seeding) 477 // ========================================================================= 478 479 describe('initialize() page seeding', () => { 480 it('seeds default pages after admin promotion', async () => { 481 mocks.returningFn.mockResolvedValueOnce([ 482 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 483 ]) 484 485 await service.initialize({ 486 communityDid: TEST_COMMUNITY_DID, 487 did: TEST_DID, 488 }) 489 490 // insert is called 6 times: settings, onboarding, pages, categories, topics, replies 491 expect(mocks.insertFn).toHaveBeenCalledTimes(6) 492 }) 493 494 it('seeds exactly 5 default pages with correct slugs', async () => { 495 mocks.returningFn.mockResolvedValueOnce([ 496 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 497 ]) 498 499 // Capture the values passed to the pages insert call (pages have 'status' field) 500 let capturedPageValues: Array<{ slug: string; status: string; communityDid: string }> = [] 501 mocks.valuesFn.mockImplementation((vals: unknown) => { 502 if ( 503 Array.isArray(vals) && 504 vals.length > 0 && 505 'status' in (vals[0] as Record<string, unknown>) && 506 'slug' in (vals[0] as Record<string, unknown>) 507 ) { 508 capturedPageValues = vals as typeof capturedPageValues 509 } 510 return { 511 onConflictDoUpdate: mocks.onConflictDoUpdateFn, 512 onConflictDoNothing: mocks.onConflictDoNothingFn, 513 } 514 }) 515 516 await service.initialize({ 517 communityDid: TEST_COMMUNITY_DID, 518 did: TEST_DID, 519 }) 520 521 expect(capturedPageValues).toHaveLength(5) 522 const slugs = capturedPageValues.map((v) => v.slug) 523 expect(slugs).toContain('terms-of-service') 524 expect(slugs).toContain('privacy-policy') 525 expect(slugs).toContain('cookie-policy') 526 expect(slugs).toContain('accessibility') 527 expect(slugs).toContain('your-data') 528 529 for (const page of capturedPageValues) { 530 expect(page.status).toBe('published') 531 expect(page.communityDid).toBe(TEST_COMMUNITY_DID) 532 } 533 }) 534 535 it('does not seed pages when community is already initialized', async () => { 536 mocks.returningFn.mockResolvedValueOnce([]) 537 538 const result = await service.initialize({ 539 communityDid: TEST_COMMUNITY_DID, 540 did: TEST_DID, 541 }) 542 543 expect(result).toStrictEqual({ alreadyInitialized: true }) 544 // Only 1 insert call (the upsert), no pages insert 545 expect(mocks.insertFn).toHaveBeenCalledTimes(1) 546 }) 547 548 it('logs page seeding info', async () => { 549 mocks.returningFn.mockResolvedValueOnce([ 550 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 551 ]) 552 553 await service.initialize({ 554 communityDid: TEST_COMMUNITY_DID, 555 did: TEST_DID, 556 }) 557 558 const infoFn = mockLogger.info as ReturnType<typeof vi.fn> 559 const logCalls = infoFn.mock.calls as Array<[Record<string, unknown>, string]> 560 const seedLog = logCalls.find( 561 ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Default pages seeded') 562 ) 563 expect(seedLog).toBeDefined() 564 if (seedLog) { 565 expect(seedLog[0]).toHaveProperty('communityDid', TEST_COMMUNITY_DID) 566 expect(seedLog[0]).toHaveProperty('pageCount', 5) 567 } 568 }) 569 }) 570 571 // ========================================================================= 572 // initialize (category and demo content seeding) 573 // ========================================================================= 574 575 describe('initialize() category and demo content seeding', () => { 576 it('seeds categories with subcategories', async () => { 577 mocks.returningFn.mockResolvedValueOnce([ 578 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 579 ]) 580 581 let capturedCategoryValues: Array<{ 582 slug: string 583 parentId: string | null 584 communityDid: string 585 }> = [] 586 mocks.valuesFn.mockImplementation((vals: unknown) => { 587 if ( 588 Array.isArray(vals) && 589 vals.length > 0 && 590 'maturityRating' in (vals[0] as Record<string, unknown>) && 591 'slug' in (vals[0] as Record<string, unknown>) 592 ) { 593 capturedCategoryValues = vals as typeof capturedCategoryValues 594 } 595 return { 596 onConflictDoUpdate: mocks.onConflictDoUpdateFn, 597 onConflictDoNothing: mocks.onConflictDoNothingFn, 598 } 599 }) 600 601 await service.initialize({ 602 communityDid: TEST_COMMUNITY_DID, 603 did: TEST_DID, 604 }) 605 606 expect(capturedCategoryValues.length).toBeGreaterThan(4) 607 608 // Root categories have null parentId 609 const roots = capturedCategoryValues.filter((c) => c.parentId === null) 610 expect(roots.length).toBeGreaterThanOrEqual(4) 611 612 // Subcategories have non-null parentId 613 const subs = capturedCategoryValues.filter((c) => c.parentId !== null) 614 expect(subs.length).toBeGreaterThanOrEqual(3) 615 616 // All categories belong to the correct community 617 for (const cat of capturedCategoryValues) { 618 expect(cat.communityDid).toBe(TEST_COMMUNITY_DID) 619 } 620 }) 621 622 it('seeds demo topics across categories including subcategories', async () => { 623 mocks.returningFn.mockResolvedValueOnce([ 624 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 625 ]) 626 627 let capturedTopicValues: Array<{ 628 category: string 629 title: string 630 authorDid: string 631 }> = [] 632 mocks.valuesFn.mockImplementation((vals: unknown) => { 633 if ( 634 Array.isArray(vals) && 635 vals.length > 0 && 636 'title' in (vals[0] as Record<string, unknown>) && 637 'category' in (vals[0] as Record<string, unknown>) 638 ) { 639 capturedTopicValues = vals as typeof capturedTopicValues 640 } 641 return { 642 onConflictDoUpdate: mocks.onConflictDoUpdateFn, 643 onConflictDoNothing: mocks.onConflictDoNothingFn, 644 } 645 }) 646 647 await service.initialize({ 648 communityDid: TEST_COMMUNITY_DID, 649 did: TEST_DID, 650 }) 651 652 expect(capturedTopicValues.length).toBeGreaterThanOrEqual(5) 653 654 // Topics should span both root and subcategories 655 const topicCategories = new Set(capturedTopicValues.map((t) => t.category)) 656 expect(topicCategories.has('general')).toBe(true) 657 expect(topicCategories.has('frontend')).toBe(true) 658 expect(topicCategories.has('backend')).toBe(true) 659 660 // All topics use the admin DID as author 661 for (const topic of capturedTopicValues) { 662 expect(topic.authorDid).toBe(TEST_DID) 663 } 664 }) 665 666 it('seeds demo replies for each topic', async () => { 667 mocks.returningFn.mockResolvedValueOnce([ 668 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 669 ]) 670 671 let capturedReplyValues: Array<{ 672 rootUri: string 673 authorDid: string 674 depth: number 675 }> = [] 676 mocks.valuesFn.mockImplementation((vals: unknown) => { 677 if ( 678 Array.isArray(vals) && 679 vals.length > 0 && 680 'rootUri' in (vals[0] as Record<string, unknown>) && 681 'depth' in (vals[0] as Record<string, unknown>) 682 ) { 683 capturedReplyValues = vals as typeof capturedReplyValues 684 } 685 return { 686 onConflictDoUpdate: mocks.onConflictDoUpdateFn, 687 onConflictDoNothing: mocks.onConflictDoNothingFn, 688 } 689 }) 690 691 await service.initialize({ 692 communityDid: TEST_COMMUNITY_DID, 693 did: TEST_DID, 694 }) 695 696 // One reply per topic 697 expect(capturedReplyValues.length).toBeGreaterThanOrEqual(5) 698 699 for (const reply of capturedReplyValues) { 700 expect(reply.authorDid).toBe(TEST_DID) 701 expect(reply.depth).toBe(1) 702 } 703 }) 704 705 it('logs category and demo content seeding', async () => { 706 mocks.returningFn.mockResolvedValueOnce([ 707 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 708 ]) 709 710 await service.initialize({ 711 communityDid: TEST_COMMUNITY_DID, 712 did: TEST_DID, 713 }) 714 715 const infoFn = mockLogger.info as ReturnType<typeof vi.fn> 716 const logCalls = infoFn.mock.calls as Array<[Record<string, unknown>, string]> 717 718 const catLog = logCalls.find( 719 ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Default categories seeded') 720 ) 721 expect(catLog).toBeDefined() 722 723 const contentLog = logCalls.find( 724 ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Demo content seeded') 725 ) 726 expect(contentLog).toBeDefined() 727 }) 728 }) 729 730 // ========================================================================= 731 // initialize (without PlcDidService injected) 732 // ========================================================================= 733 734 describe('initialize() without PlcDidService', () => { 735 it('logs warning when handle/serviceEndpoint provided but no PlcDidService', async () => { 736 // Create service without PlcDidService 737 const { db, mocks: m } = createMockDb() 738 const logger = createMockLogger() 739 const serviceWithoutPlc = createSetupService(db as never, logger, TEST_ENCRYPTION_KEY) 740 741 m.returningFn.mockResolvedValueOnce([ 742 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: null }, 743 ]) 744 745 await serviceWithoutPlc.initialize({ 746 did: TEST_DID, 747 handle: TEST_HANDLE, 748 serviceEndpoint: TEST_SERVICE_ENDPOINT, 749 }) 750 751 const warnFn = logger.warn as ReturnType<typeof vi.fn> 752 expect(warnFn).toHaveBeenCalledWith( 753 expect.objectContaining({ 754 handle: TEST_HANDLE, 755 serviceEndpoint: TEST_SERVICE_ENDPOINT, 756 }) as Record<string, unknown>, 757 'PLC DID generation requested but PlcDidService not available' 758 ) 759 }) 760 }) 761})