@sudobility/subscription-components-rn 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SegmentedControl.d.ts +71 -0
- package/dist/SegmentedControl.d.ts.map +1 -0
- package/dist/SubscriptionLayout.d.ts +95 -0
- package/dist/SubscriptionLayout.d.ts.map +1 -0
- package/dist/SubscriptionProvider.d.ts +33 -0
- package/dist/SubscriptionProvider.d.ts.map +1 -0
- package/dist/SubscriptionTile.d.ts +64 -0
- package/dist/SubscriptionTile.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1629 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1629 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/SegmentedControl.tsx +221 -0
- package/src/SubscriptionLayout.tsx +379 -0
- package/src/SubscriptionProvider.tsx +257 -0
- package/src/SubscriptionTile.tsx +390 -0
- package/src/__tests__/SegmentedControl.test.tsx +182 -0
- package/src/__tests__/SubscriptionLayout.test.tsx +312 -0
- package/src/__tests__/SubscriptionProvider.test.tsx +180 -0
- package/src/__tests__/SubscriptionTile.test.tsx +248 -0
- package/src/index.ts +53 -0
- package/src/nativewind.d.ts +24 -0
- package/src/types.ts +214 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react-native';
|
|
3
|
+
import { SegmentedControl, PeriodSelector } from '../SegmentedControl';
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
7
|
+
{ value: 'yearly', label: 'Yearly' },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe('SegmentedControl', () => {
|
|
11
|
+
it('renders all options', () => {
|
|
12
|
+
const onChange = jest.fn();
|
|
13
|
+
render(
|
|
14
|
+
<SegmentedControl options={options} value="monthly" onChange={onChange} />
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(screen.getByText('Monthly')).toBeTruthy();
|
|
18
|
+
expect(screen.getByText('Yearly')).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('marks the selected option with accessibilityState.selected', () => {
|
|
22
|
+
const onChange = jest.fn();
|
|
23
|
+
render(
|
|
24
|
+
<SegmentedControl options={options} value="monthly" onChange={onChange} />
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const tabs = screen.getAllByRole('tab');
|
|
28
|
+
expect(tabs[0].props.accessibilityState.selected).toBe(true);
|
|
29
|
+
expect(tabs[1].props.accessibilityState.selected).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('calls onChange when an unselected option is pressed', () => {
|
|
33
|
+
const onChange = jest.fn();
|
|
34
|
+
render(
|
|
35
|
+
<SegmentedControl options={options} value="monthly" onChange={onChange} />
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
fireEvent.press(screen.getByText('Yearly'));
|
|
39
|
+
expect(onChange).toHaveBeenCalledWith('yearly');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not call onChange when the entire control is disabled', () => {
|
|
43
|
+
const onChange = jest.fn();
|
|
44
|
+
render(
|
|
45
|
+
<SegmentedControl
|
|
46
|
+
options={options}
|
|
47
|
+
value="monthly"
|
|
48
|
+
onChange={onChange}
|
|
49
|
+
disabled
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
fireEvent.press(screen.getByText('Yearly'));
|
|
54
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not call onChange when an individual option is disabled', () => {
|
|
58
|
+
const onChange = jest.fn();
|
|
59
|
+
const optionsWithDisabled = [
|
|
60
|
+
{ value: 'a', label: 'A' },
|
|
61
|
+
{ value: 'b', label: 'B', disabled: true },
|
|
62
|
+
];
|
|
63
|
+
render(
|
|
64
|
+
<SegmentedControl
|
|
65
|
+
options={optionsWithDisabled}
|
|
66
|
+
value="a"
|
|
67
|
+
onChange={onChange}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
fireEvent.press(screen.getByText('B'));
|
|
72
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders badge text when option has a badge', () => {
|
|
76
|
+
const onChange = jest.fn();
|
|
77
|
+
const optionsWithBadge = [
|
|
78
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
79
|
+
{ value: 'yearly', label: 'Yearly', badge: 'Save 20%' },
|
|
80
|
+
];
|
|
81
|
+
render(
|
|
82
|
+
<SegmentedControl
|
|
83
|
+
options={optionsWithBadge}
|
|
84
|
+
value="monthly"
|
|
85
|
+
onChange={onChange}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(screen.getByText('Save 20%')).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('includes badge in accessibility label', () => {
|
|
93
|
+
const onChange = jest.fn();
|
|
94
|
+
const optionsWithBadge = [
|
|
95
|
+
{ value: 'monthly', label: 'Monthly' },
|
|
96
|
+
{ value: 'yearly', label: 'Yearly', badge: 'Save 20%' },
|
|
97
|
+
];
|
|
98
|
+
render(
|
|
99
|
+
<SegmentedControl
|
|
100
|
+
options={optionsWithBadge}
|
|
101
|
+
value="monthly"
|
|
102
|
+
onChange={onChange}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const tabs = screen.getAllByRole('tab');
|
|
107
|
+
expect(tabs[1].props.accessibilityLabel).toBe('Yearly, Save 20%');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('applies custom accessibilityLabel to the container', () => {
|
|
111
|
+
const onChange = jest.fn();
|
|
112
|
+
render(
|
|
113
|
+
<SegmentedControl
|
|
114
|
+
options={options}
|
|
115
|
+
value="monthly"
|
|
116
|
+
onChange={onChange}
|
|
117
|
+
accessibilityLabel="Period picker"
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByLabelText('Period picker')).toBeTruthy();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('PeriodSelector', () => {
|
|
126
|
+
it('renders Monthly and Yearly options with default labels', () => {
|
|
127
|
+
const onPeriodChange = jest.fn();
|
|
128
|
+
render(
|
|
129
|
+
<PeriodSelector period="monthly" onPeriodChange={onPeriodChange} />
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(screen.getByText('Monthly')).toBeTruthy();
|
|
133
|
+
expect(screen.getByText('Yearly')).toBeTruthy();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('uses custom labels', () => {
|
|
137
|
+
const onPeriodChange = jest.fn();
|
|
138
|
+
render(
|
|
139
|
+
<PeriodSelector
|
|
140
|
+
period="monthly"
|
|
141
|
+
onPeriodChange={onPeriodChange}
|
|
142
|
+
monthlyLabel="Mensuel"
|
|
143
|
+
yearlyLabel="Annuel"
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText('Mensuel')).toBeTruthy();
|
|
148
|
+
expect(screen.getByText('Annuel')).toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('calls onPeriodChange when switching period', () => {
|
|
152
|
+
const onPeriodChange = jest.fn();
|
|
153
|
+
render(
|
|
154
|
+
<PeriodSelector period="monthly" onPeriodChange={onPeriodChange} />
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
fireEvent.press(screen.getByText('Yearly'));
|
|
158
|
+
expect(onPeriodChange).toHaveBeenCalledWith('yearly');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('renders yearly savings badge when provided', () => {
|
|
162
|
+
const onPeriodChange = jest.fn();
|
|
163
|
+
render(
|
|
164
|
+
<PeriodSelector
|
|
165
|
+
period="monthly"
|
|
166
|
+
onPeriodChange={onPeriodChange}
|
|
167
|
+
yearlySavings="Save 20%"
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(screen.getByText('Save 20%')).toBeTruthy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('has the billing period selector accessibility label', () => {
|
|
175
|
+
const onPeriodChange = jest.fn();
|
|
176
|
+
render(
|
|
177
|
+
<PeriodSelector period="monthly" onPeriodChange={onPeriodChange} />
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(screen.getByLabelText('Billing period selector')).toBeTruthy();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react-native';
|
|
3
|
+
import { Text } from 'react-native';
|
|
4
|
+
import {
|
|
5
|
+
SubscriptionLayout,
|
|
6
|
+
SubscriptionDivider,
|
|
7
|
+
SubscriptionFooter,
|
|
8
|
+
} from '../SubscriptionLayout';
|
|
9
|
+
|
|
10
|
+
describe('SubscriptionLayout', () => {
|
|
11
|
+
it('renders title and children', () => {
|
|
12
|
+
render(
|
|
13
|
+
<SubscriptionLayout title="Choose a Plan">
|
|
14
|
+
<Text>Child content</Text>
|
|
15
|
+
</SubscriptionLayout>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText('Choose a Plan')).toBeTruthy();
|
|
19
|
+
expect(screen.getByText('Child content')).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('displays error message when error prop is provided', () => {
|
|
23
|
+
render(
|
|
24
|
+
<SubscriptionLayout title="Plans" error="Something went wrong">
|
|
25
|
+
<Text>Content</Text>
|
|
26
|
+
</SubscriptionLayout>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(screen.getByText('Something went wrong')).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('does not display error section when error is null', () => {
|
|
33
|
+
render(
|
|
34
|
+
<SubscriptionLayout title="Plans" error={null}>
|
|
35
|
+
<Text>Content</Text>
|
|
36
|
+
</SubscriptionLayout>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(screen.queryByText('Something went wrong')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders primary and secondary action buttons in selection variant', () => {
|
|
43
|
+
const onPrimary = jest.fn();
|
|
44
|
+
const onSecondary = jest.fn();
|
|
45
|
+
render(
|
|
46
|
+
<SubscriptionLayout
|
|
47
|
+
title="Plans"
|
|
48
|
+
variant="selection"
|
|
49
|
+
primaryAction={{ label: 'Subscribe Now', onPress: onPrimary }}
|
|
50
|
+
secondaryAction={{ label: 'Restore', onPress: onSecondary }}
|
|
51
|
+
>
|
|
52
|
+
<Text>Content</Text>
|
|
53
|
+
</SubscriptionLayout>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(screen.getByText('Subscribe Now')).toBeTruthy();
|
|
57
|
+
expect(screen.getByText('Restore')).toBeTruthy();
|
|
58
|
+
|
|
59
|
+
fireEvent.press(screen.getByText('Subscribe Now'));
|
|
60
|
+
expect(onPrimary).toHaveBeenCalledTimes(1);
|
|
61
|
+
|
|
62
|
+
fireEvent.press(screen.getByText('Restore'));
|
|
63
|
+
expect(onSecondary).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not render action buttons in cta variant', () => {
|
|
67
|
+
const onPrimary = jest.fn();
|
|
68
|
+
render(
|
|
69
|
+
<SubscriptionLayout
|
|
70
|
+
title="Plans"
|
|
71
|
+
variant="cta"
|
|
72
|
+
primaryAction={{ label: 'Subscribe Now', onPress: onPrimary }}
|
|
73
|
+
>
|
|
74
|
+
<Text>Content</Text>
|
|
75
|
+
</SubscriptionLayout>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(screen.queryByText('Subscribe Now')).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders active subscription status', () => {
|
|
82
|
+
render(
|
|
83
|
+
<SubscriptionLayout
|
|
84
|
+
title="Plans"
|
|
85
|
+
currentStatus={{
|
|
86
|
+
isActive: true,
|
|
87
|
+
activeContent: {
|
|
88
|
+
title: 'Pro Subscription',
|
|
89
|
+
fields: [
|
|
90
|
+
{ label: 'Plan', value: 'Pro' },
|
|
91
|
+
{ label: 'Expires', value: 'Jan 1, 2027' },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<Text>Content</Text>
|
|
97
|
+
</SubscriptionLayout>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByText('Pro Subscription')).toBeTruthy();
|
|
101
|
+
expect(screen.getByText('Plan')).toBeTruthy();
|
|
102
|
+
expect(screen.getByText('Pro')).toBeTruthy();
|
|
103
|
+
expect(screen.getByText('Expires')).toBeTruthy();
|
|
104
|
+
expect(screen.getByText('Jan 1, 2027')).toBeTruthy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('renders inactive subscription status', () => {
|
|
108
|
+
render(
|
|
109
|
+
<SubscriptionLayout
|
|
110
|
+
title="Plans"
|
|
111
|
+
currentStatus={{
|
|
112
|
+
isActive: false,
|
|
113
|
+
inactiveContent: {
|
|
114
|
+
title: 'No Subscription',
|
|
115
|
+
message: 'Subscribe to unlock features',
|
|
116
|
+
},
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<Text>Content</Text>
|
|
120
|
+
</SubscriptionLayout>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(screen.getByText('No Subscription')).toBeTruthy();
|
|
124
|
+
expect(screen.getByText('Subscribe to unlock features')).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders custom currentStatusLabel', () => {
|
|
128
|
+
render(
|
|
129
|
+
<SubscriptionLayout
|
|
130
|
+
title="Plans"
|
|
131
|
+
currentStatus={{ isActive: false }}
|
|
132
|
+
currentStatusLabel="Your Status"
|
|
133
|
+
>
|
|
134
|
+
<Text>Content</Text>
|
|
135
|
+
</SubscriptionLayout>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(screen.getByText('Your Status')).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('renders free tile in cta variant when freeTileConfig is provided', () => {
|
|
142
|
+
render(
|
|
143
|
+
<SubscriptionLayout
|
|
144
|
+
title="Plans"
|
|
145
|
+
variant="cta"
|
|
146
|
+
freeTileConfig={{
|
|
147
|
+
title: 'Free',
|
|
148
|
+
price: '$0',
|
|
149
|
+
features: ['Basic access'],
|
|
150
|
+
ctaButton: { label: 'Get Started', onPress: jest.fn() },
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Text>Paid tiles</Text>
|
|
154
|
+
</SubscriptionLayout>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(screen.getByText('Free')).toBeTruthy();
|
|
158
|
+
expect(screen.getByText('$0')).toBeTruthy();
|
|
159
|
+
expect(screen.getByText('Basic access')).toBeTruthy();
|
|
160
|
+
expect(screen.getByText('Get Started')).toBeTruthy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not render free tile in selection variant', () => {
|
|
164
|
+
render(
|
|
165
|
+
<SubscriptionLayout
|
|
166
|
+
title="Plans"
|
|
167
|
+
variant="selection"
|
|
168
|
+
freeTileConfig={{
|
|
169
|
+
title: 'Free',
|
|
170
|
+
price: '$0',
|
|
171
|
+
features: ['Basic access'],
|
|
172
|
+
ctaButton: { label: 'Get Started', onPress: jest.fn() },
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<Text>Paid tiles</Text>
|
|
176
|
+
</SubscriptionLayout>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// The free tile title won't appear since it's selection variant
|
|
180
|
+
// However "Plans" title uses Text, so check for the free-specific price
|
|
181
|
+
expect(screen.queryByText('$0')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('renders header and footer content', () => {
|
|
185
|
+
render(
|
|
186
|
+
<SubscriptionLayout
|
|
187
|
+
title="Plans"
|
|
188
|
+
headerContent={<Text>Header info</Text>}
|
|
189
|
+
footerContent={<Text>Footer info</Text>}
|
|
190
|
+
>
|
|
191
|
+
<Text>Content</Text>
|
|
192
|
+
</SubscriptionLayout>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(screen.getByText('Header info')).toBeTruthy();
|
|
196
|
+
expect(screen.getByText('Footer info')).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('renders aboveProducts content', () => {
|
|
200
|
+
render(
|
|
201
|
+
<SubscriptionLayout
|
|
202
|
+
title="Plans"
|
|
203
|
+
aboveProducts={<Text>Billing period selector</Text>}
|
|
204
|
+
>
|
|
205
|
+
<Text>Content</Text>
|
|
206
|
+
</SubscriptionLayout>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(screen.getByText('Billing period selector')).toBeTruthy();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('calls onTrack when primary action is pressed', () => {
|
|
213
|
+
const onTrack = jest.fn();
|
|
214
|
+
render(
|
|
215
|
+
<SubscriptionLayout
|
|
216
|
+
title="Plans"
|
|
217
|
+
variant="selection"
|
|
218
|
+
primaryAction={{ label: 'Go', onPress: jest.fn() }}
|
|
219
|
+
onTrack={onTrack}
|
|
220
|
+
trackingLabel="sub_layout"
|
|
221
|
+
>
|
|
222
|
+
<Text>Content</Text>
|
|
223
|
+
</SubscriptionLayout>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
fireEvent.press(screen.getByText('Go'));
|
|
227
|
+
expect(onTrack).toHaveBeenCalledWith({
|
|
228
|
+
action: 'primary_action',
|
|
229
|
+
trackingLabel: 'sub_layout',
|
|
230
|
+
componentName: 'SubscriptionLayout',
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('SubscriptionDivider', () => {
|
|
236
|
+
it('renders without label (simple divider)', () => {
|
|
237
|
+
const { toJSON } = render(<SubscriptionDivider />);
|
|
238
|
+
expect(toJSON()).toBeTruthy();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('renders with label text', () => {
|
|
242
|
+
render(<SubscriptionDivider label="or" />);
|
|
243
|
+
expect(screen.getByText('or')).toBeTruthy();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('SubscriptionFooter', () => {
|
|
248
|
+
it('renders default link text', () => {
|
|
249
|
+
render(
|
|
250
|
+
<SubscriptionFooter
|
|
251
|
+
onRestore={jest.fn()}
|
|
252
|
+
onTermsPress={jest.fn()}
|
|
253
|
+
onPrivacyPress={jest.fn()}
|
|
254
|
+
/>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(screen.getByText('Restore Purchases')).toBeTruthy();
|
|
258
|
+
expect(screen.getByText('Terms of Service')).toBeTruthy();
|
|
259
|
+
expect(screen.getByText('Privacy Policy')).toBeTruthy();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('renders custom link text', () => {
|
|
263
|
+
render(
|
|
264
|
+
<SubscriptionFooter
|
|
265
|
+
restoreText="Restaurer"
|
|
266
|
+
termsText="Conditions"
|
|
267
|
+
privacyText="Confidentialite"
|
|
268
|
+
onRestore={jest.fn()}
|
|
269
|
+
onTermsPress={jest.fn()}
|
|
270
|
+
onPrivacyPress={jest.fn()}
|
|
271
|
+
/>
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(screen.getByText('Restaurer')).toBeTruthy();
|
|
275
|
+
expect(screen.getByText('Conditions')).toBeTruthy();
|
|
276
|
+
expect(screen.getByText('Confidentialite')).toBeTruthy();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('calls onRestore when restore is pressed', () => {
|
|
280
|
+
const onRestore = jest.fn();
|
|
281
|
+
render(<SubscriptionFooter onRestore={onRestore} />);
|
|
282
|
+
|
|
283
|
+
fireEvent.press(screen.getByText('Restore Purchases'));
|
|
284
|
+
expect(onRestore).toHaveBeenCalledTimes(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('does not render restore link when onRestore is not provided', () => {
|
|
288
|
+
render(<SubscriptionFooter />);
|
|
289
|
+
expect(screen.queryByText('Restore Purchases')).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('renders disclaimer text', () => {
|
|
293
|
+
render(<SubscriptionFooter />);
|
|
294
|
+
expect(
|
|
295
|
+
screen.getByText(/Subscriptions will automatically renew/)
|
|
296
|
+
).toBeTruthy();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('calls onTermsPress and onPrivacyPress', () => {
|
|
300
|
+
const onTerms = jest.fn();
|
|
301
|
+
const onPrivacy = jest.fn();
|
|
302
|
+
render(
|
|
303
|
+
<SubscriptionFooter onTermsPress={onTerms} onPrivacyPress={onPrivacy} />
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
fireEvent.press(screen.getByText('Terms of Service'));
|
|
307
|
+
expect(onTerms).toHaveBeenCalledTimes(1);
|
|
308
|
+
|
|
309
|
+
fireEvent.press(screen.getByText('Privacy Policy'));
|
|
310
|
+
expect(onPrivacy).toHaveBeenCalledTimes(1);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, act } from '@testing-library/react-native';
|
|
3
|
+
import { Text, Pressable } from 'react-native';
|
|
4
|
+
import {
|
|
5
|
+
SubscriptionProvider,
|
|
6
|
+
useSubscriptionContext,
|
|
7
|
+
} from '../SubscriptionProvider';
|
|
8
|
+
|
|
9
|
+
// Helper component that exposes context values for testing
|
|
10
|
+
function TestConsumer() {
|
|
11
|
+
const ctx = useSubscriptionContext();
|
|
12
|
+
return (
|
|
13
|
+
<>
|
|
14
|
+
<Text testID="loading">{ctx.isLoading ? 'true' : 'false'}</Text>
|
|
15
|
+
<Text testID="error">{ctx.error ?? 'none'}</Text>
|
|
16
|
+
<Text testID="subscription">
|
|
17
|
+
{ctx.currentSubscription ? 'active' : 'none'}
|
|
18
|
+
</Text>
|
|
19
|
+
<Pressable
|
|
20
|
+
testID="initialize"
|
|
21
|
+
onPress={() => {
|
|
22
|
+
ctx.initialize();
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
<Pressable
|
|
26
|
+
testID="purchase"
|
|
27
|
+
onPress={() => {
|
|
28
|
+
ctx.purchase('test_product');
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
<Pressable
|
|
32
|
+
testID="restore"
|
|
33
|
+
onPress={() => {
|
|
34
|
+
ctx.restore();
|
|
35
|
+
}}
|
|
36
|
+
/>
|
|
37
|
+
<Pressable testID="clearError" onPress={() => ctx.clearError()} />
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SubscriptionProvider', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.useFakeTimers();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
jest.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('throws when useSubscriptionContext is used outside provider', () => {
|
|
52
|
+
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
53
|
+
expect(() => render(<TestConsumer />)).toThrow(
|
|
54
|
+
'useSubscriptionContext must be used within a SubscriptionProvider'
|
|
55
|
+
);
|
|
56
|
+
spy.mockRestore();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('provides initial state with no loading, no error, no subscription', () => {
|
|
60
|
+
render(
|
|
61
|
+
<SubscriptionProvider apiKey="test_key">
|
|
62
|
+
<TestConsumer />
|
|
63
|
+
</SubscriptionProvider>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(screen.getByTestId('loading').props.children).toBe('false');
|
|
67
|
+
expect(screen.getByTestId('error').props.children).toBe('none');
|
|
68
|
+
expect(screen.getByTestId('subscription').props.children).toBe('none');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('initializes in development mode (no real API key)', async () => {
|
|
72
|
+
const onError = jest.fn();
|
|
73
|
+
render(
|
|
74
|
+
<SubscriptionProvider apiKey="" onError={onError}>
|
|
75
|
+
<TestConsumer />
|
|
76
|
+
</SubscriptionProvider>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
fireEvent.press(screen.getByTestId('initialize'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(screen.getByTestId('loading').props.children).toBe('false');
|
|
84
|
+
expect(screen.getByTestId('error').props.children).toBe('none');
|
|
85
|
+
expect(onError).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('purchase flow sets active subscription in dev mode', async () => {
|
|
89
|
+
const onPurchaseSuccess = jest.fn();
|
|
90
|
+
render(
|
|
91
|
+
<SubscriptionProvider apiKey="" onPurchaseSuccess={onPurchaseSuccess}>
|
|
92
|
+
<TestConsumer />
|
|
93
|
+
</SubscriptionProvider>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Start purchase
|
|
97
|
+
await act(async () => {
|
|
98
|
+
fireEvent.press(screen.getByTestId('purchase'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Advance past the 2000ms simulated delay
|
|
102
|
+
await act(async () => {
|
|
103
|
+
jest.advanceTimersByTime(2100);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(screen.getByTestId('subscription').props.children).toBe('active');
|
|
107
|
+
expect(onPurchaseSuccess).toHaveBeenCalledWith('test_product');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('restore flow sets error "No previous purchases found" in dev mode', async () => {
|
|
111
|
+
render(
|
|
112
|
+
<SubscriptionProvider apiKey="">
|
|
113
|
+
<TestConsumer />
|
|
114
|
+
</SubscriptionProvider>
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await act(async () => {
|
|
118
|
+
fireEvent.press(screen.getByTestId('restore'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
jest.advanceTimersByTime(1100);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(screen.getByTestId('error').props.children).toBe(
|
|
126
|
+
'No previous purchases found'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('clearError resets error to null', async () => {
|
|
131
|
+
render(
|
|
132
|
+
<SubscriptionProvider apiKey="">
|
|
133
|
+
<TestConsumer />
|
|
134
|
+
</SubscriptionProvider>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Trigger restore to get an error
|
|
138
|
+
await act(async () => {
|
|
139
|
+
fireEvent.press(screen.getByTestId('restore'));
|
|
140
|
+
});
|
|
141
|
+
await act(async () => {
|
|
142
|
+
jest.advanceTimersByTime(1100);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(screen.getByTestId('error').props.children).toBe(
|
|
146
|
+
'No previous purchases found'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Clear it
|
|
150
|
+
await act(async () => {
|
|
151
|
+
fireEvent.press(screen.getByTestId('clearError'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(screen.getByTestId('error').props.children).toBe('none');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('initialize only runs once (idempotent)', async () => {
|
|
158
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
159
|
+
render(
|
|
160
|
+
<SubscriptionProvider apiKey="">
|
|
161
|
+
<TestConsumer />
|
|
162
|
+
</SubscriptionProvider>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
fireEvent.press(screen.getByTestId('initialize'));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
warn.mockClear();
|
|
170
|
+
|
|
171
|
+
// Second call should be a no-op (isInitialized is true)
|
|
172
|
+
await act(async () => {
|
|
173
|
+
fireEvent.press(screen.getByTestId('initialize'));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// The warning should not fire again because initialize returns early
|
|
177
|
+
expect(warn).not.toHaveBeenCalled();
|
|
178
|
+
warn.mockRestore();
|
|
179
|
+
});
|
|
180
|
+
});
|