WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
at ac6cce98d5bb1720650ca6bb1f07b6ce41c56bad 1014 lines 28 kB view raw
1import { beforeEach, describe, expect, it, vi } from 'vitest'; 2import type { TangledApiClient } from '../../src/lib/api-client.js'; 3import { 4 closeIssue, 5 createIssue, 6 getCompleteIssueData, 7 getIssue, 8 getIssueState, 9 listIssues, 10 reopenIssue, 11 resolveSequentialNumber, 12 updateIssue, 13} from '../../src/lib/issues-api.js'; 14 15// Mock API client factory 16const createMockClient = (authenticated = true): TangledApiClient => { 17 const mockAgent = { 18 com: { 19 atproto: { 20 repo: { 21 createRecord: vi.fn(), 22 listRecords: vi.fn(), 23 getRecord: vi.fn(), 24 putRecord: vi.fn(), 25 deleteRecord: vi.fn(), 26 }, 27 }, 28 }, 29 }; 30 31 return { 32 isAuthenticated: vi.fn(() => authenticated), 33 getSession: vi.fn(() => 34 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 35 ), 36 getAgent: vi.fn(() => mockAgent), 37 } as unknown as TangledApiClient; 38}; 39 40describe('createIssue', () => { 41 let mockClient: TangledApiClient; 42 43 beforeEach(() => { 44 mockClient = createMockClient(true); 45 }); 46 47 it('should create an issue with all fields', async () => { 48 const mockCreateRecord = vi.fn().mockResolvedValue({ 49 data: { 50 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 51 cid: 'cid123', 52 }, 53 }); 54 55 vi.mocked(mockClient.getAgent).mockReturnValue({ 56 com: { 57 atproto: { 58 repo: { 59 createRecord: mockCreateRecord, 60 }, 61 }, 62 }, 63 } as never); 64 65 const result = await createIssue({ 66 client: mockClient, 67 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 68 title: 'Bug: Login fails', 69 body: 'Detailed description of the bug', 70 }); 71 72 expect(result).toMatchObject({ 73 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 74 title: 'Bug: Login fails', 75 body: 'Detailed description of the bug', 76 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 77 cid: 'cid123', 78 author: 'did:plc:test123', 79 }); 80 81 expect(mockCreateRecord).toHaveBeenCalledWith({ 82 repo: 'did:plc:test123', 83 collection: 'sh.tangled.repo.issue', 84 record: expect.objectContaining({ 85 $type: 'sh.tangled.repo.issue', 86 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 87 title: 'Bug: Login fails', 88 body: 'Detailed description of the bug', 89 createdAt: expect.any(String), 90 }), 91 }); 92 }); 93 94 it('should create an issue without body', async () => { 95 const mockCreateRecord = vi.fn().mockResolvedValue({ 96 data: { 97 uri: 'at://did:plc:test123/sh.tangled.repo.issue/abc123', 98 cid: 'cid123', 99 }, 100 }); 101 102 vi.mocked(mockClient.getAgent).mockReturnValue({ 103 com: { 104 atproto: { 105 repo: { 106 createRecord: mockCreateRecord, 107 }, 108 }, 109 }, 110 } as never); 111 112 const result = await createIssue({ 113 client: mockClient, 114 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 115 title: 'Simple issue', 116 }); 117 118 expect(result.body).toBeUndefined(); 119 expect(mockCreateRecord).toHaveBeenCalled(); 120 }); 121 122 it('should throw error when not authenticated', async () => { 123 mockClient = createMockClient(false); 124 125 await expect( 126 createIssue({ 127 client: mockClient, 128 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 129 title: 'Test', 130 }) 131 ).rejects.toThrow('Must be authenticated'); 132 }); 133 134 it('should throw error on API failure', async () => { 135 const mockCreateRecord = vi.fn().mockRejectedValue(new Error('API error')); 136 137 vi.mocked(mockClient.getAgent).mockReturnValue({ 138 com: { 139 atproto: { 140 repo: { 141 createRecord: mockCreateRecord, 142 }, 143 }, 144 }, 145 } as never); 146 147 await expect( 148 createIssue({ 149 client: mockClient, 150 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 151 title: 'Test', 152 }) 153 ).rejects.toThrow('Failed to create issue: API error'); 154 }); 155}); 156 157describe('listIssues', () => { 158 let mockClient: TangledApiClient; 159 160 beforeEach(() => { 161 mockClient = createMockClient(true); 162 }); 163 164 it('should list issues for a repository', async () => { 165 const mockListRecords = vi.fn().mockResolvedValue({ 166 data: { 167 records: [ 168 { 169 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 170 cid: 'cid1', 171 value: { 172 $type: 'sh.tangled.repo.issue', 173 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 174 title: 'Issue 1', 175 body: 'Description 1', 176 createdAt: '2024-01-01T00:00:00.000Z', 177 }, 178 }, 179 { 180 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 181 cid: 'cid2', 182 value: { 183 $type: 'sh.tangled.repo.issue', 184 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 185 title: 'Issue 2', 186 createdAt: '2024-01-02T00:00:00.000Z', 187 }, 188 }, 189 ], 190 cursor: undefined, 191 }, 192 }); 193 194 vi.mocked(mockClient.getAgent).mockReturnValue({ 195 com: { 196 atproto: { 197 repo: { 198 listRecords: mockListRecords, 199 }, 200 }, 201 }, 202 } as never); 203 204 const result = await listIssues({ 205 client: mockClient, 206 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 207 }); 208 209 expect(result.issues).toHaveLength(2); 210 expect(result.issues[0]).toMatchObject({ 211 title: 'Issue 1', 212 body: 'Description 1', 213 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 214 }); 215 }); 216 217 it('should filter issues by repository', async () => { 218 const mockListRecords = vi.fn().mockResolvedValue({ 219 data: { 220 records: [ 221 { 222 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 223 cid: 'cid1', 224 value: { 225 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 226 title: 'Issue 1', 227 createdAt: '2024-01-01T00:00:00.000Z', 228 }, 229 }, 230 { 231 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue2', 232 cid: 'cid2', 233 value: { 234 repo: 'at://did:plc:owner/sh.tangled.repo/other-repo', 235 title: 'Issue 2', 236 createdAt: '2024-01-02T00:00:00.000Z', 237 }, 238 }, 239 ], 240 cursor: undefined, 241 }, 242 }); 243 244 vi.mocked(mockClient.getAgent).mockReturnValue({ 245 com: { 246 atproto: { 247 repo: { 248 listRecords: mockListRecords, 249 }, 250 }, 251 }, 252 } as never); 253 254 const result = await listIssues({ 255 client: mockClient, 256 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 257 }); 258 259 // Should only include issue from my-repo, not other-repo 260 expect(result.issues).toHaveLength(1); 261 expect(result.issues[0].title).toBe('Issue 1'); 262 }); 263 264 it('should return empty array when no issues found', async () => { 265 const mockListRecords = vi.fn().mockResolvedValue({ 266 data: { 267 records: [], 268 cursor: undefined, 269 }, 270 }); 271 272 vi.mocked(mockClient.getAgent).mockReturnValue({ 273 com: { 274 atproto: { 275 repo: { 276 listRecords: mockListRecords, 277 }, 278 }, 279 }, 280 } as never); 281 282 const result = await listIssues({ 283 client: mockClient, 284 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 285 }); 286 287 expect(result.issues).toEqual([]); 288 }); 289 290 it('should throw error when not authenticated', async () => { 291 mockClient = createMockClient(false); 292 293 await expect( 294 listIssues({ 295 client: mockClient, 296 repoAtUri: 'at://did:plc:owner/sh.tangled.repo/my-repo', 297 }) 298 ).rejects.toThrow('Must be authenticated'); 299 }); 300 301 it('should throw error for invalid repo URI', async () => { 302 await expect( 303 listIssues({ 304 client: mockClient, 305 repoAtUri: 'invalid-uri', 306 }) 307 ).rejects.toThrow('Invalid repository AT-URI'); 308 }); 309}); 310 311describe('getIssue', () => { 312 let mockClient: TangledApiClient; 313 314 beforeEach(() => { 315 mockClient = createMockClient(true); 316 }); 317 318 it('should get a specific issue', async () => { 319 const mockGetRecord = vi.fn().mockResolvedValue({ 320 data: { 321 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 322 cid: 'cid1', 323 value: { 324 $type: 'sh.tangled.repo.issue', 325 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 326 title: 'Test Issue', 327 body: 'Test Description', 328 createdAt: '2024-01-01T00:00:00.000Z', 329 }, 330 }, 331 }); 332 333 vi.mocked(mockClient.getAgent).mockReturnValue({ 334 com: { 335 atproto: { 336 repo: { 337 getRecord: mockGetRecord, 338 }, 339 }, 340 }, 341 } as never); 342 343 const result = await getIssue({ 344 client: mockClient, 345 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 346 }); 347 348 expect(result).toMatchObject({ 349 title: 'Test Issue', 350 body: 'Test Description', 351 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 352 cid: 'cid1', 353 }); 354 355 expect(mockGetRecord).toHaveBeenCalledWith({ 356 repo: 'did:plc:owner', 357 collection: 'sh.tangled.repo.issue', 358 rkey: 'issue1', 359 }); 360 }); 361 362 it('should throw error when issue not found', async () => { 363 const mockGetRecord = vi.fn().mockRejectedValue(new Error('Record not found')); 364 365 vi.mocked(mockClient.getAgent).mockReturnValue({ 366 com: { 367 atproto: { 368 repo: { 369 getRecord: mockGetRecord, 370 }, 371 }, 372 }, 373 } as never); 374 375 await expect( 376 getIssue({ 377 client: mockClient, 378 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 379 }) 380 ).rejects.toThrow('Issue not found'); 381 }); 382 383 it('should throw error for invalid issue URI', async () => { 384 await expect( 385 getIssue({ 386 client: mockClient, 387 issueUri: 'invalid-uri', 388 }) 389 ).rejects.toThrow('Invalid issue AT-URI'); 390 }); 391 392 it('should throw error when not authenticated', async () => { 393 mockClient = createMockClient(false); 394 395 await expect( 396 getIssue({ 397 client: mockClient, 398 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 399 }) 400 ).rejects.toThrow('Must be authenticated'); 401 }); 402}); 403 404describe('updateIssue', () => { 405 let mockClient: TangledApiClient; 406 407 beforeEach(() => { 408 mockClient = createMockClient(true); 409 }); 410 411 it('should update issue title', async () => { 412 const mockGetRecord = vi.fn().mockResolvedValue({ 413 data: { 414 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 415 cid: 'old-cid', 416 value: { 417 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 418 title: 'Old Title', 419 body: 'Original body', 420 createdAt: '2024-01-01T00:00:00.000Z', 421 }, 422 }, 423 }); 424 425 const mockPutRecord = vi.fn().mockResolvedValue({ 426 data: { 427 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 428 cid: 'new-cid', 429 }, 430 }); 431 432 vi.mocked(mockClient.getAgent).mockReturnValue({ 433 com: { 434 atproto: { 435 repo: { 436 getRecord: mockGetRecord, 437 putRecord: mockPutRecord, 438 }, 439 }, 440 }, 441 } as never); 442 443 const result = await updateIssue({ 444 client: mockClient, 445 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 446 title: 'New Title', 447 }); 448 449 expect(result.title).toBe('New Title'); 450 expect(result.body).toBe('Original body'); // Body unchanged 451 452 expect(mockPutRecord).toHaveBeenCalledWith({ 453 repo: 'did:plc:test123', 454 collection: 'sh.tangled.repo.issue', 455 rkey: 'issue1', 456 record: expect.objectContaining({ 457 title: 'New Title', 458 body: 'Original body', 459 }), 460 swapRecord: 'old-cid', 461 }); 462 }); 463 464 it('should update issue body', async () => { 465 const mockGetRecord = vi.fn().mockResolvedValue({ 466 data: { 467 uri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 468 cid: 'old-cid', 469 value: { 470 repo: 'at://did:plc:test123/sh.tangled.repo/my-repo', 471 title: 'Title', 472 body: 'Old body', 473 createdAt: '2024-01-01T00:00:00.000Z', 474 }, 475 }, 476 }); 477 478 const mockPutRecord = vi.fn().mockResolvedValue({ 479 data: { 480 cid: 'new-cid', 481 }, 482 }); 483 484 vi.mocked(mockClient.getAgent).mockReturnValue({ 485 com: { 486 atproto: { 487 repo: { 488 getRecord: mockGetRecord, 489 putRecord: mockPutRecord, 490 }, 491 }, 492 }, 493 } as never); 494 495 const result = await updateIssue({ 496 client: mockClient, 497 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 498 body: 'New body', 499 }); 500 501 expect(result.title).toBe('Title'); // Title unchanged 502 expect(result.body).toBe('New body'); 503 }); 504 505 it('should throw error when updating issue not owned by user', async () => { 506 await expect( 507 updateIssue({ 508 client: mockClient, 509 issueUri: 'at://did:plc:someone-else/sh.tangled.repo.issue/issue1', 510 title: 'New Title', 511 }) 512 ).rejects.toThrow('Cannot update issue: you are not the author'); 513 }); 514 515 it('should throw error when not authenticated', async () => { 516 mockClient = createMockClient(false); 517 518 await expect( 519 updateIssue({ 520 client: mockClient, 521 issueUri: 'at://did:plc:test123/sh.tangled.repo.issue/issue1', 522 title: 'New Title', 523 }) 524 ).rejects.toThrow('Must be authenticated'); 525 }); 526}); 527 528describe('closeIssue', () => { 529 let mockClient: TangledApiClient; 530 531 beforeEach(() => { 532 mockClient = createMockClient(true); 533 }); 534 535 it('should close an issue', async () => { 536 const mockGetRecord = vi.fn().mockResolvedValue({ 537 data: { 538 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 539 cid: 'cid1', 540 value: { 541 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 542 title: 'Test Issue', 543 createdAt: '2024-01-01T00:00:00.000Z', 544 }, 545 }, 546 }); 547 548 const mockCreateRecord = vi.fn().mockResolvedValue({ 549 data: { 550 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 551 cid: 'state-cid', 552 }, 553 }); 554 555 vi.mocked(mockClient.getAgent).mockReturnValue({ 556 com: { 557 atproto: { 558 repo: { 559 getRecord: mockGetRecord, 560 createRecord: mockCreateRecord, 561 }, 562 }, 563 }, 564 } as never); 565 566 await closeIssue({ 567 client: mockClient, 568 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 569 }); 570 571 expect(mockCreateRecord).toHaveBeenCalledWith({ 572 repo: 'did:plc:test123', 573 collection: 'sh.tangled.repo.issue.state', 574 record: { 575 $type: 'sh.tangled.repo.issue.state', 576 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 577 state: 'sh.tangled.repo.issue.state.closed', 578 }, 579 }); 580 }); 581 582 it('should throw error when not authenticated', async () => { 583 mockClient = createMockClient(false); 584 585 await expect( 586 closeIssue({ 587 client: mockClient, 588 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 589 }) 590 ).rejects.toThrow('Must be authenticated'); 591 }); 592}); 593 594describe('getIssueState', () => { 595 let mockClient: TangledApiClient; 596 597 beforeEach(() => { 598 mockClient = createMockClient(true); 599 }); 600 601 it('should return open when no state records exist', async () => { 602 const mockListRecords = vi.fn().mockResolvedValue({ 603 data: { records: [] }, 604 }); 605 606 vi.mocked(mockClient.getAgent).mockReturnValue({ 607 com: { atproto: { repo: { listRecords: mockListRecords } } }, 608 } as never); 609 610 const result = await getIssueState({ 611 client: mockClient, 612 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 613 }); 614 615 expect(result).toBe('open'); 616 expect(mockListRecords).toHaveBeenCalledWith({ 617 repo: 'did:plc:owner', 618 collection: 'sh.tangled.repo.issue.state', 619 limit: 100, 620 }); 621 }); 622 623 it('should return closed when latest state record is closed', async () => { 624 const mockListRecords = vi.fn().mockResolvedValue({ 625 data: { 626 records: [ 627 { 628 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 629 cid: 'cid1', 630 value: { 631 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 632 state: 'sh.tangled.repo.issue.state.closed', 633 }, 634 }, 635 ], 636 }, 637 }); 638 639 vi.mocked(mockClient.getAgent).mockReturnValue({ 640 com: { atproto: { repo: { listRecords: mockListRecords } } }, 641 } as never); 642 643 const result = await getIssueState({ 644 client: mockClient, 645 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 646 }); 647 648 expect(result).toBe('closed'); 649 }); 650 651 it('should return open when latest state record is open', async () => { 652 const mockListRecords = vi.fn().mockResolvedValue({ 653 data: { 654 records: [ 655 { 656 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 657 cid: 'cid1', 658 value: { 659 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 660 state: 'sh.tangled.repo.issue.state.closed', 661 }, 662 }, 663 { 664 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state2', 665 cid: 'cid2', 666 value: { 667 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 668 state: 'sh.tangled.repo.issue.state.open', 669 }, 670 }, 671 ], 672 }, 673 }); 674 675 vi.mocked(mockClient.getAgent).mockReturnValue({ 676 com: { atproto: { repo: { listRecords: mockListRecords } } }, 677 } as never); 678 679 const result = await getIssueState({ 680 client: mockClient, 681 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 682 }); 683 684 expect(result).toBe('open'); 685 }); 686 687 it('should filter state records to only the target issue', async () => { 688 const mockListRecords = vi.fn().mockResolvedValue({ 689 data: { 690 records: [ 691 { 692 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/state1', 693 cid: 'cid1', 694 value: { 695 issue: 'at://did:plc:owner/sh.tangled.repo.issue/other-issue', 696 state: 'sh.tangled.repo.issue.state.closed', 697 }, 698 }, 699 ], 700 }, 701 }); 702 703 vi.mocked(mockClient.getAgent).mockReturnValue({ 704 com: { atproto: { repo: { listRecords: mockListRecords } } }, 705 } as never); 706 707 // The closed state is for a different issue, so this one should be open 708 const result = await getIssueState({ 709 client: mockClient, 710 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 711 }); 712 713 expect(result).toBe('open'); 714 }); 715 716 it('should throw error when not authenticated', async () => { 717 mockClient = createMockClient(false); 718 719 await expect( 720 getIssueState({ 721 client: mockClient, 722 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 723 }) 724 ).rejects.toThrow('Must be authenticated'); 725 }); 726}); 727 728describe('reopenIssue', () => { 729 let mockClient: TangledApiClient; 730 731 beforeEach(() => { 732 mockClient = createMockClient(true); 733 }); 734 735 it('should reopen a closed issue', async () => { 736 const mockGetRecord = vi.fn().mockResolvedValue({ 737 data: { 738 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 739 cid: 'cid1', 740 value: { 741 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 742 title: 'Test Issue', 743 createdAt: '2024-01-01T00:00:00.000Z', 744 }, 745 }, 746 }); 747 748 const mockCreateRecord = vi.fn().mockResolvedValue({ 749 data: { 750 uri: 'at://did:plc:test123/sh.tangled.repo.issue.state/state1', 751 cid: 'state-cid', 752 }, 753 }); 754 755 vi.mocked(mockClient.getAgent).mockReturnValue({ 756 com: { 757 atproto: { 758 repo: { 759 getRecord: mockGetRecord, 760 createRecord: mockCreateRecord, 761 }, 762 }, 763 }, 764 } as never); 765 766 await reopenIssue({ 767 client: mockClient, 768 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 769 }); 770 771 expect(mockCreateRecord).toHaveBeenCalledWith({ 772 repo: 'did:plc:test123', 773 collection: 'sh.tangled.repo.issue.state', 774 record: { 775 $type: 'sh.tangled.repo.issue.state', 776 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 777 state: 'sh.tangled.repo.issue.state.open', 778 }, 779 }); 780 }); 781 782 it('should throw error when not authenticated', async () => { 783 mockClient = createMockClient(false); 784 785 await expect( 786 reopenIssue({ 787 client: mockClient, 788 issueUri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 789 }) 790 ).rejects.toThrow('Must be authenticated'); 791 }); 792}); 793 794describe('resolveSequentialNumber', () => { 795 let mockClient: TangledApiClient; 796 797 beforeEach(() => { 798 mockClient = createMockClient(true); 799 }); 800 801 it('should return number directly for #N displayId without an API call (fast path)', async () => { 802 const result = await resolveSequentialNumber( 803 '#3', 804 'at://did:plc:owner/sh.tangled.repo.issue/issue3', 805 mockClient, 806 'at://did:plc:owner/sh.tangled.repo/my-repo' 807 ); 808 expect(result).toBe(3); 809 }); 810 811 it('should scan issue list and return 1-based position for rkey displayId', async () => { 812 const mockListRecords = vi.fn().mockResolvedValue({ 813 data: { 814 records: [ 815 { 816 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 817 cid: 'cid1', 818 value: { 819 $type: 'sh.tangled.repo.issue', 820 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 821 title: 'First', 822 createdAt: '2024-01-01T00:00:00.000Z', 823 }, 824 }, 825 { 826 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 827 cid: 'cid2', 828 value: { 829 $type: 'sh.tangled.repo.issue', 830 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 831 title: 'Second', 832 createdAt: '2024-01-02T00:00:00.000Z', 833 }, 834 }, 835 ], 836 cursor: undefined, 837 }, 838 }); 839 840 vi.mocked(mockClient.getAgent).mockReturnValue({ 841 com: { atproto: { repo: { listRecords: mockListRecords } } }, 842 } as never); 843 844 const result = await resolveSequentialNumber( 845 'issue-b', 846 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 847 mockClient, 848 'at://did:plc:owner/sh.tangled.repo/my-repo' 849 ); 850 expect(result).toBe(2); 851 }); 852 853 it('should return undefined when issue URI not found in list', async () => { 854 const mockListRecords = vi.fn().mockResolvedValue({ 855 data: { 856 records: [ 857 { 858 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 859 cid: 'cid1', 860 value: { 861 $type: 'sh.tangled.repo.issue', 862 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 863 title: 'First', 864 createdAt: '2024-01-01T00:00:00.000Z', 865 }, 866 }, 867 ], 868 cursor: undefined, 869 }, 870 }); 871 872 vi.mocked(mockClient.getAgent).mockReturnValue({ 873 com: { atproto: { repo: { listRecords: mockListRecords } } }, 874 } as never); 875 876 const result = await resolveSequentialNumber( 877 'nonexistent', 878 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 879 mockClient, 880 'at://did:plc:owner/sh.tangled.repo/my-repo' 881 ); 882 expect(result).toBeUndefined(); 883 }); 884}); 885 886describe('getCompleteIssueData', () => { 887 let mockClient: TangledApiClient; 888 889 beforeEach(() => { 890 mockClient = createMockClient(true); 891 }); 892 893 it('should return all fields including fetched state', async () => { 894 const mockGetRecord = vi.fn().mockResolvedValue({ 895 data: { 896 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 897 cid: 'cid1', 898 value: { 899 $type: 'sh.tangled.repo.issue', 900 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 901 title: 'Test Issue', 902 body: 'Test body', 903 createdAt: '2024-01-01T00:00:00.000Z', 904 }, 905 }, 906 }); 907 908 // getIssueState uses listRecords on the state collection 909 const mockListRecords = vi.fn().mockResolvedValue({ 910 data: { 911 records: [ 912 { 913 uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 914 cid: 'scid1', 915 value: { 916 issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 917 state: 'sh.tangled.repo.issue.state.closed', 918 }, 919 }, 920 ], 921 }, 922 }); 923 924 vi.mocked(mockClient.getAgent).mockReturnValue({ 925 com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 926 } as never); 927 928 const result = await getCompleteIssueData( 929 mockClient, 930 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 931 '#1', // fast-path for number — no listRecords call for issues 932 'at://did:plc:owner/sh.tangled.repo/my-repo' 933 ); 934 935 expect(result).toEqual({ 936 number: 1, 937 title: 'Test Issue', 938 body: 'Test body', 939 state: 'closed', 940 author: 'did:plc:owner', 941 createdAt: '2024-01-01T00:00:00.000Z', 942 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 943 cid: 'cid1', 944 }); 945 }); 946 947 it('should use stateOverride and skip the getIssueState network call', async () => { 948 const mockGetRecord = vi.fn().mockResolvedValue({ 949 data: { 950 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 951 cid: 'cid1', 952 value: { 953 $type: 'sh.tangled.repo.issue', 954 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 955 title: 'Test Issue', 956 createdAt: '2024-01-01T00:00:00.000Z', 957 }, 958 }, 959 }); 960 961 const mockListRecords = vi.fn(); 962 vi.mocked(mockClient.getAgent).mockReturnValue({ 963 com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 964 } as never); 965 966 const result = await getCompleteIssueData( 967 mockClient, 968 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 969 '#2', 970 'at://did:plc:owner/sh.tangled.repo/my-repo', 971 'closed' 972 ); 973 974 expect(result.number).toBe(2); 975 expect(result.state).toBe('closed'); 976 expect(mockListRecords).not.toHaveBeenCalled(); 977 }); 978 979 it('should return undefined body and default open state when issue has no body or state records', async () => { 980 const mockGetRecord = vi.fn().mockResolvedValue({ 981 data: { 982 uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 983 cid: 'cid1', 984 value: { 985 $type: 'sh.tangled.repo.issue', 986 repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 987 title: 'No body issue', 988 createdAt: '2024-01-01T00:00:00.000Z', 989 }, 990 }, 991 }); 992 993 vi.mocked(mockClient.getAgent).mockReturnValue({ 994 com: { 995 atproto: { 996 repo: { 997 getRecord: mockGetRecord, 998 listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 999 }, 1000 }, 1001 }, 1002 } as never); 1003 1004 const result = await getCompleteIssueData( 1005 mockClient, 1006 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1007 '#1', 1008 'at://did:plc:owner/sh.tangled.repo/my-repo' 1009 ); 1010 1011 expect(result.body).toBeUndefined(); 1012 expect(result.state).toBe('open'); 1013 }); 1014});