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 main 1203 lines 42 kB view raw
1import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2import { createIssueCommand } from '../../src/commands/issue.js'; 3import type { TangledApiClient } from '../../src/lib/api-client.js'; 4import * as apiClient from '../../src/lib/api-client.js'; 5import * as context from '../../src/lib/context.js'; 6import type { IssueWithMetadata } from '../../src/lib/issues-api.js'; 7import * as issuesApi from '../../src/lib/issues-api.js'; 8import * as atUri from '../../src/utils/at-uri.js'; 9import * as authHelpers from '../../src/utils/auth-helpers.js'; 10import * as bodyInput from '../../src/utils/body-input.js'; 11 12// Mock dependencies 13vi.mock('../../src/lib/api-client.js'); 14vi.mock('../../src/lib/issues-api.js'); 15vi.mock('../../src/lib/context.js'); 16vi.mock('../../src/utils/at-uri.js'); 17vi.mock('../../src/utils/body-input.js'); 18vi.mock('../../src/utils/auth-helpers.js'); 19vi.mock('@inquirer/prompts'); 20 21describe('issue create command', () => { 22 let mockClient: TangledApiClient; 23 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 24 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 25 let processExitSpy: ReturnType<typeof vi.spyOn>; 26 27 beforeEach(() => { 28 // Mock console methods 29 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 30 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 31 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 32 throw new Error(`process.exit(${code})`); 33 }) as never; 34 35 // Mock API client 36 mockClient = { 37 resumeSession: vi.fn(async () => true), 38 } as unknown as TangledApiClient; 39 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 40 41 // Mock context 42 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 43 owner: 'test.bsky.social', 44 ownerType: 'handle', 45 name: 'test-repo', 46 remoteName: 'origin', 47 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 48 protocol: 'ssh', 49 }); 50 51 // Mock AT-URI builder 52 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue( 53 'at://did:plc:abc123/sh.tangled.repo/test-repo' 54 ); 55 56 // Mock body input 57 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 58 59 // Default listIssues mock (needed for sequential number computation after create) 60 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 61 }); 62 63 afterEach(() => { 64 vi.restoreAllMocks(); 65 }); 66 67 describe('create with --body flag', () => { 68 it('should create issue with body text', async () => { 69 const mockIssue: IssueWithMetadata = { 70 $type: 'sh.tangled.repo.issue', 71 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 72 title: 'Test Issue', 73 body: 'Test body', 74 createdAt: new Date().toISOString(), 75 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 76 cid: 'bafyreiabc123', 77 author: 'did:plc:abc123', 78 }; 79 80 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 81 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 82 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 83 84 const command = createIssueCommand(); 85 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); 86 87 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 88 client: mockClient, 89 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 90 title: 'Test Issue', 91 body: 'Test body', 92 }); 93 94 expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 95 expect(consoleLogSpy).toHaveBeenCalledWith('\n✓ Issue #1 created'); 96 }); 97 }); 98 99 describe('create with --body-file flag', () => { 100 it('should create issue with body from file', async () => { 101 const mockIssue: IssueWithMetadata = { 102 $type: 'sh.tangled.repo.issue', 103 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 104 title: 'Test Issue', 105 body: 'Body from file', 106 createdAt: new Date().toISOString(), 107 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/xyz789', 108 cid: 'bafyreixyz789', 109 author: 'did:plc:abc123', 110 }; 111 112 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 113 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 114 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 115 116 const command = createIssueCommand(); 117 await command.parseAsync([ 118 'node', 119 'test', 120 'create', 121 'Test Issue', 122 '--body-file', 123 '/tmp/body.txt', 124 ]); 125 126 expect(bodyInput.readBodyInput).toHaveBeenCalledWith(undefined, '/tmp/body.txt'); 127 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 128 client: mockClient, 129 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 130 title: 'Test Issue', 131 body: 'Body from file', 132 }); 133 }); 134 }); 135 136 describe('create without body', () => { 137 it('should create issue without body', async () => { 138 const mockIssue: IssueWithMetadata = { 139 $type: 'sh.tangled.repo.issue', 140 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 141 title: 'Test Issue', 142 createdAt: new Date().toISOString(), 143 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/test123', 144 cid: 'bafyreitest123', 145 author: 'did:plc:abc123', 146 }; 147 148 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 149 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 150 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 151 152 const command = createIssueCommand(); 153 await command.parseAsync(['node', 'test', 'create', 'Test Issue']); 154 155 expect(issuesApi.createIssue).toHaveBeenCalledWith({ 156 client: mockClient, 157 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 158 title: 'Test Issue', 159 body: undefined, 160 }); 161 }); 162 }); 163 164 describe('authentication required', () => { 165 it('should fail when not authenticated', async () => { 166 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 167 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 168 process.exit(1); 169 }); 170 171 const command = createIssueCommand(); 172 173 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 174 'process.exit(1)' 175 ); 176 177 expect(consoleErrorSpy).toHaveBeenCalledWith( 178 '✗ Not authenticated. Run "tangled auth login" first.' 179 ); 180 expect(processExitSpy).toHaveBeenCalledWith(1); 181 }); 182 }); 183 184 describe('context required', () => { 185 it('should fail when not in a Tangled repository', async () => { 186 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 187 188 const command = createIssueCommand(); 189 190 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 191 'process.exit(1)' 192 ); 193 194 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 195 expect(processExitSpy).toHaveBeenCalledWith(1); 196 }); 197 }); 198 199 describe('validation errors', () => { 200 it('should fail with empty title', async () => { 201 const command = createIssueCommand(); 202 203 await expect(command.parseAsync(['node', 'test', 'create', ''])).rejects.toThrow( 204 'process.exit(1)' 205 ); 206 207 expect(consoleErrorSpy).toHaveBeenCalledWith( 208 expect.stringContaining('Issue title cannot be empty') 209 ); 210 expect(processExitSpy).toHaveBeenCalledWith(1); 211 }); 212 213 it('should fail with title over 256 characters', async () => { 214 const longTitle = 'A'.repeat(257); 215 216 const command = createIssueCommand(); 217 218 await expect(command.parseAsync(['node', 'test', 'create', longTitle])).rejects.toThrow( 219 'process.exit(1)' 220 ); 221 222 expect(consoleErrorSpy).toHaveBeenCalledWith( 223 expect.stringContaining('Issue title must be 256 characters or less') 224 ); 225 expect(processExitSpy).toHaveBeenCalledWith(1); 226 }); 227 228 it('should fail with body over 50,000 characters', async () => { 229 const longBody = 'A'.repeat(50001); 230 231 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(longBody); 232 233 const command = createIssueCommand(); 234 235 await expect( 236 command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', longBody]) 237 ).rejects.toThrow('process.exit(1)'); 238 239 expect(consoleErrorSpy).toHaveBeenCalledWith( 240 expect.stringContaining('Issue body must be 50,000 characters or less') 241 ); 242 expect(processExitSpy).toHaveBeenCalledWith(1); 243 }); 244 }); 245 246 describe('API errors', () => { 247 it('should handle API errors gracefully', async () => { 248 vi.mocked(issuesApi.createIssue).mockRejectedValue(new Error('Network error')); 249 250 const command = createIssueCommand(); 251 252 await expect(command.parseAsync(['node', 'test', 'create', 'Test Issue'])).rejects.toThrow( 253 'process.exit(1)' 254 ); 255 256 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to create issue: Network error'); 257 expect(processExitSpy).toHaveBeenCalledWith(1); 258 }); 259 }); 260 261 describe('JSON output', () => { 262 const mockIssue: IssueWithMetadata = { 263 $type: 'sh.tangled.repo.issue', 264 repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 265 title: 'Test Issue', 266 body: 'Test body', 267 createdAt: '2024-01-01T00:00:00.000Z', 268 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 269 cid: 'bafyreiabc123', 270 author: 'did:plc:abc123', 271 }; 272 273 it('should output JSON of created issue when --json is passed', async () => { 274 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 275 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 276 277 const command = createIssueCommand(); 278 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); 279 280 // Should NOT print human-readable messages 281 expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...'); 282 283 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 284 expect(jsonOutput).toMatchObject({ 285 number: 1, 286 title: 'Test Issue', 287 body: 'Test body', 288 author: 'did:plc:abc123', 289 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 290 cid: 'bafyreiabc123', 291 }); 292 }); 293 294 it('should output filtered JSON when --json with fields is passed', async () => { 295 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 296 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 297 298 const command = createIssueCommand(); 299 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'number,uri']); 300 301 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 302 expect(jsonOutput).toEqual({ 303 number: 1, 304 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 305 }); 306 expect(jsonOutput).not.toHaveProperty('title'); 307 expect(jsonOutput).not.toHaveProperty('body'); 308 expect(jsonOutput).not.toHaveProperty('author'); 309 }); 310 }); 311}); 312 313describe('issue list command', () => { 314 let mockClient: TangledApiClient; 315 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 316 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 317 let processExitSpy: ReturnType<typeof vi.spyOn>; 318 319 beforeEach(() => { 320 // Mock console methods 321 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 322 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 323 processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { 324 throw new Error(`process.exit(${code})`); 325 }) as never; 326 327 // Mock API client 328 mockClient = { 329 resumeSession: vi.fn(async () => true), 330 } as unknown as TangledApiClient; 331 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 332 333 // Mock context 334 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 335 owner: 'test.bsky.social', 336 ownerType: 'handle', 337 name: 'test-repo', 338 remoteName: 'origin', 339 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 340 protocol: 'ssh', 341 }); 342 343 // Mock AT-URI builder 344 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 345 }); 346 347 afterEach(() => { 348 vi.restoreAllMocks(); 349 }); 350 351 describe('list issues', () => { 352 it('should list issues successfully', async () => { 353 const mockIssues: IssueWithMetadata[] = [ 354 { 355 $type: 'sh.tangled.repo.issue', 356 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 357 title: 'First Issue', 358 createdAt: new Date('2024-01-01').toISOString(), 359 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 360 cid: 'bafyrei1', 361 author: 'did:plc:abc123', 362 }, 363 { 364 $type: 'sh.tangled.repo.issue', 365 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 366 title: 'Second Issue', 367 createdAt: new Date('2024-01-02').toISOString(), 368 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 369 cid: 'bafyrei2', 370 author: 'did:plc:abc123', 371 }, 372 ]; 373 374 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 375 issues: mockIssues, 376 cursor: undefined, 377 }); 378 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 379 380 const command = createIssueCommand(); 381 await command.parseAsync(['node', 'test', 'list']); 382 383 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 384 client: mockClient, 385 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 386 limit: 50, 387 }); 388 389 expect(consoleLogSpy).toHaveBeenCalledWith('\nFound 2 issues:\n'); 390 expect(consoleLogSpy).toHaveBeenCalledWith(' #1 [OPEN] First Issue'); 391 expect(consoleLogSpy).toHaveBeenCalledWith(' #2 [OPEN] Second Issue'); 392 }); 393 394 it('should handle custom limit', async () => { 395 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 396 issues: [], 397 cursor: undefined, 398 }); 399 400 const command = createIssueCommand(); 401 await command.parseAsync(['node', 'test', 'list', '--limit', '25']); 402 403 expect(issuesApi.listIssues).toHaveBeenCalledWith({ 404 client: mockClient, 405 repoAtUri: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 406 limit: 25, 407 }); 408 }); 409 410 it('should handle empty issue list', async () => { 411 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 412 issues: [], 413 cursor: undefined, 414 }); 415 416 const command = createIssueCommand(); 417 await command.parseAsync(['node', 'test', 'list']); 418 419 expect(consoleLogSpy).toHaveBeenCalledWith('No issues found for this repository.'); 420 }); 421 }); 422 423 describe('authentication required', () => { 424 it('should fail when not authenticated', async () => { 425 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 426 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 427 process.exit(1); 428 }); 429 430 const command = createIssueCommand(); 431 432 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 433 434 expect(consoleErrorSpy).toHaveBeenCalledWith( 435 '✗ Not authenticated. Run "tangled auth login" first.' 436 ); 437 expect(processExitSpy).toHaveBeenCalledWith(1); 438 }); 439 }); 440 441 describe('context required', () => { 442 it('should fail when not in a Tangled repository', async () => { 443 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 444 445 const command = createIssueCommand(); 446 447 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 448 449 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 450 expect(processExitSpy).toHaveBeenCalledWith(1); 451 }); 452 }); 453 454 describe('validation errors', () => { 455 it('should fail with invalid limit (too low)', async () => { 456 const command = createIssueCommand(); 457 458 await expect(command.parseAsync(['node', 'test', 'list', '--limit', '0'])).rejects.toThrow( 459 'process.exit(1)' 460 ); 461 462 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 463 expect(processExitSpy).toHaveBeenCalledWith(1); 464 }); 465 466 it('should fail with invalid limit (too high)', async () => { 467 const command = createIssueCommand(); 468 469 await expect(command.parseAsync(['node', 'test', 'list', '--limit', '101'])).rejects.toThrow( 470 'process.exit(1)' 471 ); 472 473 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 474 expect(processExitSpy).toHaveBeenCalledWith(1); 475 }); 476 477 it('should fail with non-numeric limit', async () => { 478 const command = createIssueCommand(); 479 480 await expect(command.parseAsync(['node', 'test', 'list', '--limit', 'abc'])).rejects.toThrow( 481 'process.exit(1)' 482 ); 483 484 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Invalid limit. Must be between 1 and 100.'); 485 expect(processExitSpy).toHaveBeenCalledWith(1); 486 }); 487 }); 488 489 describe('API errors', () => { 490 it('should handle API errors gracefully', async () => { 491 vi.mocked(issuesApi.listIssues).mockRejectedValue(new Error('Network error')); 492 493 const command = createIssueCommand(); 494 495 await expect(command.parseAsync(['node', 'test', 'list'])).rejects.toThrow('process.exit(1)'); 496 497 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Failed to list issues: Network error'); 498 expect(processExitSpy).toHaveBeenCalledWith(1); 499 }); 500 }); 501 502 describe('JSON output', () => { 503 const mockIssues: IssueWithMetadata[] = [ 504 { 505 $type: 'sh.tangled.repo.issue', 506 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 507 title: 'First Issue', 508 body: 'First body', 509 createdAt: new Date('2024-01-01').toISOString(), 510 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 511 cid: 'bafyrei1', 512 author: 'did:plc:abc123', 513 }, 514 { 515 $type: 'sh.tangled.repo.issue', 516 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 517 title: 'Second Issue', 518 createdAt: new Date('2024-01-02').toISOString(), 519 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 520 cid: 'bafyrei2', 521 author: 'did:plc:abc123', 522 }, 523 ]; 524 525 beforeEach(() => { 526 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 527 issues: mockIssues, 528 cursor: undefined, 529 }); 530 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 531 }); 532 533 it('should output JSON array when --json is passed', async () => { 534 const command = createIssueCommand(); 535 await command.parseAsync(['node', 'test', 'list', '--json']); 536 537 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 538 expect(Array.isArray(jsonOutput)).toBe(true); 539 expect(jsonOutput).toHaveLength(2); 540 expect(jsonOutput[0]).toMatchObject({ 541 number: 1, 542 title: 'First Issue', 543 state: 'open', 544 author: 'did:plc:abc123', 545 }); 546 expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' }); 547 }); 548 549 it('should output filtered JSON when --json with fields is passed', async () => { 550 const command = createIssueCommand(); 551 await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']); 552 553 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 554 expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' }); 555 expect(jsonOutput[0]).not.toHaveProperty('author'); 556 expect(jsonOutput[0]).not.toHaveProperty('uri'); 557 }); 558 559 it('should output empty JSON array when no issues exist', async () => { 560 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 561 562 const command = createIssueCommand(); 563 await command.parseAsync(['node', 'test', 'list', '--json']); 564 565 expect(consoleLogSpy).toHaveBeenCalledWith('[]'); 566 }); 567 }); 568}); 569 570describe('issue view command', () => { 571 let mockClient: TangledApiClient; 572 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 573 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 574 575 const mockIssue: IssueWithMetadata = { 576 $type: 'sh.tangled.repo.issue', 577 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 578 title: 'Test Issue', 579 body: 'Issue body', 580 createdAt: new Date('2024-01-01').toISOString(), 581 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 582 cid: 'bafyrei1', 583 author: 'did:plc:abc123', 584 }; 585 586 beforeEach(() => { 587 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 588 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 589 vi.spyOn(process, 'exit').mockImplementation((code) => { 590 throw new Error(`process.exit(${code})`); 591 }) as never; 592 593 mockClient = { 594 resumeSession: vi.fn(async () => true), 595 } as unknown as TangledApiClient; 596 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 597 598 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 599 owner: 'test.bsky.social', 600 ownerType: 'handle', 601 name: 'test-repo', 602 remoteName: 'origin', 603 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 604 protocol: 'ssh', 605 }); 606 607 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 608 609 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 610 did: 'did:plc:abc123', 611 handle: 'test.bsky.social', 612 } as never); 613 }); 614 615 afterEach(() => { 616 vi.restoreAllMocks(); 617 }); 618 619 it('should view issue by number', async () => { 620 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 621 issues: [mockIssue], 622 cursor: undefined, 623 }); 624 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 625 number: 1, 626 title: mockIssue.title, 627 body: mockIssue.body, 628 state: 'open', 629 author: mockIssue.author, 630 createdAt: mockIssue.createdAt, 631 uri: mockIssue.uri, 632 cid: mockIssue.cid, 633 }); 634 635 const command = createIssueCommand(); 636 await command.parseAsync(['node', 'test', 'view', '1']); 637 638 expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 639 mockClient, 640 mockIssue.uri, 641 '#1', 642 'at://did:plc:abc123/sh.tangled.repo/xyz789' 643 ); 644 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 645 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 646 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); 647 expect(consoleLogSpy).toHaveBeenCalledWith('Issue body'); 648 }); 649 650 it('should view issue by rkey', async () => { 651 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 652 number: undefined, 653 title: mockIssue.title, 654 body: mockIssue.body, 655 state: 'closed', 656 author: mockIssue.author, 657 createdAt: mockIssue.createdAt, 658 uri: mockIssue.uri, 659 cid: mockIssue.cid, 660 }); 661 662 const command = createIssueCommand(); 663 await command.parseAsync(['node', 'test', 'view', 'issue1']); 664 665 expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 666 mockClient, 667 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 668 'issue1', 669 'at://did:plc:abc123/sh.tangled.repo/xyz789' 670 ); 671 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 672 }); 673 674 it('should show issue without body', async () => { 675 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 676 issues: [mockIssue], 677 cursor: undefined, 678 }); 679 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 680 number: 1, 681 title: mockIssue.title, 682 body: undefined, 683 state: 'open', 684 author: mockIssue.author, 685 createdAt: mockIssue.createdAt, 686 uri: mockIssue.uri, 687 cid: mockIssue.cid, 688 }); 689 690 const command = createIssueCommand(); 691 await command.parseAsync(['node', 'test', 'view', '1']); 692 693 const allCalls = consoleLogSpy.mock.calls.map((c) => c[0]); 694 expect(allCalls).not.toContain('Body:'); 695 }); 696 697 it('should fail when not authenticated', async () => { 698 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 699 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 700 process.exit(1); 701 }); 702 703 const command = createIssueCommand(); 704 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 705 'process.exit(1)' 706 ); 707 708 expect(consoleErrorSpy).toHaveBeenCalledWith( 709 '✗ Not authenticated. Run "tangled auth login" first.' 710 ); 711 }); 712 713 it('should fail when not in a Tangled repository', async () => { 714 vi.mocked(context.getCurrentRepoContext).mockResolvedValue(null); 715 716 const command = createIssueCommand(); 717 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( 718 'process.exit(1)' 719 ); 720 721 expect(consoleErrorSpy).toHaveBeenCalledWith('✗ Not in a Tangled repository'); 722 }); 723 724 it('should fail when issue number is out of range', async () => { 725 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 726 issues: [mockIssue], 727 cursor: undefined, 728 }); 729 730 const command = createIssueCommand(); 731 await expect(command.parseAsync(['node', 'test', 'view', '99'])).rejects.toThrow( 732 'process.exit(1)' 733 ); 734 735 expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); 736 }); 737 738 describe('JSON output', () => { 739 it('should output JSON when --json is passed', async () => { 740 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 741 issues: [mockIssue], 742 cursor: undefined, 743 }); 744 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 745 number: 1, 746 title: mockIssue.title, 747 body: mockIssue.body, 748 state: 'open', 749 author: mockIssue.author, 750 createdAt: mockIssue.createdAt, 751 uri: mockIssue.uri, 752 cid: mockIssue.cid, 753 }); 754 755 const command = createIssueCommand(); 756 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 757 758 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 759 expect(jsonOutput).toMatchObject({ 760 number: 1, 761 title: 'Test Issue', 762 body: 'Issue body', 763 state: 'open', 764 author: 'did:plc:abc123', 765 uri: mockIssue.uri, 766 cid: mockIssue.cid, 767 }); 768 }); 769 770 it('should output filtered JSON when --json with fields is passed', async () => { 771 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 772 issues: [mockIssue], 773 cursor: undefined, 774 }); 775 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 776 number: 1, 777 title: mockIssue.title, 778 body: mockIssue.body, 779 state: 'closed', 780 author: mockIssue.author, 781 createdAt: mockIssue.createdAt, 782 uri: mockIssue.uri, 783 cid: mockIssue.cid, 784 }); 785 786 const command = createIssueCommand(); 787 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); 788 789 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 790 expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' }); 791 expect(jsonOutput).not.toHaveProperty('body'); 792 expect(jsonOutput).not.toHaveProperty('author'); 793 }); 794 }); 795}); 796 797describe('issue edit command', () => { 798 let mockClient: TangledApiClient; 799 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 800 let consoleErrorSpy: ReturnType<typeof vi.spyOn>; 801 802 const mockIssue: IssueWithMetadata = { 803 $type: 'sh.tangled.repo.issue', 804 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 805 title: 'Original Title', 806 createdAt: new Date('2024-01-01').toISOString(), 807 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 808 cid: 'bafyrei1', 809 author: 'did:plc:abc123', 810 }; 811 812 beforeEach(() => { 813 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 814 consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) as never; 815 vi.spyOn(process, 'exit').mockImplementation((code) => { 816 throw new Error(`process.exit(${code})`); 817 }) as never; 818 819 mockClient = { 820 resumeSession: vi.fn(async () => true), 821 } as unknown as TangledApiClient; 822 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 823 824 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 825 owner: 'test.bsky.social', 826 ownerType: 'handle', 827 name: 'test-repo', 828 remoteName: 'origin', 829 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 830 protocol: 'ssh', 831 }); 832 833 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 834 835 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 836 vi.mocked(authHelpers.requireAuth).mockResolvedValue({ 837 did: 'did:plc:abc123', 838 handle: 'test.bsky.social', 839 } as never); 840 }); 841 842 afterEach(() => { 843 vi.restoreAllMocks(); 844 }); 845 846 it('should edit issue title by number', async () => { 847 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 848 issues: [mockIssue], 849 cursor: undefined, 850 }); 851 vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, title: 'New Title' }); 852 853 const command = createIssueCommand(); 854 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title']); 855 856 expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 857 client: mockClient, 858 issueUri: mockIssue.uri, 859 title: 'New Title', 860 body: undefined, 861 }); 862 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 updated'); 863 expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: title'); 864 }); 865 866 it('should edit issue body', async () => { 867 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 868 issues: [mockIssue], 869 cursor: undefined, 870 }); 871 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('New body'); 872 vi.mocked(issuesApi.updateIssue).mockResolvedValue({ ...mockIssue, body: 'New body' }); 873 874 const command = createIssueCommand(); 875 await command.parseAsync(['node', 'test', 'edit', '1', '--body', 'New body']); 876 877 expect(issuesApi.updateIssue).toHaveBeenCalledWith({ 878 client: mockClient, 879 issueUri: mockIssue.uri, 880 title: undefined, 881 body: 'New body', 882 }); 883 expect(consoleLogSpy).toHaveBeenCalledWith(' Updated: body'); 884 }); 885 886 it('should fail when no options provided', async () => { 887 const command = createIssueCommand(); 888 await expect(command.parseAsync(['node', 'test', 'edit', '1'])).rejects.toThrow( 889 'process.exit(1)' 890 ); 891 892 expect(consoleErrorSpy).toHaveBeenCalledWith( 893 '✗ At least one of --title, --body, or --body-file must be provided' 894 ); 895 }); 896 897 it('should fail when not authenticated', async () => { 898 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 899 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 900 process.exit(1); 901 }); 902 903 const command = createIssueCommand(); 904 await expect( 905 command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New']) 906 ).rejects.toThrow('process.exit(1)'); 907 908 expect(consoleErrorSpy).toHaveBeenCalledWith( 909 '✗ Not authenticated. Run "tangled auth login" first.' 910 ); 911 }); 912 913 describe('JSON output', () => { 914 it('should output JSON of updated issue when --json is passed', async () => { 915 const updatedIssue = { ...mockIssue, title: 'New Title' }; 916 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 917 issues: [mockIssue], 918 cursor: undefined, 919 }); 920 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 921 vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 922 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 923 924 const command = createIssueCommand(); 925 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 926 927 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 928 expect(jsonOutput).toMatchObject({ 929 number: 1, 930 title: 'New Title', 931 state: 'open', 932 author: 'did:plc:abc123', 933 uri: mockIssue.uri, 934 cid: mockIssue.cid, 935 }); 936 // Human-readable messages should NOT appear 937 expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated'); 938 }); 939 940 it('should output filtered JSON when --json with fields is passed', async () => { 941 const updatedIssue = { ...mockIssue, title: 'New Title' }; 942 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 943 issues: [mockIssue], 944 cursor: undefined, 945 }); 946 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 947 vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 948 vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 949 950 const command = createIssueCommand(); 951 await command.parseAsync([ 952 'node', 953 'test', 954 'edit', 955 '1', 956 '--title', 957 'New Title', 958 '--json', 959 'title,uri', 960 ]); 961 962 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 963 expect(jsonOutput).toEqual({ 964 title: 'New Title', 965 uri: mockIssue.uri, 966 }); 967 expect(jsonOutput).not.toHaveProperty('author'); 968 }); 969 }); 970}); 971 972describe('issue close command', () => { 973 let mockClient: TangledApiClient; 974 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 975 976 const mockIssue: IssueWithMetadata = { 977 $type: 'sh.tangled.repo.issue', 978 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 979 title: 'Test Issue', 980 createdAt: new Date('2024-01-01').toISOString(), 981 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 982 cid: 'bafyrei1', 983 author: 'did:plc:abc123', 984 }; 985 986 beforeEach(() => { 987 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 988 vi.spyOn(console, 'error').mockImplementation(() => {}); 989 vi.spyOn(process, 'exit').mockImplementation((code) => { 990 throw new Error(`process.exit(${code})`); 991 }) as never; 992 993 mockClient = { 994 resumeSession: vi.fn(async () => true), 995 } as unknown as TangledApiClient; 996 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 997 998 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 999 owner: 'test.bsky.social', 1000 ownerType: 'handle', 1001 name: 'test-repo', 1002 remoteName: 'origin', 1003 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 1004 protocol: 'ssh', 1005 }); 1006 1007 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1008 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1009 number: 1, 1010 title: mockIssue.title, 1011 body: undefined, 1012 state: 'closed', 1013 author: mockIssue.author, 1014 createdAt: mockIssue.createdAt, 1015 uri: mockIssue.uri, 1016 cid: mockIssue.cid, 1017 }); 1018 }); 1019 1020 afterEach(() => { 1021 vi.restoreAllMocks(); 1022 }); 1023 1024 it('should close issue by number', async () => { 1025 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1026 issues: [mockIssue], 1027 cursor: undefined, 1028 }); 1029 vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1030 1031 const command = createIssueCommand(); 1032 await command.parseAsync(['node', 'test', 'close', '1']); 1033 1034 expect(issuesApi.closeIssue).toHaveBeenCalledWith({ 1035 client: mockClient, 1036 issueUri: mockIssue.uri, 1037 }); 1038 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 closed'); 1039 expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1040 }); 1041 1042 it('should fail when not authenticated', async () => { 1043 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1044 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1045 process.exit(1); 1046 }); 1047 1048 const command = createIssueCommand(); 1049 await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( 1050 'process.exit(1)' 1051 ); 1052 }); 1053 1054 describe('JSON output', () => { 1055 it('should output JSON when --json is passed', async () => { 1056 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1057 vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1058 1059 const command = createIssueCommand(); 1060 await command.parseAsync(['node', 'test', 'close', '1', '--json']); 1061 1062 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1063 expect(jsonOutput).toEqual({ 1064 number: 1, 1065 title: 'Test Issue', 1066 state: 'closed', 1067 author: mockIssue.author, 1068 createdAt: mockIssue.createdAt, 1069 uri: mockIssue.uri, 1070 cid: mockIssue.cid, 1071 }); 1072 }); 1073 1074 it('should output filtered JSON when --json with fields is passed', async () => { 1075 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1076 vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1077 1078 const command = createIssueCommand(); 1079 await command.parseAsync(['node', 'test', 'close', '1', '--json', 'number,state']); 1080 1081 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1082 expect(jsonOutput).toEqual({ number: 1, state: 'closed' }); 1083 expect(jsonOutput).not.toHaveProperty('title'); 1084 expect(jsonOutput).not.toHaveProperty('uri'); 1085 }); 1086 }); 1087}); 1088 1089describe('issue reopen command', () => { 1090 let mockClient: TangledApiClient; 1091 let consoleLogSpy: ReturnType<typeof vi.spyOn>; 1092 1093 const mockIssue: IssueWithMetadata = { 1094 $type: 'sh.tangled.repo.issue', 1095 repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 1096 title: 'Test Issue', 1097 createdAt: new Date('2024-01-01').toISOString(), 1098 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 1099 cid: 'bafyrei1', 1100 author: 'did:plc:abc123', 1101 }; 1102 1103 beforeEach(() => { 1104 consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) as never; 1105 vi.spyOn(console, 'error').mockImplementation(() => {}); 1106 vi.spyOn(process, 'exit').mockImplementation((code) => { 1107 throw new Error(`process.exit(${code})`); 1108 }) as never; 1109 1110 mockClient = { 1111 resumeSession: vi.fn(async () => true), 1112 } as unknown as TangledApiClient; 1113 vi.mocked(apiClient.createApiClient).mockReturnValue(mockClient); 1114 1115 vi.mocked(context.getCurrentRepoContext).mockResolvedValue({ 1116 owner: 'test.bsky.social', 1117 ownerType: 'handle', 1118 name: 'test-repo', 1119 remoteName: 'origin', 1120 remoteUrl: 'git@tangled.org:test.bsky.social/test-repo.git', 1121 protocol: 'ssh', 1122 }); 1123 1124 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1125 vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1126 number: 1, 1127 title: mockIssue.title, 1128 body: undefined, 1129 state: 'open', 1130 author: mockIssue.author, 1131 createdAt: mockIssue.createdAt, 1132 uri: mockIssue.uri, 1133 cid: mockIssue.cid, 1134 }); 1135 }); 1136 1137 afterEach(() => { 1138 vi.restoreAllMocks(); 1139 }); 1140 1141 it('should reopen issue by number', async () => { 1142 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 1143 issues: [mockIssue], 1144 cursor: undefined, 1145 }); 1146 vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1147 1148 const command = createIssueCommand(); 1149 await command.parseAsync(['node', 'test', 'reopen', '1']); 1150 1151 expect(issuesApi.reopenIssue).toHaveBeenCalledWith({ 1152 client: mockClient, 1153 issueUri: mockIssue.uri, 1154 }); 1155 expect(consoleLogSpy).toHaveBeenCalledWith('✓ Issue #1 reopened'); 1156 expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1157 }); 1158 1159 it('should fail when not authenticated', async () => { 1160 vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1161 console.error('✗ Not authenticated. Run "tangled auth login" first.'); 1162 process.exit(1); 1163 }); 1164 1165 const command = createIssueCommand(); 1166 await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( 1167 'process.exit(1)' 1168 ); 1169 }); 1170 1171 describe('JSON output', () => { 1172 it('should output JSON when --json is passed', async () => { 1173 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1174 vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1175 1176 const command = createIssueCommand(); 1177 await command.parseAsync(['node', 'test', 'reopen', '1', '--json']); 1178 1179 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1180 expect(jsonOutput).toEqual({ 1181 number: 1, 1182 title: 'Test Issue', 1183 state: 'open', 1184 author: mockIssue.author, 1185 createdAt: mockIssue.createdAt, 1186 uri: mockIssue.uri, 1187 cid: mockIssue.cid, 1188 }); 1189 }); 1190 1191 it('should output filtered JSON when --json with fields is passed', async () => { 1192 vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1193 vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1194 1195 const command = createIssueCommand(); 1196 await command.parseAsync(['node', 'test', 'reopen', '1', '--json', 'number,state']); 1197 1198 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1199 expect(jsonOutput).toEqual({ number: 1, state: 'open' }); 1200 expect(jsonOutput).not.toHaveProperty('title'); 1201 }); 1202 }); 1203});