Skip to content

Commit 3c9a13e

Browse files
committed
test(cli): extend pull-request tests with PR listing and cleanup
Add tests for getSocketFixPrs and cleanupSocketFixPrs functions: - PR listing with author filtering - GraphQL error handling - Empty response handling - Missing author handling - Early pagination exit optimization - Stale PR updates (BEHIND status) - Merged PR branch cleanup - Update and delete error handling
1 parent 19bfd8e commit 3c9a13e

File tree

1 file changed

+343
-6
lines changed

1 file changed

+343
-6
lines changed

packages/cli/test/unit/commands/fix/pull-request.test.mts

Lines changed: 343 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030

3131
import { beforeEach, describe, expect, it, vi } from 'vitest'
3232

33-
import { openSocketFixPr } from '../../../../src/commands/fix/pull-request.mts'
33+
import {
34+
cleanupSocketFixPrs,
35+
getSocketFixPrs,
36+
openSocketFixPr,
37+
} from '../../../../src/commands/fix/pull-request.mts'
3438

3539
const mockGetOctokit = vi.hoisted(() => vi.fn())
3640
const mockCreatePrProvider = vi.hoisted(() => vi.fn())
@@ -52,25 +56,47 @@ const mockWithGitHubRetry = vi.hoisted(() =>
5256
}),
5357
)
5458

59+
const mockGetSocketFixBranchPattern = vi.hoisted(() =>
60+
vi.fn(() => /^socket\/fix\/GHSA-.*/),
61+
)
62+
5563
// Mock dependencies.
5664
vi.mock('../../../../src/commands/fix/git.mts', () => ({
65+
getSocketFixBranchPattern: mockGetSocketFixBranchPattern,
5766
getSocketFixPullRequestTitle: mockGetSocketFixPullRequestTitle,
5867
getSocketFixPullRequestBody: mockGetSocketFixPullRequestBody,
5968
}))
6069

70+
// Mock debug.
71+
vi.mock('@socketsecurity/lib/debug', () => ({
72+
debug: vi.fn(),
73+
debugDir: vi.fn(),
74+
}))
75+
76+
// Mock pr-lifecycle-logger.
77+
vi.mock('../../../../src/commands/fix/pr-lifecycle-logger.mts', () => ({
78+
logPrEvent: vi.fn(),
79+
}))
80+
81+
const mockGetOctokitGraphql = vi.hoisted(() => vi.fn())
82+
const mockCacheFetch = vi.hoisted(() => vi.fn())
83+
const mockWriteCache = vi.hoisted(() => vi.fn())
84+
const mockHandleGraphqlError = vi.hoisted(() =>
85+
vi.fn(() => ({ ok: false, message: 'GraphQL error' })),
86+
)
87+
6188
vi.mock('../../../../src/utils/git/github.mts', () => ({
89+
cacheFetch: mockCacheFetch,
6290
getOctokit: mockGetOctokit,
91+
getOctokitGraphql: mockGetOctokitGraphql,
6392
handleGitHubApiError: vi.fn((e: unknown, context: string) => ({
6493
ok: false,
6594
message: 'GitHub API error',
6695
cause: `Error while ${context}: ${e instanceof Error ? e.message : String(e)}`,
6796
})),
68-
handleGraphqlError: vi.fn((_e: unknown, context: string) => ({
69-
ok: false,
70-
message: 'GitHub GraphQL error',
71-
cause: `GraphQL error while ${context}`,
72-
})),
97+
handleGraphqlError: mockHandleGraphqlError,
7398
withGitHubRetry: mockWithGitHubRetry,
99+
writeCache: mockWriteCache,
74100
}))
75101

