import {ProjectFixture} from 'getsentry-test/fixtures/project';
import {
mockRootAllocations,
mockSpendAllocations,
} from 'getsentry-test/fixtures/spendAllocation';
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {initializeOrg} from 'sentry-test/initializeOrg';
import {
act,
cleanup,
render,
renderGlobalModal,
screen,
userEvent,
waitFor,
within,
} from 'sentry-test/reactTestingLibrary';
import selectEvent from 'sentry-test/selectEvent';
import ProjectsStore from 'sentry/stores/projectsStore';
import SubscriptionStore from 'getsentry/stores/subscriptionStore';
import {SpendAllocationsRoot} from 'getsentry/views/spendAllocations/index';
describe('SpendAllocations feature enable flow', () => {
let organization: any, subscription: any, mockGet: any, dateTs: number;
beforeEach(() => {
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
mockGet = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: [],
status: 403,
statusCode: 403,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
});
afterEach(() => {
cleanup();
});
it('renders enable button for owners/billing in an org that has not enabled spend allocations', async () => {
organization.access = [
'org:read',
'org:write',
'org:admin',
'org:billing',
'project:read',
'project:admin',
];
render(
);
await waitFor(() =>
screen.findByRole('button', {
name: 'Get started',
})
);
const enableSpendAllocations = screen.getByRole('button', {
name: 'Get started',
});
expect(enableSpendAllocations).toBeInTheDocument();
expect(enableSpendAllocations).toBeEnabled();
});
it('does not render for YY partnership', async function () {
subscription = SubscriptionFixture({
plan: 'am2_business',
planTier: 'am2',
partner: {
externalId: 'x123x',
name: 'YY Org',
partnership: {
id: 'YY',
displayName: 'YY',
supportNote: 'foo',
},
isActive: true,
},
organization,
});
SubscriptionStore.set(organization.slug, subscription);
render(
);
expect(await screen.findByTestId('partnership-note')).toBeInTheDocument();
expect(screen.queryByRole('button', {name: 'Get Started'})).not.toBeInTheDocument();
});
it('does not render enable button for non billing role for org that has not enabled spend allocations', async () => {
organization.access = ['project:read', 'project:admin'];
render(
);
// Waiting a tick for requests to finish
await act(tick);
expect(screen.queryByRole('button', {name: 'Get Started'})).not.toBeInTheDocument();
});
it('re-fetches org and project spend-allocations on enable click', async () => {
organization.access = [
'org:read',
'org:write',
'org:admin',
'org:billing',
'project:read',
'project:admin',
];
render(
);
const enableSpendAllocations = await screen.findByRole('button', {
name: 'Get started',
});
expect(mockGet).toHaveBeenCalledTimes(1);
MockApiClient.clearMockResponses();
const toggleRequestMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/toggle/`,
method: 'POST',
});
const createAllocationsRequestMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/index/`,
method: 'POST',
});
const mockGet_success = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: [],
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
await userEvent.click(enableSpendAllocations);
expect(toggleRequestMock).toHaveBeenCalled();
expect(createAllocationsRequestMock).toHaveBeenCalled();
expect(mockGet_success).toHaveBeenCalledTimes(2);
});
});
describe('enabled Spend Allocations page', () => {
let organization: any, subscription: any, dateTs: any;
beforeEach(() => {
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
organization.access = [
'org:read',
'org:write',
'org:admin',
'org:billing',
'project:read',
'project:admin',
];
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockSpendAllocations,
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
});
it('Does not render with insufficient access', async () => {
organization.access = ['org:read'];
render(
);
// Waiting a tick for requests to finish
await act(tick);
expect(screen.queryByTestId('spend-allocation-form')).not.toBeInTheDocument();
expect(screen.queryByTestId('subhead-actions')).not.toBeInTheDocument();
});
it('renders allocations table', async () => {
render(
);
await waitFor(() => screen.findByTestId('allocations-table'));
});
it('renders billing metric select dropdown', async () => {
render(
);
expect(
await screen.findByRole('button', {name: 'Category Errors'})
).toBeInTheDocument();
});
it('properly filters allocations by select dropdown', async () => {
render(
);
const dropdown = await screen.findByRole('button', {name: 'Category Errors'});
await selectEvent.select(dropdown, 'Transactions');
expect(
await screen.findByRole('button', {name: 'Category Transactions'})
).toBeInTheDocument();
await screen.findByText('Un-Allocated Transactions Pool');
await screen.findAllByTestId('allocation-row');
const tableRows = screen.getAllByTestId('allocation-row');
expect(tableRows).toHaveLength(1); // org allocations are no longer included in table rows
await selectEvent.select(dropdown, 'Attachments');
expect(
await screen.findByRole('button', {name: 'Category Attachments'})
).toBeInTheDocument();
await screen.findByText('Un-Allocated Attachments Pool');
expect(screen.getByTestId('no-allocations')).toBeInTheDocument();
await selectEvent.openMenu(dropdown);
// assert dropdown options are properly rendered
// This is hacky. the CompactSelect component sets the option value as the test-id
expect(screen.getByTestId('errors')).toBeInTheDocument();
expect(screen.getByTestId('transactions')).toBeInTheDocument();
expect(screen.getByTestId('attachments')).toBeInTheDocument();
});
it('only renders allocation-supported categories that are on the subscription', async () => {
const am3Sub = SubscriptionFixture({
organization,
plan: 'am3_f',
planTier: 'am3',
});
render();
const dropdown = await screen.findByRole('button', {name: 'Category Errors'});
await selectEvent.openMenu(dropdown);
expect(screen.getByTestId('errors')).toBeInTheDocument();
expect(screen.queryByTestId('transactions')).not.toBeInTheDocument();
expect(screen.getByTestId('attachments')).toBeInTheDocument();
});
// NOTE: Period navigation has been removed for now
// eslint-disable-next-line jest/no-disabled-tests
it.skip('refetches allocations on view period change', async () => {
render(
);
await screen.findAllByTestId('allocation-row');
const tableRows = screen.getAllByTestId('allocation-row');
expect(tableRows).toHaveLength(2); // default metric is error with 2 project mocks
const date = new Date(dateTs * 1000);
date.setMonth(date.getMonth() + 1);
const start = new Date(subscription.onDemandPeriodEnd + 'T00:00:00.000');
start.setDate(start.getDate() + 1);
const nextTs = Math.max(date.getTime() / 1000, new Date(start).getTime() / 1000);
const mockGet_success = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockRootAllocations,
status: 200,
statusCode: 200,
match: [
MockApiClient.matchQuery({
timestamp: nextTs,
}),
],
});
await userEvent.click(screen.getByTestId('nextPeriod'));
expect(await screen.findByTestId('no-allocations')).toBeInTheDocument();
expect(mockGet_success).toHaveBeenCalledTimes(1);
});
it('deletes allocations on disable', async () => {
render(
);
await waitFor(() => screen.findByTestId('allocations-table'));
expect(
screen.queryByRole('button', {
name: 'Create Organization-Level Allocation',
})
).not.toBeInTheDocument();
MockApiClient.clearMockResponses();
const mockDelete = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/index/`,
method: 'DELETE',
});
const mockGet = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: [],
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
await userEvent.click(
screen.getByRole('button', {
name: 'Disable Spend Allocations',
})
);
renderGlobalModal();
await userEvent.click(
screen.getByRole('button', {
name: 'Confirm',
})
);
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockGet).toHaveBeenCalledTimes(2);
});
});
describe('enabled Spend Allocations page without root', () => {
let organization: any, subscription: any, dateTs: any, mockGet: any;
beforeEach(() => {
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
organization.access = [
'org:read',
'org:write',
'org:admin',
'org:billing',
'project:read',
'project:admin',
];
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
mockGet = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: [],
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
});
it('renders missing root card', async () => {
render(
);
expect(mockGet).toHaveBeenCalledTimes(1);
await screen.findByTestId('missing-root');
});
it('creates root allocation for billing metric', async () => {
render(
);
await screen.findByRole('button', {
name: 'Create Organization-Level Allocation',
});
expect(mockGet).toHaveBeenCalledTimes(2);
const enableSpendAllocation = screen.getByRole('button', {
name: 'Create Organization-Level Allocation',
});
MockApiClient.clearMockResponses();
const requestMock = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'POST',
});
const mockGet_success = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockRootAllocations,
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
await userEvent.click(enableSpendAllocation);
expect(requestMock).toHaveBeenCalled();
expect(mockGet_success).toHaveBeenCalledTimes(2);
});
});
describe('POST Create spend allocation', () => {
let organization: any, subscription: any, projects: any, mockPost: any, dateTs: number;
beforeEach(() => {
projects = [
ProjectFixture({
id: String(mockSpendAllocations[3]!.targetId),
slug: mockSpendAllocations[3]!.targetSlug,
}),
ProjectFixture({
id: String(mockSpendAllocations[4]!.targetId),
slug: mockSpendAllocations[4]!.targetSlug,
}),
ProjectFixture({
// transaction allocation
id: String(mockSpendAllocations[5]!.targetId),
slug: mockSpendAllocations[5]!.targetSlug,
}),
];
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockSpendAllocations,
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
mockPost = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'POST',
status: 200,
statusCode: 200,
});
ProjectsStore.loadInitialData(projects);
});
it('opens and closes form', async () => {
render(
);
expect(
await screen.findByRole('button', {name: 'New Allocation'})
).toBeInTheDocument();
await userEvent.click(screen.getByText('New Allocation'));
const {waitForModalToHide} = renderGlobalModal();
expect(await screen.findByRole('button', {name: 'Cancel'})).toBeInTheDocument();
expect(screen.getByTestId('spend-allocation-form')).toBeInTheDocument();
await userEvent.click(screen.getByText('Cancel'));
await waitForModalToHide();
expect(
await screen.findByRole('button', {name: 'New Allocation'})
).toBeInTheDocument();
expect(screen.queryByTestId('spend-allocation-form')).not.toBeInTheDocument();
});
it('filters target project list', async () => {
// TODO: figure out how to write tests for SelectField component.
});
it('prevents submit on incomplete form', async () => {
render(
);
expect(
await screen.findByRole('button', {name: 'New Allocation'})
).toBeInTheDocument();
await userEvent.click(screen.getByText('New Allocation'));
renderGlobalModal();
await userEvent.click(screen.getByText('Submit'));
expect(mockPost.mock.calls).toHaveLength(0);
});
});
describe('Disable Submit button in Spend Allocation', () => {
let organization: any, subscription: any, projects: any, dateTs: number;
beforeEach(() => {
projects = [
ProjectFixture({
id: String(mockSpendAllocations[3]!.targetId),
slug: mockSpendAllocations[3]!.targetSlug,
}),
ProjectFixture({
id: String(mockSpendAllocations[4]!.targetId),
slug: mockSpendAllocations[4]!.targetSlug,
}),
ProjectFixture({
// transaction allocation
id: String(mockSpendAllocations[5]!.targetId),
slug: mockSpendAllocations[5]!.targetSlug,
}),
];
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: [],
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
ProjectsStore.loadInitialData(projects);
});
it('prevents submit with no root allocations', async () => {
render(
);
expect(
await screen.findByRole('button', {name: 'New Allocation'})
).toBeInTheDocument();
await userEvent.click(screen.getByText('New Allocation'));
renderGlobalModal();
await userEvent.click(screen.getByText('Select a project to continue'));
expect(screen.getByText(projects[0].slug)).toBeInTheDocument();
await userEvent.click(screen.getByText(projects[0].slug));
expect(screen.getByTestId('spend-allocation-submit')).toBeDisabled();
});
});
describe('DELETE spend allocation', () => {
let organization: any, subscription: any, mockDelete: any, mockGet: any, dateTs: number;
beforeEach(() => {
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
mockGet = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockSpendAllocations,
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
mockDelete = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'DELETE',
status: 200,
statusCode: 200,
match: [
MockApiClient.matchQuery({
billing_metric: 'error',
target_id: 1,
target_type: 'Project',
}),
],
});
});
it('renders delete button for project allocations', async () => {
render(
);
await screen.findAllByTestId('allocation-row');
const tableRows = screen.getAllByTestId('allocation-row');
expect(tableRows).toHaveLength(2);
expect(within(tableRows[0]!).getByTestId('delete')).toBeInTheDocument();
expect(within(tableRows[1]!).getByTestId('delete')).toBeInTheDocument();
});
it('fires delete request on click', async () => {
render(
);
expect(mockGet.mock.calls).toHaveLength(1);
await screen.findAllByTestId('allocation-row');
const tableRows = screen.getAllByTestId('allocation-row');
await userEvent.click(within(tableRows[0]!).getByTestId('delete'));
expect(mockDelete.mock.calls).toHaveLength(1);
// Assert that it refetches allocations on success
expect(mockGet.mock.calls).toHaveLength(4);
});
});
describe('PUT edit spend allocation', () => {
let organization: any, subscription: any, projects: any, mockPut: any, dateTs: number;
beforeEach(() => {
projects = [
ProjectFixture({
id: String(mockSpendAllocations[2]!.targetId),
slug: mockSpendAllocations[2]!.targetSlug,
}),
ProjectFixture({
id: String(mockSpendAllocations[3]!.targetId),
slug: mockSpendAllocations[3]!.targetSlug,
}),
ProjectFixture({
// transaction allocation
id: String(mockSpendAllocations[4]!.targetId),
slug: mockSpendAllocations[4]!.targetSlug,
}),
];
organization = initializeOrg({
organization: {
features: ['spend-allocations'],
},
}).organization;
subscription = SubscriptionFixture({
organization,
plan: 'am1_f',
planTier: 'am1',
});
MockApiClient.clearMockResponses();
dateTs = Math.max(
new Date().getTime() / 1000,
new Date(subscription.onDemandPeriodStart + 'T00:00:00.000').getTime() / 1000
);
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'GET',
body: mockSpendAllocations,
status: 200,
statusCode: 200,
match: [MockApiClient.matchQuery({timestamp: dateTs})],
});
mockPut = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/spend-allocations/`,
method: 'PUT',
status: 200,
statusCode: 200,
});
ProjectsStore.loadInitialData(projects);
});
it('opens, initializes form on edit, and submits PUT', async () => {
render(
);
await screen.findAllByTestId('allocation-row');
const tableRows = screen.getAllByTestId('allocation-row');
expect(tableRows).toHaveLength(2);
expect(within(tableRows[0]!).getByTestId('edit')).toBeInTheDocument();
// Should be editing the first 'error' allocation (mockSpendAllocations[2])
await userEvent.click(within(tableRows[0]!).getByTestId('edit'));
renderGlobalModal();
expect(await screen.findByRole('button', {name: 'Cancel'})).toBeInTheDocument();
expect(screen.getByTestId('spend-allocation-form')).toBeInTheDocument();
// Mock currently only includes a single project NOT allocated for errors
expect(screen.queryAllByTestId('badge-display-name')).toHaveLength(1);
expect(screen.getByTestId('allocation-input')).toHaveValue(
mockSpendAllocations[2]!.reservedQuantity
);
expect(screen.getByTestId('toggle-spend')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('toggle-spend'));
expect(screen.getByTestId('allocation-input')).toHaveValue(
(mockSpendAllocations[2]!.reservedQuantity! *
mockSpendAllocations[2]!.costPerItem!) /
100
);
await userEvent.click(screen.getByText('Save Changes'));
expect(mockPut.mock.calls).toHaveLength(1);
});
});