Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions static/app/utils/createStorage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {createStorage} from 'sentry/utils/createStorage';

describe('createStorage', () => {
it('returns noopStorage when underlying storage is null', () => {
const storage = createStorage(() => null as unknown as Storage);

expect(storage.getItem('any-key')).toBeNull();
expect(() => storage.setItem('key', 'value')).not.toThrow();
expect(() => storage.removeItem('key')).not.toThrow();
expect(() => storage.clear()).not.toThrow();
expect(storage).toHaveLength(0);
expect(storage.key(0)).toBeNull();
});

it('returns noopStorage when storage.setItem throws', () => {
const brokenStorage = {
...window.localStorage,
setItem() {
throw new DOMException('The quota has been exceeded.');
},
} as Storage;

const storage = createStorage(() => brokenStorage);

expect(storage.getItem('any-key')).toBeNull();
expect(() => storage.setItem('key', 'value')).not.toThrow();
expect(storage).toHaveLength(0);
});

it('returns real storage when it works correctly', () => {
const storage = createStorage(() => window.localStorage);

storage.setItem('test-key', 'test-value');
expect(storage.getItem('test-key')).toBe('test-value');
storage.removeItem('test-key');
expect(storage.getItem('test-key')).toBeNull();
});
});
65 changes: 65 additions & 0 deletions static/gsApp/components/navBillingStatus.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils';

import {DataCategory} from 'sentry/types/core';

jest.mock('sentry/utils/localStorage', () => {
const actual = jest.requireActual<typeof import('sentry/utils/localStorage')>(
'sentry/utils/localStorage'
);
const wrapper = actual.localStorageWrapper;
return {
...actual,
localStorageWrapper: {
getItem: jest.fn((...args: Parameters<Storage['getItem']>) =>
wrapper.getItem(...args)
),
setItem: jest.fn((...args: Parameters<Storage['setItem']>) =>
wrapper.setItem(...args)
),
removeItem: jest.fn((...args: Parameters<Storage['removeItem']>) =>
wrapper.removeItem(...args)
),
clear: jest.fn(() => wrapper.clear()),
key: jest.fn((index: number) => wrapper.key(index)),
length: wrapper.length,
},
};
});

const {localStorageWrapper} = jest.requireMock<
typeof import('sentry/utils/localStorage')
>('sentry/utils/localStorage');

import {PrimaryNavigationQuotaExceeded} from 'getsentry/components/navBillingStatus';
import {SubscriptionStore} from 'getsentry/stores/subscriptionStore';
import {OnDemandBudgetMode} from 'getsentry/types';
Expand Down Expand Up @@ -657,4 +685,41 @@ describe('PrimaryNavigationQuotaExceeded', () => {
).toBeInTheDocument();
});
});

