Skip to content

Commit fa53d66

Browse files
committed
test(onboarding): Add tests for useScmRepoSearch and ScmRepoSelector
These components had zero test coverage. The hook tests verify debounced search, API param passing, response transformation, selected repo disabling, error states, and search clearing. The component tests verify rendering, empty state messaging, error display, search results, and selected repo context. Refs VDY-54
1 parent 78f6d10 commit fa53d66

File tree

2 files changed

+308
-0
lines changed

2 files changed

+308
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations';
3+
import {RepositoryFixture} from 'sentry-fixture/repository';
4+
5+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
6+
7+
import {
8+
OnboardingContextProvider,
9+
type OnboardingSessionState,
10+
} from 'sentry/components/onboarding/onboardingContext';
11+
12+
import {ScmRepoSelector} from './scmRepoSelector';
13+
14+
function makeOnboardingWrapper(initialState?: OnboardingSessionState) {
15+
return function OnboardingWrapper({children}: {children?: React.ReactNode}) {
16+
return (
17+
<OnboardingContextProvider initialValue={initialState}>
18+
{children}
19+
</OnboardingContextProvider>
20+
);
21+
};
22+
}
23+
24+
describe('ScmRepoSelector', () => {
25+
const organization = OrganizationFixture();
26+
27+
const mockIntegration = OrganizationIntegrationsFixture({
28+
id: '1',
29+
name: 'getsentry',
30+
domainName: 'github.com/getsentry',
31+
provider: {
32+
key: 'github',
33+
slug: 'github',
34+
name: 'GitHub',
35+
canAdd: true,
36+
canDisable: false,
37+
features: ['commits'],
38+
aspects: {},
39+
},
40+
});
41+
42+
afterEach(() => {
43+
MockApiClient.clearMockResponses();
44+
});
45+
46+
it('renders search placeholder', () => {
47+
render(<ScmRepoSelector integration={mockIntegration} />, {
48+
organization,
49+
wrapper: makeOnboardingWrapper(),
50+
});
51+
52+
expect(screen.getByText('Search repositories')).toBeInTheDocument();
53+
});
54+
55+
it('shows empty state message when search returns no results', async () => {
56+
MockApiClient.addMockResponse({
57+
url: `/organizations/${organization.slug}/integrations/1/repos/`,
58+
body: {repos: []},
59+
});
60+
61+
render(<ScmRepoSelector integration={mockIntegration} />, {
62+
organization,
63+
wrapper: makeOnboardingWrapper(),
64+
});
65+
66+
await userEvent.type(screen.getByRole('textbox'), 'nonexistent');
67+
68+
expect(await screen.findByText('No repositories found.')).toBeInTheDocument();
69+
});
70+
71+
it('shows error message on API failure', async () => {
72+
MockApiClient.addMockResponse({
73+
url: `/organizations/${organization.slug}/integrations/1/repos/`,
74+
statusCode: 500,
75+
body: {detail: 'Internal Error'},
76+
});
77+
78+
render(<ScmRepoSelector integration={mockIntegration} />, {
79+
organization,
80+
wrapper: makeOnboardingWrapper(),
81+
});
82+
83+
await userEvent.type(screen.getByRole('textbox'), 'sentry');
84+
85+
expect(
86+
await screen.findByText('Failed to search repositories. Please try again.')
87+
).toBeInTheDocument();
88+
});
89+
90+
it('displays repos returned by search', async () => {
91+
MockApiClient.addMockResponse({
92+
url: `/organizations/${organization.slug}/integrations/1/repos/`,
93+
body: {
94+
repos: [
95+
{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false},
96+
{identifier: 'getsentry/relay', name: 'relay', isInstalled: false},
97+
],
98+
},
99+
});
100+
101+
render(<ScmRepoSelector integration={mockIntegration} />, {
102+
organization,
103+
wrapper: makeOnboardingWrapper(),
104+
});
105+
106+
await userEvent.type(screen.getByRole('textbox'), 'get');
107+
108+
expect(await screen.findByText('sentry')).toBeInTheDocument();
109+
expect(screen.getByText('relay')).toBeInTheDocument();
110+
});
111+
112+
it('shows selected repo value when one is in context', () => {
113+
const selectedRepo = RepositoryFixture({
114+
name: 'getsentry/old-repo',
115+
externalSlug: 'getsentry/old-repo',
116+
});
117+
118+
render(<ScmRepoSelector integration={mockIntegration} />, {
119+
organization,
120+
wrapper: makeOnboardingWrapper({selectedRepository: selectedRepo}),
121+
});
122+
123+
expect(screen.getByText('getsentry/old-repo')).toBeInTheDocument();
124+
});
125+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {RepositoryFixture} from 'sentry-fixture/repository';
3+
4+
import {act, renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';
5+
6+
import {useScmRepoSearch} from './useScmRepoSearch';
7+
8+
describe('useScmRepoSearch', () => {
9+
const organization = OrganizationFixture();
10+
const integrationId = '1';
11+
12+
beforeEach(() => {
13+
jest.useFakeTimers();
14+
});
15+
16+
afterEach(() => {
17+
MockApiClient.clearMockResponses();
18+
jest.useRealTimers();
19+
});
20+
21+
function renderHook(selectedRepo?: Parameters<typeof useScmRepoSearch>[1]) {
22+
return renderHookWithProviders(() => useScmRepoSearch(integrationId, selectedRepo), {
23+
organization,
24+
});
25+
}
26+
27+
it('does not fetch when search is empty', () => {
28+
const request = MockApiClient.addMockResponse({
29+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
30+
body: {repos: []},
31+
});
32+
33+
renderHook();
34+
35+
expect(request).not.toHaveBeenCalled();
36+
});
37+
38+
it('fetches repos after search is set', async () => {
39+
const request = MockApiClient.addMockResponse({
40+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
41+
body: {repos: []},
42+
});
43+
44+
const {result} = renderHook();
45+
46+
act(() => {
47+
result.current.setSearch('sentry');
48+
jest.advanceTimersByTime(200);
49+
});
50+
51+
await waitFor(() => expect(request).toHaveBeenCalled());
52+
});
53+
54+
it('passes search query param to the API', async () => {
55+
const request = MockApiClient.addMockResponse({
56+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
57+
body: {repos: []},
58+
});
59+
60+
const {result} = renderHook();
61+
62+
act(() => {
63+
result.current.setSearch('sentry');
64+
jest.advanceTimersByTime(200);
65+
});
66+
67+
await waitFor(() => expect(request).toHaveBeenCalled());
68+
69+
expect(request).toHaveBeenCalledWith(
70+
expect.anything(),
71+
expect.objectContaining({
72+
query: expect.objectContaining({search: 'sentry'}),
73+
})
74+
);
75+
});
76+
77+
it('transforms API response into reposByIdentifier and dropdownItems', async () => {
78+
MockApiClient.addMockResponse({
79+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
80+
body: {
81+
repos: [
82+
{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false},
83+
{identifier: 'getsentry/relay', name: 'relay', isInstalled: true},
84+
],
85+
},
86+
});
87+
88+
const {result} = renderHook();
89+
90+
act(() => {
91+
result.current.setSearch('get');
92+
jest.advanceTimersByTime(200);
93+
});
94+
95+
await waitFor(() => expect(result.current.reposByIdentifier.size).toBe(2));
96+
97+
expect(result.current.reposByIdentifier.get('getsentry/sentry')).toEqual({
98+
identifier: 'getsentry/sentry',
99+
name: 'sentry',
100+
isInstalled: false,
101+
});
102+
103+
expect(result.current.dropdownItems).toEqual([
104+
{value: 'getsentry/sentry', label: 'sentry', disabled: false},
105+
{value: 'getsentry/relay', label: 'relay', disabled: false},
106+
]);
107+
});
108+
109+
it('marks selected repo as disabled in dropdownItems', async () => {
110+
MockApiClient.addMockResponse({
111+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
112+
body: {
113+
repos: [
114+
{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false},
115+
{identifier: 'getsentry/relay', name: 'relay', isInstalled: false},
116+
],
117+
},
118+
});
119+
120+
const selectedRepo = RepositoryFixture({externalSlug: 'getsentry/sentry'});
121+
const {result} = renderHook(selectedRepo);
122+
123+
act(() => {
124+
result.current.setSearch('get');
125+
jest.advanceTimersByTime(200);
126+
});
127+
128+
await waitFor(() => expect(result.current.dropdownItems).toHaveLength(2));
129+
130+
expect(result.current.dropdownItems[0]).toEqual(
131+
expect.objectContaining({value: 'getsentry/sentry', disabled: true})
132+
);
133+
expect(result.current.dropdownItems[1]).toEqual(
134+
expect.objectContaining({value: 'getsentry/relay', disabled: false})
135+
);
136+
});
137+
138+
it('returns isError true on API failure', async () => {
139+
MockApiClient.addMockResponse({
140+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
141+
statusCode: 500,
142+
body: {detail: 'Internal Error'},
143+
});
144+
145+
const {result} = renderHook();
146+
147+
act(() => {
148+
result.current.setSearch('sentry');
149+
jest.advanceTimersByTime(200);
150+
});
151+
152+
await waitFor(() => expect(result.current.isError).toBe(true));
153+
});
154+
155+
it('returns empty results when search is cleared after searching', async () => {
156+
MockApiClient.addMockResponse({
157+
url: `/organizations/${organization.slug}/integrations/${integrationId}/repos/`,
158+
body: {
159+
repos: [{identifier: 'getsentry/sentry', name: 'sentry', isInstalled: false}],
160+
},
161+
});
162+
163+
const {result} = renderHook();
164+
165+
// Search and get results
166+
act(() => {
167+
result.current.setSearch('sentry');
168+
jest.advanceTimersByTime(200);
169+
});
170+
171+
await waitFor(() => expect(result.current.reposByIdentifier.size).toBe(1));
172+
173+
// Clear search
174+
act(() => {
175+
result.current.setSearch('');
176+
jest.advanceTimersByTime(200);
177+
});
178+
179+
// placeholderData returns undefined when debouncedSearch is empty
180+
await waitFor(() => expect(result.current.reposByIdentifier.size).toBe(0));
181+
expect(result.current.dropdownItems).toEqual([]);
182+
});
183+
});

0 commit comments

Comments
 (0)