Skip to content

Commit d7ef56d

Browse files
test(dynamic-sampling): Add tests for ProjectSampling
Cover initial render, edit/reset flow, validation on submit, API payload, post-save state, error handling, and access control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0865dc6 commit d7ef56d

File tree

1 file changed

+190
-0
lines changed

1 file changed

+190
-0
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
import {ProjectFixture} from 'sentry-fixture/project';
3+
4+
import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
5+
6+
import {ProjectsStore} from 'sentry/stores/projectsStore';
7+
8+
import {ProjectSampling} from './projectSampling';
9+
10+
jest.mock('@tanstack/react-virtual', () => ({
11+
useVirtualizer: jest.fn(({count}: {count: number}) => ({
12+
getVirtualItems: jest.fn(() =>
13+
Array.from({length: count}, (_, index) => ({
14+
key: index,
15+
index,
16+
start: index * 63,
17+
size: 63,
18+
}))
19+
),
20+
getTotalSize: jest.fn(() => count * 63),
21+
measure: jest.fn(),
22+
})),
23+
}));
24+
25+
describe('ProjectSampling', () => {
26+
const project = ProjectFixture({id: '1', slug: 'project-slug'});
27+
const organization = OrganizationFixture({
28+
slug: 'org-slug',
29+
access: ['org:write'],
30+
samplingMode: 'project',
31+
});
32+
33+
beforeEach(() => {
34+
MockApiClient.clearMockResponses();
35+
act(() => ProjectsStore.loadInitialData([project]));
36+
37+
MockApiClient.addMockResponse({
38+
url: '/organizations/org-slug/sampling/project-root-counts/',
39+
body: {
40+
data: [
41+
[
42+
{
43+
by: {project: 'project-slug', target_project_id: '1'},
44+
totals: 1000,
45+
series: [],
46+
},
47+
],
48+
],
49+
end: '',
50+
intervals: [],
51+
start: '',
52+
},
53+
});
54+
55+
MockApiClient.addMockResponse({
56+
url: '/organizations/org-slug/sampling/project-rates/',
57+
body: [{id: 1, sampleRate: 0.5}],
58+
});
59+
});
60+
61+
function getProjectRateInput() {
62+
// The first spinbutton is the org rate, the second is the project rate
63+
const inputs = screen.getAllByRole('spinbutton');
64+
return inputs[inputs.length - 1]!;
65+
}
66+
67+
async function waitForProjectRateInput() {
68+
// Wait for the project table to render by finding the project link
69+
await screen.findByRole('link', {name: 'View Project Details'});
70+
return getProjectRateInput();
71+
}
72+
73+
it('renders project rate inputs with initial values', async () => {
74+
// The input briefly transitions from uncontrolled to controlled as form
75+
// state initializes with the fetched project rates.
76+
jest.spyOn(console, 'error').mockImplementation();
77+
78+
render(<ProjectSampling />, {organization});
79+
80+
const input = await waitForProjectRateInput();
81+
expect(input).toHaveValue(50);
82+
});
83+
84+
it('enables Reset button after changing a project rate', async () => {
85+
render(<ProjectSampling />, {organization});
86+
87+
const input = await waitForProjectRateInput();
88+
expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled();
89+
90+
await userEvent.clear(input);
91+
await userEvent.type(input, '30');
92+
93+
expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled();
94+
});
95+
96+
it('resets the input back to the saved value when Reset is clicked', async () => {
97+
render(<ProjectSampling />, {organization});
98+
99+
const input = await waitForProjectRateInput();
100+
await userEvent.clear(input);
101+
await userEvent.type(input, '30');
102+
103+
await userEvent.click(screen.getByRole('button', {name: 'Reset'}));
104+
105+
expect(input).toHaveValue(50);
106+
});
107+
108+
it('shows validation error for empty value on submit', async () => {
109+
render(<ProjectSampling />, {organization});
110+
111+
const input = await waitForProjectRateInput();
112+
await userEvent.clear(input);
113+
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));
114+
115+
expect(await screen.findByText('Please enter a valid number')).toBeInTheDocument();
116+
});
117+
118+
it('calls the API with the correct payload on save', async () => {
119+
const putMock = MockApiClient.addMockResponse({
120+
url: '/organizations/org-slug/sampling/project-rates/',
121+
method: 'PUT',
122+
body: [{id: 1, sampleRate: 0.3}],
123+
});
124+
125+
render(<ProjectSampling />, {organization});
126+
127+
const input = await waitForProjectRateInput();
128+
await userEvent.clear(input);
129+
await userEvent.type(input, '30');
130+
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));
131+
132+
await waitFor(() => {
133+
expect(putMock).toHaveBeenCalledWith(
134+
'/organizations/org-slug/sampling/project-rates/',
135+
expect.objectContaining({data: [{id: 1, sampleRate: 0.3}]})
136+
);
137+
});
138+
});
139+
140+
it('resets form to clean state after a successful save', async () => {
141+
MockApiClient.addMockResponse({
142+
url: '/organizations/org-slug/sampling/project-rates/',
143+
method: 'PUT',
144+
body: [{id: 1, sampleRate: 0.3}],
145+
});
146+
147+
render(<ProjectSampling />, {organization});
148+
149+
const input = await waitForProjectRateInput();
150+
await userEvent.clear(input);
151+
await userEvent.type(input, '30');
152+
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));
153+
154+
await waitFor(() =>
155+
expect(screen.getByRole('button', {name: 'Reset'})).toBeDisabled()
156+
);
157+
});
158+
159+
it('keeps form dirty after an API error', async () => {
160+
MockApiClient.addMockResponse({
161+
url: '/organizations/org-slug/sampling/project-rates/',
162+
method: 'PUT',
163+
statusCode: 500,
164+
body: {detail: 'Internal Server Error'},
165+
});
166+
167+
render(<ProjectSampling />, {organization});
168+
169+
const input = await waitForProjectRateInput();
170+
await userEvent.clear(input);
171+
await userEvent.type(input, '30');
172+
await userEvent.click(screen.getByRole('button', {name: 'Apply Changes'}));
173+
174+
await waitFor(() =>
175+
expect(screen.getByRole('button', {name: 'Reset'})).toBeEnabled()
176+
);
177+
});
178+
179+
it('disables Apply Changes for users without org:write access', async () => {
180+
const orgWithoutAccess = OrganizationFixture({
181+
access: [],
182+
samplingMode: 'project',
183+
});
184+
185+
render(<ProjectSampling />, {organization: orgWithoutAccess});
186+
187+
await waitForProjectRateInput();
188+
expect(screen.getByRole('button', {name: 'Apply Changes'})).toBeDisabled();
189+
});
190+
});

0 commit comments

Comments
 (0)