describe('localStorage unavailable', () => {
beforeEach(() => {
jest.mocked(localStorageWrapper.getItem).mockReturnValue(null);
jest.mocked(localStorageWrapper.setItem).mockImplementation(() => undefined);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should render and auto-open without crashing when localStorage is unavailable', async () => {
render(<PrimaryNavigationQuotaExceeded organization={organization} />);

expect(
await screen.findByRole('button', {name: 'Billing Status'})
).toBeInTheDocument();
// Auto-opens because currentCategories !== lastShownCategories (null)
expect(await screen.findByText('Quotas Exceeded')).toBeInTheDocument();
// setItem is a no-op but should not throw
expect(localStorageWrapper.setItem).toHaveBeenCalled();
});

it('should allow dismiss interaction when localStorage is unavailable', async () => {
render(<PrimaryNavigationQuotaExceeded organization={organization} />);

expect(await screen.findByText('Quotas Exceeded')).toBeInTheDocument();

await userEvent.click(
screen.getByRole('button', {
name: 'Dismiss alert for the rest of the billing cycle',
})
);

expect(promptMock).toHaveBeenCalledTimes(4);
});
});
});
9 changes: 5 additions & 4 deletions static/gsApp/components/navBillingStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {t, tct} from 'sentry/locale';
import {DataCategory} from 'sentry/types/core';
import type {Organization} from 'sentry/types/organization';
import {getDaysSinceDate} from 'sentry/utils/getDaysSinceDate';
import {localStorageWrapper} from 'sentry/utils/localStorage';
import {
PrimaryNavigation,
usePrimaryNavigationButtonOverlay,
Expand Down Expand Up @@ -443,10 +444,10 @@ export function PrimaryNavigationQuotaExceeded({
// either it has been more than a day since the last shown date,
// the categories have changed, or
// the last time it was shown was before the current usage cycle started
const lastShownCategories = localStorage.getItem(
const lastShownCategories = localStorageWrapper.getItem(
`billing-status-last-shown-categories-${organization.id}`
);
const lastShownDate = localStorage.getItem(
const lastShownDate = localStorageWrapper.getItem(
`billing-status-last-shown-date-${organization.id}`
);
const daysSinceLastShown = lastShownDate ? getDaysSinceDate(lastShownDate) : 0;
Expand All @@ -463,11 +464,11 @@ export function PrimaryNavigationQuotaExceeded({
) {
hasAutoOpenedAlertRef.current = true;
overlayState.open();
localStorage.setItem(
localStorageWrapper.setItem(
`billing-status-last-shown-categories-${organization.id}`,
currentCategories
);
localStorage.setItem(
localStorageWrapper.setItem(
`billing-status-last-shown-date-${organization.id}`,
moment().utc().toISOString()
);
Expand Down
71 changes: 71 additions & 0 deletions static/gsApp/components/trialStarter.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import {TeamFixture} from 'sentry-fixture/team';
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
import {act, render, screen} from 'sentry-test/reactTestingLibrary';

jest.mock('sentry/utils/localStorage', () => {
const actual = jest.requireActual<typeof import('sentry/utils/localStorage')>(
'sentry/utils/localStorage'
);
return {
...actual,
localStorageWrapper: {
...actual.localStorageWrapper,
removeItem: jest.fn((...args: Parameters<Storage['removeItem']>) =>
actual.localStorageWrapper.removeItem(...args)
),
},
};
});

const {localStorageWrapper} = jest.requireMock<
typeof import('sentry/utils/localStorage')
>('sentry/utils/localStorage');

import TrialStarter from 'getsentry/components/trialStarter';
import {SubscriptionStore} from 'getsentry/stores/subscriptionStore';

Expand Down Expand Up @@ -106,4 +125,56 @@ describe('TrialStarter', () => {
expect(startedCall.trialStarted).toBe(false);
expect(startedCall.trialFailed).toBe(true);
});

it('starts a trial successfully when localStorage is unavailable', async () => {
// Simulate noopStorage: removeItem is a no-op (e.g., Android WebView without DOM Storage)
jest.mocked(localStorageWrapper.removeItem).mockImplementation(() => null);

const handleTrialStarted = jest.fn();
// eslint-disable-next-line no-empty-pattern
const renderer = jest.fn(({}: RendererProps) => <div>render text</div>);
MockApiClient.addMockResponse({
url: `/organizations/${org.slug}/`,
body: org,
});
MockApiClient.addMockResponse({
url: `/organizations/${org.slug}/projects/`,
body: [ProjectFixture()],
});
MockApiClient.addMockResponse({
url: `/organizations/${org.slug}/teams/`,
body: [TeamFixture()],
});

render(
<TrialStarter
organization={org}
source="test-abc"
onTrialStarted={handleTrialStarted}
>
{renderer}
</TrialStarter>
);

MockApiClient.addMockResponse({
url: `/customers/${org.slug}/`,
method: 'PUT',
});
MockApiClient.addMockResponse({
url: `/customers/${org.slug}/`,
method: 'GET',
body: sub,
});

await act(() => renderer.mock.calls.at(-1)![0].startTrial());

expect(handleTrialStarted).toHaveBeenCalled();
expect(localStorageWrapper.removeItem).toHaveBeenCalledWith(
'sidebar-new-seen:customizable-dashboards'
);

const startedCall = renderer.mock.calls.at(-1)![0];
expect(startedCall.trialStarted).toBe(true);
expect(startedCall.trialFailed).toBe(false);
});
});
3 changes: 2 additions & 1 deletion static/gsApp/components/trialStarter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {useState} from 'react';

import {fetchOrganizationDetails} from 'sentry/actionCreators/organization';
import type {Organization} from 'sentry/types/organization';
import {localStorageWrapper} from 'sentry/utils/localStorage';
import {useApi} from 'sentry/utils/useApi';

import {withSubscription} from 'getsentry/components/withSubscription';
Expand Down Expand Up @@ -69,7 +70,7 @@ function TrialStarter(props: Props) {

// we showed the "new" icon for the upsell that wasn't the actual dashboard
// we should clear this so folks can see "new" for the actual dashboard
localStorage.removeItem('sidebar-new-seen:customizable-dashboards');
localStorageWrapper.removeItem('sidebar-new-seen:customizable-dashboards');
};

const {subscription, children} = props;
Expand Down
Loading