76102
vi.mock('../../../../src/utils/git/provider-factory.mts', () => ({
@@ -321,4 +347,315 @@ describe('pull-request', () => {
321347
)
322348
})
323349
})
350+
351+
describe('getSocketFixPrs', () => {
352+
beforeEach(() => {
353+
mockGetOctokitGraphql.mockReturnValue(vi.fn())
354+
})
355+
356+
it('returns matching PRs', async () => {
357+
mockCacheFetch.mockResolvedValueOnce({
358+
repository: {
359+
pullRequests: {
360+
pageInfo: { hasNextPage: false, endCursor: null },
361+
nodes: [
362+
{
363+
author: { login: 'testuser' },
364+
baseRefName: 'main',
365+
headRefName: 'socket/fix/GHSA-xxxx-xxxx-xxxx',
366+
mergeStateStatus: 'CLEAN',
367+
number: 1,
368+
state: 'OPEN',
369+
title: 'Fix GHSA-xxxx',
370+
},
371+
{
372+
author: { login: 'otheruser' },
373+
baseRefName: 'main',
374+
headRefName: 'feature-branch',
375+
mergeStateStatus: 'CLEAN',
376+
number: 2,
377+
state: 'OPEN',
378+
title: 'Other PR',
379+
},
380+
],
381+
},
382+
},
383+
})
384+
385+
const result = await getSocketFixPrs('owner', 'repo')
386+
387+
expect(result).toHaveLength(1)
388+
expect(result[0]?.number).toBe(1)
389+
})
390+
391+
it('filters by author', async () => {
392+
mockCacheFetch.mockResolvedValueOnce({
393+
repository: {
394+
pullRequests: {
395+
pageInfo: { hasNextPage: false, endCursor: null },
396+
nodes: [
397+
{
398+
author: { login: 'testuser' },
399+
baseRefName: 'main',
400+
headRefName: 'socket/fix/GHSA-xxxx',
401+
mergeStateStatus: 'CLEAN',
402+
number: 1,
403+
state: 'OPEN',
404+
title: 'Fix GHSA-xxxx',
405+
},
406+
{
407+
author: { login: 'otheruser' },
408+
baseRefName: 'main',
409+
headRefName: 'socket/fix/GHSA-yyyy',
410+
mergeStateStatus: 'CLEAN',
411+
number: 2,
412+
state: 'OPEN',
413+
title: 'Fix GHSA-yyyy',
414+
},
415+
],
416+
},
417+
},
418+
})
419+
420+
const result = await getSocketFixPrs('owner', 'repo', {
421+
author: 'testuser',
422+
})
423+
424+
expect(result).toHaveLength(1)
425+
expect(result[0]?.author).toBe('testuser')
426+
})
427+
428+
it('handles GraphQL errors gracefully', async () => {
429+
mockCacheFetch.mockRejectedValueOnce(new Error('GraphQL error'))
430+
431+
const result = await getSocketFixPrs('owner', 'repo')
432+
433+
expect(result).toEqual([])
434+
expect(mockHandleGraphqlError).toHaveBeenCalled()
435+
})
436+
437+
it('handles empty response', async () => {
438+
mockCacheFetch.mockResolvedValueOnce({
439+
repository: {
440+
pullRequests: {
441+
pageInfo: { hasNextPage: false, endCursor: null },
442+
nodes: [],
443+
},
444+
},
445+
})
446+
447+
const result = await getSocketFixPrs('owner', 'repo')
448+
449+
expect(result).toEqual([])
450+
})
451+
452+
it('handles missing author in node', async () => {
453+
mockCacheFetch.mockResolvedValueOnce({
454+
repository: {
455+
pullRequests: {
456+
pageInfo: { hasNextPage: false, endCursor: null },
457+
nodes: [
458+
{
459+
author: null,
460+
baseRefName: 'main',
461+
headRefName: 'socket/fix/GHSA-xxxx',
462+
mergeStateStatus: 'CLEAN',
463+
number: 1,
464+
state: 'OPEN',
465+
title: 'Fix GHSA-xxxx',
466+
},
467+
],
468+
},
469+
},
470+
})
471+
472+
const result = await getSocketFixPrs('owner', 'repo')
473+
474+
expect(result).toHaveLength(1)
475+
// UNKNOWN_VALUE from @socketsecurity/lib/constants/core.
476+
expect(result[0]?.author).toBe('<unknown>')
477+
})
478+
479+
it('stops pagination early when ghsaId match found', async () => {
480+
mockCacheFetch.mockResolvedValueOnce({
481+
repository: {
482+
pullRequests: {
483+
pageInfo: { hasNextPage: true, endCursor: 'cursor1' },
484+
nodes: [
485+
{
486+
author: { login: 'user1' },
487+
baseRefName: 'main',
488+
headRefName: 'socket/fix/GHSA-xxxx',
489+
mergeStateStatus: 'CLEAN',
490+
number: 1,
491+
state: 'OPEN',
492+
title: 'Fix GHSA-xxxx',
493+
},
494+
],
495+
},
496+
},
497+
})
498+
499+
const result = await getSocketFixPrs('owner', 'repo', {
500+
ghsaId: 'GHSA-xxxx',
501+
})
502+
503+
expect(result).toHaveLength(1)
504+
// Should have only called cacheFetch once due to early exit.
505+
expect(mockCacheFetch).toHaveBeenCalledTimes(1)
506+
})
507+
508+
it('handles null pullRequests response', async () => {
509+
mockCacheFetch.mockResolvedValueOnce({
510+
repository: {
511+
pullRequests: null,
512+
},
513+
})
514+
515+
const result = await getSocketFixPrs('owner', 'repo')
516+
517+
expect(result).toEqual([])
518+
})
519+
})
520+
521+
describe('cleanupSocketFixPrs', () => {
522+
beforeEach(() => {
523+
mockGetOctokitGraphql.mockReturnValue(vi.fn())
524+
mockCreatePrProvider.mockReturnValue(mockProvider)
525+
})
526+
527+
it('returns empty array when no matching PRs', async () => {
528+
mockCacheFetch.mockResolvedValueOnce({
529+
repository: {
530+
pullRequests: {
531+
pageInfo: { hasNextPage: false, endCursor: null },
532+
nodes: [],
533+
},
534+
},
535+
})
536+
537+
const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx')
538+
539+
expect(result).toEqual([])
540+
})
541+
542+
it('updates stale PRs with BEHIND status', async () => {
543+
mockCacheFetch.mockResolvedValueOnce({
544+
repository: {
545+
pullRequests: {
546+
pageInfo: { hasNextPage: false, endCursor: null },
547+
nodes: [
548+
{
549+
author: { login: 'testuser' },
550+
baseRefName: 'main',
551+
headRefName: 'socket/fix/GHSA-xxxx',
552+
mergeStateStatus: 'BEHIND',
553+
number: 1,
554+
state: 'OPEN',
555+
title: 'Fix GHSA-xxxx',
556+
},
557+
],
558+
},
559+
},
560+
})
561+
mockProvider.updatePr.mockResolvedValueOnce({})
562+
mockWriteCache.mockResolvedValueOnce(undefined)
563+
564+
const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx')
565+
566+
expect(result).toHaveLength(1)
567+
expect(mockProvider.updatePr).toHaveBeenCalledWith({
568+
owner: 'owner',
569+
repo: 'repo',
570+
prNumber: 1,
571+
head: 'socket/fix/GHSA-xxxx',
572+
base: 'main',
573+
})
574+
})
575+
576+
it('deletes branches for merged PRs', async () => {
577+
mockCacheFetch.mockResolvedValueOnce({
578+
repository: {
579+
pullRequests: {
580+
pageInfo: { hasNextPage: false, endCursor: null },
581+
nodes: [
582+
{
583+
author: { login: 'testuser' },
584+
baseRefName: 'main',
585+
headRefName: 'socket/fix/GHSA-xxxx',
586+
mergeStateStatus: 'CLEAN',
587+
number: 1,
588+
state: 'MERGED',
589+
title: 'Fix GHSA-xxxx',
590+
},
591+
],
592+
},
593+
},
594+
})
595+
mockProvider.deleteBranch.mockResolvedValueOnce(true)
596+
597+
const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx')
598+
599+
expect(result).toHaveLength(1)
600+
expect(mockProvider.deleteBranch).toHaveBeenCalledWith(
601+
'socket/fix/GHSA-xxxx',
602+
)
603+
})
604+
605+
it('handles delete branch failure gracefully', async () => {
606+
mockCacheFetch.mockResolvedValueOnce({
607+
repository: {
608+
pullRequests: {
609+
pageInfo: { hasNextPage: false, endCursor: null },
610+
nodes: [
611+
{
612+
author: { login: 'testuser' },
613+
baseRefName: 'main',
614+
headRefName: 'socket/fix/GHSA-xxxx',
615+
mergeStateStatus: 'CLEAN',
616+
number: 1,
617+
state: 'MERGED',
618+
title: 'Fix GHSA-xxxx',
619+
},
620+
],
621+
},
622+
},
623+
})
624+
mockProvider.deleteBranch.mockRejectedValueOnce(
625+
new Error('Branch not found'),
626+
)
627+
628+
const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx')
629+
630+
// Should still return the match even if branch deletion fails.
631+
expect(result).toHaveLength(1)
632+
})
633+
634+
it('handles update PR failure gracefully', async () => {
635+
mockCacheFetch.mockResolvedValueOnce({
636+
repository: {
637+
pullRequests: {
638+
pageInfo: { hasNextPage: false, endCursor: null },
639+
nodes: [
640+
{
641+
author: { login: 'testuser' },
642+
baseRefName: 'main',
643+
headRefName: 'socket/fix/GHSA-xxxx',
644+
mergeStateStatus: 'BEHIND',
645+
number: 1,
646+
state: 'OPEN',
647+
title: 'Fix GHSA-xxxx',
648+
},
649+
],
650+
},
651+
},
652+
})
653+
mockProvider.updatePr.mockRejectedValueOnce(new Error('Update failed'))
654+
655+
const result = await cleanupSocketFixPrs('owner', 'repo', 'GHSA-xxxx')
656+
657+
// Should still return the match even if update fails.
658+
expect(result).toHaveLength(1)
659+
})
660+
})
324661
})

0 commit comments

Comments
 (0)