@bernierllc/email-campaign-management 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/.eslintrc.cjs +45 -0
- package/README.md +316 -0
- package/__tests__/api/campaigns.test.ts +217 -0
- package/__tests__/api/client.test.ts +330 -0
- package/__tests__/components/CampaignBuilder.test.tsx +103 -0
- package/__tests__/components/CampaignDashboard.test.tsx +89 -0
- package/__tests__/components/CampaignList.test.tsx +144 -0
- package/__tests__/components/MetricsOverview.test.tsx +200 -0
- package/__tests__/components/PerformanceChart.test.tsx +206 -0
- package/__tests__/hooks/useCampaignStore.test.ts +450 -0
- package/__tests__/hooks/useWorkflowValidation.test.ts +176 -0
- package/__tests__/utils/formatting.test.ts +48 -0
- package/__tests__/utils/validation.test.ts +199 -0
- package/__tests__/utils/workflow-helpers.test.ts +134 -0
- package/coverage/clover.xml +314 -0
- package/coverage/coverage-final.json +16 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +221 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/api/campaigns.ts.html +199 -0
- package/coverage/lcov-report/src/api/client.ts.html +478 -0
- package/coverage/lcov-report/src/api/index.html +131 -0
- package/coverage/lcov-report/src/components/CampaignBuilder/index.html +116 -0
- package/coverage/lcov-report/src/components/CampaignBuilder/index.tsx.html +454 -0
- package/coverage/lcov-report/src/components/CampaignDashboard/MetricsOverview.tsx.html +208 -0
- package/coverage/lcov-report/src/components/CampaignDashboard/PerformanceChart.tsx.html +232 -0
- package/coverage/lcov-report/src/components/CampaignDashboard/index.html +146 -0
- package/coverage/lcov-report/src/components/CampaignDashboard/index.tsx.html +241 -0
- package/coverage/lcov-report/src/components/CampaignList/index.html +116 -0
- package/coverage/lcov-report/src/components/CampaignList/index.tsx.html +244 -0
- package/coverage/lcov-report/src/config.ts.html +202 -0
- package/coverage/lcov-report/src/hooks/index.html +146 -0
- package/coverage/lcov-report/src/hooks/useCampaignMetrics.ts.html +208 -0
- package/coverage/lcov-report/src/hooks/useCampaignStore.ts.html +343 -0
- package/coverage/lcov-report/src/hooks/useWorkflowValidation.ts.html +136 -0
- package/coverage/lcov-report/src/index.html +116 -0
- package/coverage/lcov-report/src/types/index.html +116 -0
- package/coverage/lcov-report/src/types/index.ts.html +127 -0
- package/coverage/lcov-report/src/utils/formatting.ts.html +163 -0
- package/coverage/lcov-report/src/utils/index.html +146 -0
- package/coverage/lcov-report/src/utils/validation.ts.html +394 -0
- package/coverage/lcov-report/src/utils/workflow-helpers.ts.html +277 -0
- package/coverage/lcov.info +657 -0
- package/dist/api/campaigns.d.ts +9 -0
- package/dist/api/campaigns.js +38 -0
- package/dist/api/client.d.ts +14 -0
- package/dist/api/client.js +116 -0
- package/dist/components/CampaignBuilder/index.d.ts +8 -0
- package/dist/components/CampaignBuilder/index.js +88 -0
- package/dist/components/CampaignDashboard/MetricsOverview.d.ts +6 -0
- package/dist/components/CampaignDashboard/MetricsOverview.js +34 -0
- package/dist/components/CampaignDashboard/PerformanceChart.d.ts +7 -0
- package/dist/components/CampaignDashboard/PerformanceChart.js +45 -0
- package/dist/components/CampaignDashboard/index.d.ts +5 -0
- package/dist/components/CampaignDashboard/index.js +44 -0
- package/dist/components/CampaignList/index.d.ts +6 -0
- package/dist/components/CampaignList/index.js +68 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +31 -0
- package/dist/hooks/useCampaignMetrics.d.ts +2 -0
- package/dist/hooks/useCampaignMetrics.js +42 -0
- package/dist/hooks/useCampaignStore.d.ts +14 -0
- package/dist/hooks/useCampaignStore.js +105 -0
- package/dist/hooks/useWorkflowValidation.d.ts +3 -0
- package/dist/hooks/useWorkflowValidation.js +17 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +52 -0
- package/dist/types/abtest.d.ts +15 -0
- package/dist/types/abtest.js +9 -0
- package/dist/types/audience.d.ts +18 -0
- package/dist/types/audience.js +9 -0
- package/dist/types/campaign.d.ts +31 -0
- package/dist/types/campaign.js +9 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.js +29 -0
- package/dist/types/metrics.d.ts +27 -0
- package/dist/types/metrics.js +9 -0
- package/dist/types/schedule.d.ts +15 -0
- package/dist/types/schedule.js +9 -0
- package/dist/types/workflow.d.ts +37 -0
- package/dist/types/workflow.js +9 -0
- package/dist/utils/formatting.d.ts +4 -0
- package/dist/utils/formatting.js +28 -0
- package/dist/utils/validation.d.ts +8 -0
- package/dist/utils/validation.js +81 -0
- package/dist/utils/workflow-helpers.d.ts +12 -0
- package/dist/utils/workflow-helpers.js +62 -0
- package/jest.config.cjs +33 -0
- package/jest.setup.cjs +9 -0
- package/package.json +72 -0
- package/src/api/campaigns.ts +38 -0
- package/src/api/client.ts +131 -0
- package/src/components/CampaignBuilder/index.tsx +123 -0
- package/src/components/CampaignDashboard/MetricsOverview.tsx +41 -0
- package/src/components/CampaignDashboard/PerformanceChart.tsx +49 -0
- package/src/components/CampaignDashboard/index.tsx +52 -0
- package/src/components/CampaignList/index.tsx +53 -0
- package/src/config.ts +39 -0
- package/src/hooks/useCampaignMetrics.ts +41 -0
- package/src/hooks/useCampaignStore.ts +86 -0
- package/src/hooks/useWorkflowValidation.ts +17 -0
- package/src/index.ts +32 -0
- package/src/types/abtest.ts +25 -0
- package/src/types/audience.ts +30 -0
- package/src/types/campaign.ts +44 -0
- package/src/types/index.ts +14 -0
- package/src/types/metrics.ts +36 -0
- package/src/types/schedule.ts +26 -0
- package/src/types/workflow.ts +53 -0
- package/src/utils/formatting.ts +26 -0
- package/src/utils/validation.ts +103 -0
- package/src/utils/workflow-helpers.ts +64 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { act, renderHook } from '@testing-library/react';
|
|
10
|
+
import { useCampaignStore } from '../../src/hooks/useCampaignStore';
|
|
11
|
+
import * as campaignAPI from '../../src/api/campaigns';
|
|
12
|
+
import { Campaign } from '../../src/types';
|
|
13
|
+
|
|
14
|
+
jest.mock('../../src/api/campaigns');
|
|
15
|
+
|
|
16
|
+
const mockCampaignAPI = campaignAPI as jest.Mocked<typeof campaignAPI>;
|
|
17
|
+
|
|
18
|
+
const createMockCampaign = (overrides: Partial<Campaign> = {}): Campaign => ({
|
|
19
|
+
id: 'campaign-1',
|
|
20
|
+
name: 'Test Campaign',
|
|
21
|
+
description: 'Test description',
|
|
22
|
+
type: 'one-time',
|
|
23
|
+
status: 'draft',
|
|
24
|
+
workflow: {
|
|
25
|
+
id: 'workflow-1',
|
|
26
|
+
nodes: [],
|
|
27
|
+
edges: []
|
|
28
|
+
},
|
|
29
|
+
audience: {
|
|
30
|
+
type: 'all'
|
|
31
|
+
},
|
|
32
|
+
schedule: {
|
|
33
|
+
type: 'immediate'
|
|
34
|
+
},
|
|
35
|
+
createdAt: new Date('2025-01-01'),
|
|
36
|
+
updatedAt: new Date('2025-01-01'),
|
|
37
|
+
createdBy: 'user-123',
|
|
38
|
+
...overrides
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('useCampaignStore', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
jest.clearAllMocks();
|
|
44
|
+
// Reset store state between tests
|
|
45
|
+
const store = useCampaignStore.getState();
|
|
46
|
+
store.campaigns = [];
|
|
47
|
+
store.activeCampaign = null;
|
|
48
|
+
store.loading = false;
|
|
49
|
+
store.error = null;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('initial state', () => {
|
|
53
|
+
it('should have empty campaigns array', () => {
|
|
54
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
55
|
+
expect(result.current.campaigns).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should have null activeCampaign', () => {
|
|
59
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
60
|
+
expect(result.current.activeCampaign).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should have loading false', () => {
|
|
64
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
65
|
+
expect(result.current.loading).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should have null error', () => {
|
|
69
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
70
|
+
expect(result.current.error).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('fetchCampaigns', () => {
|
|
75
|
+
it('should set loading to true while fetching', async () => {
|
|
76
|
+
mockCampaignAPI.getCampaigns.mockImplementation(
|
|
77
|
+
() => new Promise((resolve) => setTimeout(() => resolve({
|
|
78
|
+
success: true,
|
|
79
|
+
data: []
|
|
80
|
+
}), 100))
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
84
|
+
|
|
85
|
+
act(() => {
|
|
86
|
+
result.current.fetchCampaigns();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.current.loading).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should fetch and store campaigns on success', async () => {
|
|
93
|
+
const mockCampaigns = [
|
|
94
|
+
createMockCampaign({ id: 'campaign-1', name: 'Campaign 1' }),
|
|
95
|
+
createMockCampaign({ id: 'campaign-2', name: 'Campaign 2' })
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
mockCampaignAPI.getCampaigns.mockResolvedValue({
|
|
99
|
+
success: true,
|
|
100
|
+
data: mockCampaigns
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
104
|
+
|
|
105
|
+
await act(async () => {
|
|
106
|
+
await result.current.fetchCampaigns();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(result.current.campaigns).toEqual(mockCampaigns);
|
|
110
|
+
expect(result.current.loading).toBe(false);
|
|
111
|
+
expect(result.current.error).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should set error on failure', async () => {
|
|
115
|
+
mockCampaignAPI.getCampaigns.mockResolvedValue({
|
|
116
|
+
success: false,
|
|
117
|
+
error: 'Failed to fetch campaigns'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
await result.current.fetchCampaigns();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(result.current.error).toBe('Failed to fetch campaigns');
|
|
127
|
+
expect(result.current.loading).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should use default error message when no error provided', async () => {
|
|
131
|
+
mockCampaignAPI.getCampaigns.mockResolvedValue({
|
|
132
|
+
success: false
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
await result.current.fetchCampaigns();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(result.current.error).toBe('Failed to fetch campaigns');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should clear previous error before fetching', async () => {
|
|
145
|
+
// Set up initial error state
|
|
146
|
+
const store = useCampaignStore.getState();
|
|
147
|
+
store.error = 'Previous error';
|
|
148
|
+
|
|
149
|
+
mockCampaignAPI.getCampaigns.mockResolvedValue({
|
|
150
|
+
success: true,
|
|
151
|
+
data: []
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
155
|
+
|
|
156
|
+
await act(async () => {
|
|
157
|
+
await result.current.fetchCampaigns();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.current.error).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('createCampaign', () => {
|
|
165
|
+
it('should create campaign and add to store on success', async () => {
|
|
166
|
+
const newCampaign = createMockCampaign({ id: 'new-campaign', name: 'New Campaign' });
|
|
167
|
+
|
|
168
|
+
mockCampaignAPI.createCampaign.mockResolvedValue({
|
|
169
|
+
success: true,
|
|
170
|
+
data: newCampaign
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
174
|
+
|
|
175
|
+
let createdCampaign: Campaign | null = null;
|
|
176
|
+
await act(async () => {
|
|
177
|
+
createdCampaign = await result.current.createCampaign({ name: 'New Campaign' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(createdCampaign).toEqual(newCampaign);
|
|
181
|
+
expect(result.current.campaigns).toContainEqual(newCampaign);
|
|
182
|
+
expect(result.current.loading).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should set loading while creating', async () => {
|
|
186
|
+
mockCampaignAPI.createCampaign.mockImplementation(
|
|
187
|
+
() => new Promise((resolve) => setTimeout(() => resolve({
|
|
188
|
+
success: true,
|
|
189
|
+
data: createMockCampaign()
|
|
190
|
+
}), 100))
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
194
|
+
|
|
195
|
+
act(() => {
|
|
196
|
+
result.current.createCampaign({ name: 'Test' });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(result.current.loading).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should set error and return null on failure', async () => {
|
|
203
|
+
mockCampaignAPI.createCampaign.mockResolvedValue({
|
|
204
|
+
success: false,
|
|
205
|
+
error: 'Creation failed'
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
209
|
+
|
|
210
|
+
let createdCampaign: Campaign | null = null;
|
|
211
|
+
await act(async () => {
|
|
212
|
+
createdCampaign = await result.current.createCampaign({ name: 'Test' });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(createdCampaign).toBeNull();
|
|
216
|
+
expect(result.current.error).toBe('Creation failed');
|
|
217
|
+
expect(result.current.loading).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should use default error message when no error provided', async () => {
|
|
221
|
+
mockCampaignAPI.createCampaign.mockResolvedValue({
|
|
222
|
+
success: false
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
await result.current.createCampaign({ name: 'Test' });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result.current.error).toBe('Failed to create campaign');
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('updateCampaign', () => {
|
|
236
|
+
beforeEach(() => {
|
|
237
|
+
const store = useCampaignStore.getState();
|
|
238
|
+
store.campaigns = [
|
|
239
|
+
createMockCampaign({ id: 'campaign-1', name: 'Campaign 1' }),
|
|
240
|
+
createMockCampaign({ id: 'campaign-2', name: 'Campaign 2' })
|
|
241
|
+
];
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should update campaign in store on success', async () => {
|
|
245
|
+
const updatedCampaign = createMockCampaign({ id: 'campaign-1', name: 'Updated Name' });
|
|
246
|
+
|
|
247
|
+
mockCampaignAPI.updateCampaign.mockResolvedValue({
|
|
248
|
+
success: true,
|
|
249
|
+
data: updatedCampaign
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
253
|
+
|
|
254
|
+
await act(async () => {
|
|
255
|
+
await result.current.updateCampaign('campaign-1', { name: 'Updated Name' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const updated = result.current.campaigns.find(c => c.id === 'campaign-1');
|
|
259
|
+
expect(updated?.name).toBe('Updated Name');
|
|
260
|
+
expect(result.current.loading).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should update activeCampaign if it matches the updated campaign', async () => {
|
|
264
|
+
const store = useCampaignStore.getState();
|
|
265
|
+
store.activeCampaign = createMockCampaign({ id: 'campaign-1', name: 'Original' });
|
|
266
|
+
|
|
267
|
+
const updatedCampaign = createMockCampaign({ id: 'campaign-1', name: 'Updated Active' });
|
|
268
|
+
|
|
269
|
+
mockCampaignAPI.updateCampaign.mockResolvedValue({
|
|
270
|
+
success: true,
|
|
271
|
+
data: updatedCampaign
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
275
|
+
|
|
276
|
+
await act(async () => {
|
|
277
|
+
await result.current.updateCampaign('campaign-1', { name: 'Updated Active' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(result.current.activeCampaign?.name).toBe('Updated Active');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should not update activeCampaign if it does not match', async () => {
|
|
284
|
+
const store = useCampaignStore.getState();
|
|
285
|
+
store.activeCampaign = createMockCampaign({ id: 'campaign-2', name: 'Other Campaign' });
|
|
286
|
+
|
|
287
|
+
const updatedCampaign = createMockCampaign({ id: 'campaign-1', name: 'Updated' });
|
|
288
|
+
|
|
289
|
+
mockCampaignAPI.updateCampaign.mockResolvedValue({
|
|
290
|
+
success: true,
|
|
291
|
+
data: updatedCampaign
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
295
|
+
|
|
296
|
+
await act(async () => {
|
|
297
|
+
await result.current.updateCampaign('campaign-1', { name: 'Updated' });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(result.current.activeCampaign?.name).toBe('Other Campaign');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should set error on failure', async () => {
|
|
304
|
+
mockCampaignAPI.updateCampaign.mockResolvedValue({
|
|
305
|
+
success: false,
|
|
306
|
+
error: 'Update failed'
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
310
|
+
|
|
311
|
+
await act(async () => {
|
|
312
|
+
await result.current.updateCampaign('campaign-1', { name: 'Test' });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(result.current.error).toBe('Update failed');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should use default error message when no error provided', async () => {
|
|
319
|
+
mockCampaignAPI.updateCampaign.mockResolvedValue({
|
|
320
|
+
success: false
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
324
|
+
|
|
325
|
+
await act(async () => {
|
|
326
|
+
await result.current.updateCampaign('campaign-1', { name: 'Test' });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
expect(result.current.error).toBe('Failed to update campaign');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('deleteCampaign', () => {
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
const store = useCampaignStore.getState();
|
|
336
|
+
store.campaigns = [
|
|
337
|
+
createMockCampaign({ id: 'campaign-1', name: 'Campaign 1' }),
|
|
338
|
+
createMockCampaign({ id: 'campaign-2', name: 'Campaign 2' })
|
|
339
|
+
];
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should remove campaign from store on success', async () => {
|
|
343
|
+
mockCampaignAPI.deleteCampaign.mockResolvedValue({
|
|
344
|
+
success: true
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
348
|
+
|
|
349
|
+
await act(async () => {
|
|
350
|
+
await result.current.deleteCampaign('campaign-1');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(result.current.campaigns.find(c => c.id === 'campaign-1')).toBeUndefined();
|
|
354
|
+
expect(result.current.campaigns).toHaveLength(1);
|
|
355
|
+
expect(result.current.loading).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should clear activeCampaign if it matches deleted campaign', async () => {
|
|
359
|
+
const store = useCampaignStore.getState();
|
|
360
|
+
store.activeCampaign = createMockCampaign({ id: 'campaign-1' });
|
|
361
|
+
|
|
362
|
+
mockCampaignAPI.deleteCampaign.mockResolvedValue({
|
|
363
|
+
success: true
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
await result.current.deleteCampaign('campaign-1');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(result.current.activeCampaign).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('should not clear activeCampaign if it does not match deleted campaign', async () => {
|
|
376
|
+
const store = useCampaignStore.getState();
|
|
377
|
+
store.activeCampaign = createMockCampaign({ id: 'campaign-2' });
|
|
378
|
+
|
|
379
|
+
mockCampaignAPI.deleteCampaign.mockResolvedValue({
|
|
380
|
+
success: true
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
384
|
+
|
|
385
|
+
await act(async () => {
|
|
386
|
+
await result.current.deleteCampaign('campaign-1');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(result.current.activeCampaign?.id).toBe('campaign-2');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should set error on failure', async () => {
|
|
393
|
+
mockCampaignAPI.deleteCampaign.mockResolvedValue({
|
|
394
|
+
success: false,
|
|
395
|
+
error: 'Delete failed'
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
399
|
+
|
|
400
|
+
await act(async () => {
|
|
401
|
+
await result.current.deleteCampaign('campaign-1');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(result.current.error).toBe('Delete failed');
|
|
405
|
+
// Campaign should still be in the list
|
|
406
|
+
expect(result.current.campaigns).toHaveLength(2);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should use default error message when no error provided', async () => {
|
|
410
|
+
mockCampaignAPI.deleteCampaign.mockResolvedValue({
|
|
411
|
+
success: false
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
415
|
+
|
|
416
|
+
await act(async () => {
|
|
417
|
+
await result.current.deleteCampaign('campaign-1');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(result.current.error).toBe('Failed to delete campaign');
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('setActiveCampaign', () => {
|
|
425
|
+
it('should set the active campaign', () => {
|
|
426
|
+
const campaign = createMockCampaign({ id: 'campaign-1', name: 'Active Campaign' });
|
|
427
|
+
|
|
428
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
429
|
+
|
|
430
|
+
act(() => {
|
|
431
|
+
result.current.setActiveCampaign(campaign);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(result.current.activeCampaign).toEqual(campaign);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('should clear the active campaign when set to null', () => {
|
|
438
|
+
const store = useCampaignStore.getState();
|
|
439
|
+
store.activeCampaign = createMockCampaign({ id: 'campaign-1' });
|
|
440
|
+
|
|
441
|
+
const { result } = renderHook(() => useCampaignStore());
|
|
442
|
+
|
|
443
|
+
act(() => {
|
|
444
|
+
result.current.setActiveCampaign(null);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
expect(result.current.activeCampaign).toBeNull();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { renderHook } from '@testing-library/react';
|
|
10
|
+
import { useWorkflowValidation } from '../../src/hooks/useWorkflowValidation';
|
|
11
|
+
import { CampaignWorkflow } from '../../src/types';
|
|
12
|
+
|
|
13
|
+
describe('useWorkflowValidation', () => {
|
|
14
|
+
const createValidWorkflow = (): CampaignWorkflow => ({
|
|
15
|
+
id: 'workflow-1',
|
|
16
|
+
nodes: [
|
|
17
|
+
{
|
|
18
|
+
id: 'email-1',
|
|
19
|
+
type: 'email',
|
|
20
|
+
position: { x: 0, y: 0 },
|
|
21
|
+
data: { templateId: 'template-1' }
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'email-2',
|
|
25
|
+
type: 'email',
|
|
26
|
+
position: { x: 200, y: 0 },
|
|
27
|
+
data: { templateId: 'template-2' }
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
edges: [
|
|
31
|
+
{
|
|
32
|
+
id: 'edge-1',
|
|
33
|
+
source: 'email-1',
|
|
34
|
+
target: 'email-2'
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return valid result for a valid workflow', () => {
|
|
40
|
+
const workflow = createValidWorkflow();
|
|
41
|
+
const { result } = renderHook(() => useWorkflowValidation(workflow));
|
|
42
|
+
|
|
43
|
+
expect(result.current.valid).toBe(true);
|
|
44
|
+
expect(result.current.errors).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should return errors for workflow without nodes', () => {
|
|
48
|
+
const workflow: CampaignWorkflow = {
|
|
49
|
+
id: 'workflow-1',
|
|
50
|
+
nodes: [],
|
|
51
|
+
edges: []
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const { result } = renderHook(() => useWorkflowValidation(workflow));
|
|
55
|
+
|
|
56
|
+
expect(result.current.valid).toBe(false);
|
|
57
|
+
expect(result.current.errors.length).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should memoize result when workflow does not change', () => {
|
|
61
|
+
const workflow = createValidWorkflow();
|
|
62
|
+
const { result, rerender } = renderHook(() => useWorkflowValidation(workflow));
|
|
63
|
+
|
|
64
|
+
const firstResult = result.current;
|
|
65
|
+
rerender();
|
|
66
|
+
const secondResult = result.current;
|
|
67
|
+
|
|
68
|
+
expect(firstResult).toBe(secondResult);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should recompute when workflow changes', () => {
|
|
72
|
+
let workflow = createValidWorkflow();
|
|
73
|
+
const { result, rerender } = renderHook(
|
|
74
|
+
({ wf }) => useWorkflowValidation(wf),
|
|
75
|
+
{ initialProps: { wf: workflow } }
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const firstResult = result.current;
|
|
79
|
+
expect(firstResult.valid).toBe(true);
|
|
80
|
+
|
|
81
|
+
// Change workflow to invalid one
|
|
82
|
+
const invalidWorkflow: CampaignWorkflow = {
|
|
83
|
+
id: 'workflow-2',
|
|
84
|
+
nodes: [],
|
|
85
|
+
edges: []
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
rerender({ wf: invalidWorkflow });
|
|
89
|
+
const secondResult = result.current;
|
|
90
|
+
|
|
91
|
+
expect(secondResult.valid).toBe(false);
|
|
92
|
+
expect(firstResult).not.toBe(secondResult);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return errors for empty workflow', () => {
|
|
96
|
+
const workflow: CampaignWorkflow = {
|
|
97
|
+
id: 'workflow-1',
|
|
98
|
+
nodes: [],
|
|
99
|
+
edges: []
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const { result } = renderHook(() => useWorkflowValidation(workflow));
|
|
103
|
+
|
|
104
|
+
expect(result.current.valid).toBe(false);
|
|
105
|
+
expect(result.current.errors.length).toBeGreaterThan(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should validate workflow with multiple email nodes', () => {
|
|
109
|
+
const workflow: CampaignWorkflow = {
|
|
110
|
+
id: 'workflow-1',
|
|
111
|
+
nodes: [
|
|
112
|
+
{
|
|
113
|
+
id: 'email-1',
|
|
114
|
+
type: 'email',
|
|
115
|
+
position: { x: 0, y: 0 },
|
|
116
|
+
data: { templateId: 'template-1' }
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: 'delay-1',
|
|
120
|
+
type: 'delay',
|
|
121
|
+
position: { x: 200, y: 0 },
|
|
122
|
+
data: { delayAmount: 1, delayUnit: 'days' }
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'email-2',
|
|
126
|
+
type: 'email',
|
|
127
|
+
position: { x: 400, y: 0 },
|
|
128
|
+
data: { templateId: 'template-2' }
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
edges: [
|
|
132
|
+
{ id: 'edge-1', source: 'email-1', target: 'delay-1' },
|
|
133
|
+
{ id: 'edge-2', source: 'delay-1', target: 'email-2' }
|
|
134
|
+
]
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const { result } = renderHook(() => useWorkflowValidation(workflow));
|
|
138
|
+
|
|
139
|
+
expect(result.current.valid).toBe(true);
|
|
140
|
+
expect(result.current.errors).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should handle workflow with condition node', () => {
|
|
144
|
+
const workflow: CampaignWorkflow = {
|
|
145
|
+
id: 'workflow-1',
|
|
146
|
+
nodes: [
|
|
147
|
+
{
|
|
148
|
+
id: 'email-1',
|
|
149
|
+
type: 'email',
|
|
150
|
+
position: { x: 0, y: 0 },
|
|
151
|
+
data: { templateId: 'template-1' }
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'condition-1',
|
|
155
|
+
type: 'condition',
|
|
156
|
+
position: { x: 200, y: 0 },
|
|
157
|
+
data: {
|
|
158
|
+
condition: {
|
|
159
|
+
field: 'email_opened',
|
|
160
|
+
operator: 'equals',
|
|
161
|
+
value: true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
edges: [
|
|
167
|
+
{ id: 'edge-1', source: 'email-1', target: 'condition-1' }
|
|
168
|
+
]
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const { result } = renderHook(() => useWorkflowValidation(workflow));
|
|
172
|
+
|
|
173
|
+
// Should be valid with nodes and edges
|
|
174
|
+
expect(result.current.valid).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2025 Bernier LLC
|
|
3
|
+
|
|
4
|
+
This file is licensed to the client under a limited-use license.
|
|
5
|
+
The client may use and modify this code *only within the scope of the project it was delivered for*.
|
|
6
|
+
Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatPercentage, formatNumber, formatMetricRate } from '../../src/utils/formatting';
|
|
10
|
+
|
|
11
|
+
describe('Formatting Utils', () => {
|
|
12
|
+
describe('formatPercentage', () => {
|
|
13
|
+
it('should format percentage with 2 decimals by default', () => {
|
|
14
|
+
expect(formatPercentage(45.678)).toBe('45.68%');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should format percentage with custom decimals', () => {
|
|
18
|
+
expect(formatPercentage(45.678, 1)).toBe('45.7%');
|
|
19
|
+
expect(formatPercentage(45.678, 0)).toBe('46%');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('formatNumber', () => {
|
|
24
|
+
it('should format number with thousands separator', () => {
|
|
25
|
+
expect(formatNumber(1000)).toMatch(/1[,\s]000/);
|
|
26
|
+
expect(formatNumber(1234567)).toMatch(/1[,\s]234[,\s]567/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should format small numbers without separator', () => {
|
|
30
|
+
expect(formatNumber(100)).toBe('100');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('formatMetricRate', () => {
|
|
35
|
+
it('should calculate percentage rate', () => {
|
|
36
|
+
expect(formatMetricRate(45, 100)).toBe(45);
|
|
37
|
+
expect(formatMetricRate(1, 4)).toBe(25);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should handle zero denominator', () => {
|
|
41
|
+
expect(formatMetricRate(10, 0)).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle zero numerator', () => {
|
|
45
|
+
expect(formatMetricRate(0, 100)).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|