diff --git a/static/app/utils/createStorage.spec.tsx b/static/app/utils/createStorage.spec.tsx new file mode 100644 index 00000000000000..aa12e89ab60d9c --- /dev/null +++ b/static/app/utils/createStorage.spec.tsx @@ -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(); + }); +}); diff --git a/static/gsApp/components/navBillingStatus.spec.tsx b/static/gsApp/components/navBillingStatus.spec.tsx index cb8c858a2265c9..1c801f6b64ddd4 100644 --- a/static/gsApp/components/navBillingStatus.spec.tsx +++ b/static/gsApp/components/navBillingStatus.spec.tsx @@ -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( + 'sentry/utils/localStorage' + ); + const wrapper = actual.localStorageWrapper; + return { + ...actual, + localStorageWrapper: { + getItem: jest.fn((...args: Parameters) => + wrapper.getItem(...args) + ), + setItem: jest.fn((...args: Parameters) => + wrapper.setItem(...args) + ), + removeItem: jest.fn((...args: Parameters) => + 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'; @@ -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(); + + 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(); + + 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); + }); + }); }); diff --git a/static/gsApp/components/navBillingStatus.tsx b/static/gsApp/components/navBillingStatus.tsx index b7436ee79a5957..60934b10a11dce 100644 --- a/static/gsApp/components/navBillingStatus.tsx +++ b/static/gsApp/components/navBillingStatus.tsx @@ -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, @@ -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; @@ -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() ); diff --git a/static/gsApp/components/trialStarter.spec.tsx b/static/gsApp/components/trialStarter.spec.tsx index 9a03719987913b..0f8362f96e2fc4 100644 --- a/static/gsApp/components/trialStarter.spec.tsx +++ b/static/gsApp/components/trialStarter.spec.tsx @@ -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( + 'sentry/utils/localStorage' + ); + return { + ...actual, + localStorageWrapper: { + ...actual.localStorageWrapper, + removeItem: jest.fn((...args: Parameters) => + 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'; @@ -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) =>
render text
); + 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( + + {renderer} + + ); + + 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); + }); }); diff --git a/static/gsApp/components/trialStarter.tsx b/static/gsApp/components/trialStarter.tsx index bf4a6481716d45..3c9ecd1ae934a3 100644 --- a/static/gsApp/components/trialStarter.tsx +++ b/static/gsApp/components/trialStarter.tsx @@ -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'; @@ -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;