@startsimpli/api 0.1.0 → 0.2.2
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/index.d.mts +2235 -0
- package/dist/index.d.ts +2235 -0
- package/dist/index.js +2092 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2010 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -14
- package/src/__tests__/drf-transforms.test.ts +99 -0
- package/src/__tests__/entity-query-builder.test.ts +197 -0
- package/src/__tests__/funnels-api.test.ts +237 -0
- package/src/__tests__/jwt-refresh.test.ts +11 -14
- package/src/__tests__/url-builder.test.ts +27 -1
- package/src/__tests__/validate-response.test.ts +68 -0
- package/src/constants/endpoints.ts +13 -0
- package/src/constants/options.ts +83 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-server-detail.ts +71 -0
- package/src/hooks/use-server-list.ts +169 -0
- package/src/index.ts +36 -2
- package/src/lib/api-client.ts +10 -19
- package/src/lib/cache-manager.ts +103 -0
- package/src/lib/cache-store.ts +113 -0
- package/src/lib/error-handler.ts +1 -1
- package/src/lib/fetch-wrapper.ts +38 -26
- package/src/lib/funnels-api.ts +221 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/with-rate-limit.ts +54 -0
- package/src/utils/drf-transforms.ts +64 -0
- package/src/utils/entity-query-builder.ts +174 -0
- package/src/utils/index.ts +11 -1
- package/src/utils/url-builder.ts +27 -0
- package/src/utils/validate-response.ts +39 -0
- package/src/lib/messages-api.ts.backup +0 -273
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { EntityQueryBuilder } from '../utils/entity-query-builder';
|
|
3
|
+
|
|
4
|
+
describe('EntityQueryBuilder', () => {
|
|
5
|
+
it('builds empty params by default', () => {
|
|
6
|
+
const builder = new EntityQueryBuilder();
|
|
7
|
+
expect(builder.build()).toEqual({});
|
|
8
|
+
expect(builder.toQueryString()).toBe('');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('withTags', () => {
|
|
12
|
+
it('adds a single tag', () => {
|
|
13
|
+
const params = new EntityQueryBuilder()
|
|
14
|
+
.withTags('quality:tier_1')
|
|
15
|
+
.build();
|
|
16
|
+
expect(params.tags).toBe('quality:tier_1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('adds multiple tags at once', () => {
|
|
20
|
+
const params = new EntityQueryBuilder()
|
|
21
|
+
.withTags('quality:tier_1', 'status:prospect')
|
|
22
|
+
.build();
|
|
23
|
+
expect(params.tags).toBe('quality:tier_1,status:prospect');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('accumulates tags across multiple calls', () => {
|
|
27
|
+
const params = new EntityQueryBuilder()
|
|
28
|
+
.withTags('quality:tier_1')
|
|
29
|
+
.withTags('status:prospect')
|
|
30
|
+
.build();
|
|
31
|
+
expect(params.tags).toBe('quality:tier_1,status:prospect');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does nothing for empty args', () => {
|
|
35
|
+
const params = new EntityQueryBuilder().withTags().build();
|
|
36
|
+
expect(params.tags).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('withMetrics', () => {
|
|
41
|
+
it('adds metric filters', () => {
|
|
42
|
+
const params = new EntityQueryBuilder()
|
|
43
|
+
.withMetrics('financial:aum__gte:100000000')
|
|
44
|
+
.build();
|
|
45
|
+
expect(params.metrics).toBe('financial:aum__gte:100000000');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accumulates metrics', () => {
|
|
49
|
+
const params = new EntityQueryBuilder()
|
|
50
|
+
.withMetrics('financial:aum__gte:100000000')
|
|
51
|
+
.withMetrics('check_size:min__gte:1000000')
|
|
52
|
+
.build();
|
|
53
|
+
expect(params.metrics).toBe(
|
|
54
|
+
'financial:aum__gte:100000000,check_size:min__gte:1000000'
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('withProfiles', () => {
|
|
60
|
+
it('adds profile filters', () => {
|
|
61
|
+
const params = new EntityQueryBuilder()
|
|
62
|
+
.withProfiles('professional:linkedin')
|
|
63
|
+
.build();
|
|
64
|
+
expect(params.profiles).toBe('professional:linkedin');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('withAttributes', () => {
|
|
69
|
+
it('adds attribute filters', () => {
|
|
70
|
+
const params = new EntityQueryBuilder()
|
|
71
|
+
.withAttributes('demographic:location:san_francisco')
|
|
72
|
+
.build();
|
|
73
|
+
expect(params.attributes).toBe('demographic:location:san_francisco');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('paginate', () => {
|
|
78
|
+
it('sets page and page_size', () => {
|
|
79
|
+
const params = new EntityQueryBuilder()
|
|
80
|
+
.paginate(2, 50)
|
|
81
|
+
.build();
|
|
82
|
+
expect(params.page).toBe('2');
|
|
83
|
+
expect(params.page_size).toBe('50');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('search', () => {
|
|
88
|
+
it('sets search param', () => {
|
|
89
|
+
const params = new EntityQueryBuilder()
|
|
90
|
+
.search('acme corp')
|
|
91
|
+
.build();
|
|
92
|
+
expect(params.search).toBe('acme corp');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('trims whitespace', () => {
|
|
96
|
+
const params = new EntityQueryBuilder()
|
|
97
|
+
.search(' acme ')
|
|
98
|
+
.build();
|
|
99
|
+
expect(params.search).toBe('acme');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('ignores empty search', () => {
|
|
103
|
+
const params = new EntityQueryBuilder()
|
|
104
|
+
.search(' ')
|
|
105
|
+
.build();
|
|
106
|
+
expect(params.search).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('sort', () => {
|
|
111
|
+
it('defaults to ascending', () => {
|
|
112
|
+
const params = new EntityQueryBuilder()
|
|
113
|
+
.sort('name')
|
|
114
|
+
.build();
|
|
115
|
+
expect(params.ordering).toBe('name');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('supports descending', () => {
|
|
119
|
+
const params = new EntityQueryBuilder()
|
|
120
|
+
.sort('created_at', 'desc')
|
|
121
|
+
.build();
|
|
122
|
+
expect(params.ordering).toBe('-created_at');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('withDateRange', () => {
|
|
127
|
+
it('sets from and to dates', () => {
|
|
128
|
+
const params = new EntityQueryBuilder()
|
|
129
|
+
.withDateRange('created', new Date('2024-01-01'), new Date('2024-12-31'))
|
|
130
|
+
.build();
|
|
131
|
+
expect(params.created_after).toBe('2024-01-01');
|
|
132
|
+
expect(params.created_before).toBe('2024-12-31');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles only from date', () => {
|
|
136
|
+
const params = new EntityQueryBuilder()
|
|
137
|
+
.withDateRange('created', new Date('2024-06-01'))
|
|
138
|
+
.build();
|
|
139
|
+
expect(params.created_after).toBe('2024-06-01');
|
|
140
|
+
expect(params.created_before).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('handles only to date', () => {
|
|
144
|
+
const params = new EntityQueryBuilder()
|
|
145
|
+
.withDateRange('created', undefined, new Date('2024-12-31'))
|
|
146
|
+
.build();
|
|
147
|
+
expect(params.created_after).toBeUndefined();
|
|
148
|
+
expect(params.created_before).toBe('2024-12-31');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('toQueryString', () => {
|
|
153
|
+
it('returns ?-prefixed query string', () => {
|
|
154
|
+
const qs = new EntityQueryBuilder()
|
|
155
|
+
.paginate(1, 25)
|
|
156
|
+
.search('test')
|
|
157
|
+
.toQueryString();
|
|
158
|
+
expect(qs).toMatch(/^\?/);
|
|
159
|
+
expect(qs).toContain('page=1');
|
|
160
|
+
expect(qs).toContain('page_size=25');
|
|
161
|
+
expect(qs).toContain('search=test');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('chaining', () => {
|
|
166
|
+
it('supports full complex query', () => {
|
|
167
|
+
const params = new EntityQueryBuilder()
|
|
168
|
+
.entityType('ORGANIZATION')
|
|
169
|
+
.withTags('quality:tier_1', 'status:prospect')
|
|
170
|
+
.withMetrics('financial:aum__gte:100000000')
|
|
171
|
+
.withProfiles('professional:linkedin')
|
|
172
|
+
.paginate(1, 25)
|
|
173
|
+
.search('venture')
|
|
174
|
+
.sort('name', 'asc')
|
|
175
|
+
.build();
|
|
176
|
+
|
|
177
|
+
expect(params.entity_type).toBe('ORGANIZATION');
|
|
178
|
+
expect(params.tags).toBe('quality:tier_1,status:prospect');
|
|
179
|
+
expect(params.metrics).toBe('financial:aum__gte:100000000');
|
|
180
|
+
expect(params.profiles).toBe('professional:linkedin');
|
|
181
|
+
expect(params.page).toBe('1');
|
|
182
|
+
expect(params.page_size).toBe('25');
|
|
183
|
+
expect(params.search).toBe('venture');
|
|
184
|
+
expect(params.ordering).toBe('name');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('clear', () => {
|
|
189
|
+
it('resets all parameters', () => {
|
|
190
|
+
const builder = new EntityQueryBuilder()
|
|
191
|
+
.withTags('quality:tier_1')
|
|
192
|
+
.paginate(1, 25);
|
|
193
|
+
builder.clear();
|
|
194
|
+
expect(builder.build()).toEqual({});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunnelsApi unit tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
6
|
+
import { FunnelsApi, isFunnelRunConflict, isFunnelValidationError } from '../lib/funnels-api';
|
|
7
|
+
import { ApiException } from '../lib/error-handler';
|
|
8
|
+
import type { ApiClient } from '../lib/api-client';
|
|
9
|
+
|
|
10
|
+
function makeClient(overrides: Partial<ApiClient> = {}): ApiClient {
|
|
11
|
+
return {
|
|
12
|
+
get: vi.fn(),
|
|
13
|
+
post: vi.fn(),
|
|
14
|
+
patch: vi.fn(),
|
|
15
|
+
delete: vi.fn(),
|
|
16
|
+
baseUrl: 'http://localhost:8000',
|
|
17
|
+
fetch: {} as any,
|
|
18
|
+
setTokenGetter: vi.fn(),
|
|
19
|
+
setUnauthorizedHandler: vi.fn(),
|
|
20
|
+
...overrides,
|
|
21
|
+
} as unknown as ApiClient;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mockFunnel = {
|
|
25
|
+
id: 'funnel-1',
|
|
26
|
+
name: 'Test Funnel',
|
|
27
|
+
description: 'A test funnel',
|
|
28
|
+
status: 'active' as const,
|
|
29
|
+
entityType: 'contact' as const,
|
|
30
|
+
tags: ['product:market-simpli'],
|
|
31
|
+
stages: [],
|
|
32
|
+
stageCount: 0,
|
|
33
|
+
totalRuns: 0,
|
|
34
|
+
lastRunAt: null,
|
|
35
|
+
createdBy: 'user-1',
|
|
36
|
+
createdByName: 'Test User',
|
|
37
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
38
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const mockRun = {
|
|
42
|
+
id: 'run-1',
|
|
43
|
+
funnel: 'funnel-1',
|
|
44
|
+
status: 'completed' as const,
|
|
45
|
+
totalEntities: 100,
|
|
46
|
+
processedEntities: 100,
|
|
47
|
+
resultsByStage: { 'stage-1': 50 },
|
|
48
|
+
startedAt: '2024-01-01T00:00:00Z',
|
|
49
|
+
completedAt: '2024-01-01T00:01:00Z',
|
|
50
|
+
errorMessage: null,
|
|
51
|
+
createdBy: 'user-1',
|
|
52
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mockPaginated = <T>(results: T[]) => ({
|
|
56
|
+
count: results.length,
|
|
57
|
+
next: null,
|
|
58
|
+
previous: null,
|
|
59
|
+
results,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('FunnelsApi', () => {
|
|
63
|
+
let client: ApiClient;
|
|
64
|
+
let api: FunnelsApi;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
client = makeClient();
|
|
68
|
+
api = new FunnelsApi(client);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('list', () => {
|
|
72
|
+
it('calls GET funnels with no params when no filters given', async () => {
|
|
73
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockFunnel]));
|
|
74
|
+
await api.list();
|
|
75
|
+
expect(client.get).toHaveBeenCalledWith('funnels');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('passes status filter as query param', async () => {
|
|
79
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockFunnel]));
|
|
80
|
+
await api.list({ status: 'active' });
|
|
81
|
+
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('status=active'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('passes multiple tags as repeated query params', async () => {
|
|
85
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockFunnel]));
|
|
86
|
+
await api.list({ tags: ['campaign:abc', 'product:market-simpli'] });
|
|
87
|
+
const endpoint = vi.mocked(client.get).mock.calls[0][0] as string;
|
|
88
|
+
expect(endpoint).toContain('tags=campaign%3Aabc');
|
|
89
|
+
expect(endpoint).toContain('tags=product%3Amarket-simpli');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('passes pagination params', async () => {
|
|
93
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
94
|
+
await api.list({ page: 2, pageSize: 25 });
|
|
95
|
+
const endpoint = vi.mocked(client.get).mock.calls[0][0] as string;
|
|
96
|
+
expect(endpoint).toContain('page=2');
|
|
97
|
+
expect(endpoint).toContain('pageSize=25');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('get', () => {
|
|
102
|
+
it('calls GET funnels/:id', async () => {
|
|
103
|
+
vi.mocked(client.get).mockResolvedValue(mockFunnel);
|
|
104
|
+
const result = await api.get('funnel-1');
|
|
105
|
+
expect(client.get).toHaveBeenCalledWith('funnels/funnel-1');
|
|
106
|
+
expect(result).toEqual(mockFunnel);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('create', () => {
|
|
111
|
+
it('calls POST funnels with data', async () => {
|
|
112
|
+
vi.mocked(client.post).mockResolvedValue(mockFunnel);
|
|
113
|
+
const input = { name: 'New Funnel', entityType: 'contact' as const, tags: ['campaign:abc'] };
|
|
114
|
+
await api.create(input);
|
|
115
|
+
expect(client.post).toHaveBeenCalledWith('funnels', input);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('update', () => {
|
|
120
|
+
it('calls PATCH funnels/:id with data', async () => {
|
|
121
|
+
vi.mocked(client.patch).mockResolvedValue(mockFunnel);
|
|
122
|
+
await api.update('funnel-1', { name: 'Renamed' });
|
|
123
|
+
expect(client.patch).toHaveBeenCalledWith('funnels/funnel-1', { name: 'Renamed' });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('delete', () => {
|
|
128
|
+
it('calls DELETE funnels/:id', async () => {
|
|
129
|
+
vi.mocked(client.delete).mockResolvedValue(undefined);
|
|
130
|
+
await api.delete('funnel-1');
|
|
131
|
+
expect(client.delete).toHaveBeenCalledWith('funnels/funnel-1');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('run', () => {
|
|
136
|
+
it('calls POST funnels/:id/run with empty body by default', async () => {
|
|
137
|
+
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
138
|
+
await api.run('funnel-1');
|
|
139
|
+
expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('passes entityIds in body when provided', async () => {
|
|
143
|
+
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
144
|
+
await api.run('funnel-1', { entityIds: ['e1', 'e2'] });
|
|
145
|
+
expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', { entityIds: ['e1', 'e2'] });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('getRuns', () => {
|
|
150
|
+
it('calls GET funnels/:id/runs', async () => {
|
|
151
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
|
|
152
|
+
await api.getRuns('funnel-1');
|
|
153
|
+
expect(client.get).toHaveBeenCalledWith('funnels/funnel-1/runs');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('passes page and pageSize filters', async () => {
|
|
157
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
158
|
+
await api.getRuns('funnel-1', { page: 2, pageSize: 10 });
|
|
159
|
+
const endpoint = vi.mocked(client.get).mock.calls[0][0] as string;
|
|
160
|
+
expect(endpoint).toContain('page=2');
|
|
161
|
+
expect(endpoint).toContain('pageSize=10');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('getRun', () => {
|
|
166
|
+
it('calls GET funnel-runs/:runId (global endpoint, funnelId ignored)', async () => {
|
|
167
|
+
vi.mocked(client.get).mockResolvedValue(mockRun);
|
|
168
|
+
await api.getRun('funnel-1', 'run-1');
|
|
169
|
+
expect(client.get).toHaveBeenCalledWith('funnel-runs/run-1');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('preview', () => {
|
|
174
|
+
it('throws Not implemented (endpoint missing in Django)', async () => {
|
|
175
|
+
// BEAD: fund-your-startup-rgi4 - funnels/{id}/preview does not exist in Django
|
|
176
|
+
await expect(api.preview('funnel-1', [])).rejects.toThrow('Not implemented');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('cancelRun', () => {
|
|
181
|
+
it('calls POST funnel-runs/:runId/cancel', async () => {
|
|
182
|
+
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
183
|
+
await api.cancelRun('run-1');
|
|
184
|
+
expect(client.post).toHaveBeenCalledWith('funnel-runs/run-1/cancel', {});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('listRunsGlobal', () => {
|
|
189
|
+
it('calls GET funnel-runs with no params when no filters given', async () => {
|
|
190
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
|
|
191
|
+
await api.listRunsGlobal();
|
|
192
|
+
expect(client.get).toHaveBeenCalledWith('funnel-runs');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('passes funnel filter as query param', async () => {
|
|
196
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
197
|
+
await api.listRunsGlobal({ funnel: 'funnel-1' });
|
|
198
|
+
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('funnel=funnel-1'));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('listTemplates', () => {
|
|
203
|
+
it('calls GET funnels/templates', async () => {
|
|
204
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
205
|
+
await api.listTemplates();
|
|
206
|
+
expect(client.get).toHaveBeenCalledWith('funnels/templates');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('passes category filter', async () => {
|
|
210
|
+
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
211
|
+
await api.listTemplates({ category: 'vc' });
|
|
212
|
+
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('category=vc'));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('error helpers', () => {
|
|
218
|
+
it('isFunnelRunConflict returns true for 409 ApiException', () => {
|
|
219
|
+
const err = new ApiException('Funnel already running', { status: 409 });
|
|
220
|
+
expect(isFunnelRunConflict(err)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('isFunnelRunConflict returns false for non-409 errors', () => {
|
|
224
|
+
expect(isFunnelRunConflict(new ApiException('Not found', { status: 404 }))).toBe(false);
|
|
225
|
+
expect(isFunnelRunConflict(new Error('something'))).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('isFunnelValidationError returns true for 400 with errors', () => {
|
|
229
|
+
const err = new ApiException('Validation failed', { status: 400, errors: { entityType: ['Required'] } });
|
|
230
|
+
expect(isFunnelValidationError(err)).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('isFunnelValidationError returns false for 400 without errors field', () => {
|
|
234
|
+
const err = new ApiException('Bad request', { status: 400 });
|
|
235
|
+
expect(isFunnelValidationError(err)).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -98,24 +98,21 @@ describe('JWT Refresh Flow', () => {
|
|
|
98
98
|
onTokenRefresh: refreshCallback,
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
status: 401,
|
|
104
|
-
ok: false,
|
|
105
|
-
});
|
|
101
|
+
const mock401 = { status: 401, ok: false, headers: new Headers(), text: async () => '' };
|
|
102
|
+
const mock200 = { status: 200, ok: true, json: async () => ({ data: 'success' }) };
|
|
106
103
|
|
|
107
|
-
//
|
|
104
|
+
// Both initial requests return 401, then both retries succeed
|
|
105
|
+
mockFetch
|
|
106
|
+
.mockResolvedValueOnce(mock401) // request 1 initial
|
|
107
|
+
.mockResolvedValueOnce(mock401) // request 2 initial
|
|
108
|
+
.mockResolvedValueOnce(mock200) // request 1 retry
|
|
109
|
+
.mockResolvedValueOnce(mock200); // request 2 retry
|
|
110
|
+
|
|
111
|
+
// Refresh returns new token (with delay to allow both 401s to queue)
|
|
108
112
|
refreshCallback.mockImplementation(
|
|
109
|
-
() => new Promise((resolve) => setTimeout(() => resolve('new-token'),
|
|
113
|
+
() => new Promise((resolve) => setTimeout(() => resolve('new-token'), 50))
|
|
110
114
|
);
|
|
111
115
|
|
|
112
|
-
// Retry succeeds
|
|
113
|
-
mockFetch.mockResolvedValueOnce({
|
|
114
|
-
status: 200,
|
|
115
|
-
ok: true,
|
|
116
|
-
json: async () => ({ data: 'success' }),
|
|
117
|
-
});
|
|
118
|
-
|
|
119
116
|
// Make 2 concurrent requests
|
|
120
117
|
const promise1 = wrapper.get('/api/v1/messages/');
|
|
121
118
|
const promise2 = wrapper.get('/api/v1/contacts/');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { describe, it, expect } from 'vitest';
|
|
6
|
-
import { buildUrl, buildQueryString, normalizeId } from '../utils/url-builder';
|
|
6
|
+
import { buildUrl, buildQueryString, normalizeId, resolveApiUrl } from '../utils/url-builder';
|
|
7
7
|
|
|
8
8
|
describe('buildUrl', () => {
|
|
9
9
|
it('should build URL with base and endpoint', () => {
|
|
@@ -105,6 +105,32 @@ describe('buildQueryString', () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
describe('resolveApiUrl', () => {
|
|
109
|
+
it('passes through absolute input URLs unchanged', () => {
|
|
110
|
+
expect(resolveApiUrl('https://api.example.com/v1/contacts/')).toBe('https://api.example.com/v1/contacts/');
|
|
111
|
+
expect(resolveApiUrl('https://api.example.com/v1/contacts/', 'http://localhost:8000')).toBe('https://api.example.com/v1/contacts/');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns path as-is when baseUrl is empty', () => {
|
|
115
|
+
expect(resolveApiUrl('/api/v1/contacts/')).toBe('/api/v1/contacts/');
|
|
116
|
+
expect(resolveApiUrl('/api/v1/contacts/', '')).toBe('/api/v1/contacts/');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('normalizes paths without leading slash', () => {
|
|
120
|
+
expect(resolveApiUrl('api/v1/contacts/')).toBe('/api/v1/contacts/');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('joins with absolute baseUrl via URL constructor', () => {
|
|
124
|
+
expect(resolveApiUrl('/api/v1/contacts/', 'http://localhost:8000')).toBe('http://localhost:8000/api/v1/contacts/');
|
|
125
|
+
expect(resolveApiUrl('/api/v1/contacts/', 'https://api.example.com')).toBe('https://api.example.com/api/v1/contacts/');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('joins with relative baseUrl', () => {
|
|
129
|
+
expect(resolveApiUrl('/api/v1/contacts/', '/proxy')).toBe('/proxy/api/v1/contacts/');
|
|
130
|
+
expect(resolveApiUrl('/api/v1/contacts/', '/proxy/')).toBe('/proxy/api/v1/contacts/');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
108
134
|
describe('normalizeId', () => {
|
|
109
135
|
it('should return number ID as-is', () => {
|
|
110
136
|
expect(normalizeId(123)).toBe(123);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { validateApiResponse } from '../utils/validate-response';
|
|
4
|
+
|
|
5
|
+
describe('validateApiResponse', () => {
|
|
6
|
+
const schema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
name: z.string(),
|
|
9
|
+
email: z.string().email(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns parsed data when schema matches', () => {
|
|
13
|
+
const data = { id: '1', name: 'Alice', email: 'alice@example.com' };
|
|
14
|
+
const result = validateApiResponse(data, schema);
|
|
15
|
+
expect(result).toEqual(data);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns raw data on mismatch (does not throw)', () => {
|
|
19
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
20
|
+
|
|
21
|
+
const data = { id: '1', name: 'Alice', email: 'not-an-email', extra: true };
|
|
22
|
+
const result = validateApiResponse(data, schema);
|
|
23
|
+
|
|
24
|
+
// Returns data as-is (cast, not validated)
|
|
25
|
+
expect(result).toBe(data);
|
|
26
|
+
expect((result as any).extra).toBe(true);
|
|
27
|
+
|
|
28
|
+
// Warning was logged
|
|
29
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
30
|
+
expect(warnSpy.mock.calls[0][0]).toContain('Schema mismatch');
|
|
31
|
+
|
|
32
|
+
warnSpy.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('includes context label in warning', () => {
|
|
36
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
37
|
+
|
|
38
|
+
validateApiResponse({ id: 123 }, schema, 'contacts.list');
|
|
39
|
+
|
|
40
|
+
expect(warnSpy.mock.calls[0][0]).toContain('[contacts.list]');
|
|
41
|
+
|
|
42
|
+
warnSpy.mockRestore();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('strips extra fields when schema matches', () => {
|
|
46
|
+
const strictSchema = z.object({ id: z.string() }).strict();
|
|
47
|
+
const data = { id: '1', extra: 'field' };
|
|
48
|
+
|
|
49
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
50
|
+
const result = validateApiResponse(data, strictSchema);
|
|
51
|
+
|
|
52
|
+
// strict schema will fail — returns raw data
|
|
53
|
+
expect(result).toBe(data);
|
|
54
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
55
|
+
|
|
56
|
+
warnSpy.mockRestore();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles completely wrong data shape', () => {
|
|
60
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
61
|
+
|
|
62
|
+
const result = validateApiResponse('just a string', schema);
|
|
63
|
+
expect(result).toBe('just a string');
|
|
64
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
65
|
+
|
|
66
|
+
warnSpy.mockRestore();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -36,4 +36,17 @@ export const ENDPOINTS = {
|
|
|
36
36
|
MESSAGES: 'messages',
|
|
37
37
|
MESSAGE: (id: string) => `messages/${id}`,
|
|
38
38
|
MESSAGE_SEND: (id: string) => `messages/${id}/send`,
|
|
39
|
+
|
|
40
|
+
// Funnels
|
|
41
|
+
FUNNELS: 'funnels',
|
|
42
|
+
FUNNEL: (id: string) => `funnels/${id}`,
|
|
43
|
+
FUNNEL_RUN: (id: string) => `funnels/${id}/run`,
|
|
44
|
+
FUNNEL_RUNS: (id: string) => `funnels/${id}/runs`,
|
|
45
|
+
FUNNEL_RUN_ITEM: (funnelId: string, runId: string) => `funnels/${funnelId}/runs/${runId}`,
|
|
46
|
+
FUNNEL_PREVIEW: (id: string) => `funnels/${id}/preview`,
|
|
47
|
+
FUNNEL_RESULTS: (id: string) => `funnels/${id}/results`,
|
|
48
|
+
FUNNEL_TEMPLATES: 'funnels/templates',
|
|
49
|
+
FUNNEL_RUN_BY_ID: (runId: string) => `funnel-runs/${runId}`,
|
|
50
|
+
FUNNEL_RUN_CANCEL: (runId: string) => `funnel-runs/${runId}/cancel`,
|
|
51
|
+
FUNNEL_RUNS_GLOBAL: 'funnel-runs',
|
|
39
52
|
} as const;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared CRM option constants for dropdown fields.
|
|
3
|
+
*
|
|
4
|
+
* Used across market-simpli and raise-simpli for company/contact field dropdowns.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SelectOption<T extends string = string> {
|
|
8
|
+
value: T
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const COMPANY_SIZE_OPTIONS = [
|
|
13
|
+
{ value: 'startup', label: 'Startup (1-10)' },
|
|
14
|
+
{ value: 'small', label: 'Small (11-50)' },
|
|
15
|
+
{ value: 'smb', label: 'SMB (51-200)' },
|
|
16
|
+
{ value: 'mid_market', label: 'Mid-Market (201-1000)' },
|
|
17
|
+
{ value: 'enterprise', label: 'Enterprise (1000+)' },
|
|
18
|
+
] as const satisfies SelectOption[]
|
|
19
|
+
|
|
20
|
+
export const LIFECYCLE_STAGE_OPTIONS = [
|
|
21
|
+
{ value: 'subscriber', label: 'Subscriber' },
|
|
22
|
+
{ value: 'lead', label: 'Lead' },
|
|
23
|
+
{ value: 'mql', label: 'MQL' },
|
|
24
|
+
{ value: 'sql', label: 'SQL' },
|
|
25
|
+
{ value: 'opportunity', label: 'Opportunity' },
|
|
26
|
+
{ value: 'customer', label: 'Customer' },
|
|
27
|
+
{ value: 'evangelist', label: 'Evangelist' },
|
|
28
|
+
] as const satisfies SelectOption[]
|
|
29
|
+
|
|
30
|
+
export const REVENUE_RANGE_OPTIONS = [
|
|
31
|
+
{ value: 'pre_revenue', label: 'Pre-revenue' },
|
|
32
|
+
{ value: '0_1m', label: '$0-$1M' },
|
|
33
|
+
{ value: '1m_10m', label: '$1M-$10M' },
|
|
34
|
+
{ value: '10m_50m', label: '$10M-$50M' },
|
|
35
|
+
{ value: '50m_plus', label: '$50M+' },
|
|
36
|
+
] as const satisfies SelectOption[]
|
|
37
|
+
|
|
38
|
+
export const ACTIVITY_TYPE_OPTIONS = [
|
|
39
|
+
{ value: 'call', label: 'Call' },
|
|
40
|
+
{ value: 'email', label: 'Email' },
|
|
41
|
+
{ value: 'meeting', label: 'Meeting' },
|
|
42
|
+
{ value: 'demo', label: 'Demo' },
|
|
43
|
+
{ value: 'note', label: 'Note' },
|
|
44
|
+
{ value: 'task', label: 'Task' },
|
|
45
|
+
] as const satisfies SelectOption[]
|
|
46
|
+
|
|
47
|
+
export const ACTIVITY_OUTCOME_OPTIONS = [
|
|
48
|
+
{ value: 'scheduled_demo', label: 'Scheduled Demo' },
|
|
49
|
+
{ value: 'scheduled_meeting', label: 'Scheduled Meeting' },
|
|
50
|
+
{ value: 'callback_later', label: 'Callback Later' },
|
|
51
|
+
{ value: 'left_voicemail', label: 'Left Voicemail' },
|
|
52
|
+
{ value: 'no_answer', label: 'No Answer' },
|
|
53
|
+
{ value: 'not_interested', label: 'Not Interested' },
|
|
54
|
+
{ value: 'successful', label: 'Successful' },
|
|
55
|
+
{ value: 'unsuccessful', label: 'Unsuccessful' },
|
|
56
|
+
] as const satisfies SelectOption[]
|
|
57
|
+
|
|
58
|
+
export const LOSS_REASON_OPTIONS = [
|
|
59
|
+
{ value: 'price', label: 'Price / Budget' },
|
|
60
|
+
{ value: 'timing', label: 'Bad Timing' },
|
|
61
|
+
{ value: 'competitor', label: 'Chose Competitor' },
|
|
62
|
+
{ value: 'no_need', label: 'No Need' },
|
|
63
|
+
{ value: 'no_response', label: 'No Response' },
|
|
64
|
+
{ value: 'other', label: 'Other' },
|
|
65
|
+
] as const satisfies SelectOption[]
|
|
66
|
+
|
|
67
|
+
export const DEAL_STAGE_OPTIONS = [
|
|
68
|
+
{ value: 'qualification', label: 'Qualification' },
|
|
69
|
+
{ value: 'discovery', label: 'Discovery' },
|
|
70
|
+
{ value: 'demo', label: 'Demo' },
|
|
71
|
+
{ value: 'proposal', label: 'Proposal' },
|
|
72
|
+
{ value: 'negotiation', label: 'Negotiation' },
|
|
73
|
+
{ value: 'closed_won', label: 'Closed Won' },
|
|
74
|
+
{ value: 'closed_lost', label: 'Closed Lost' },
|
|
75
|
+
] as const satisfies SelectOption[]
|
|
76
|
+
|
|
77
|
+
export type CompanySize = (typeof COMPANY_SIZE_OPTIONS)[number]['value']
|
|
78
|
+
export type LifecycleStage = (typeof LIFECYCLE_STAGE_OPTIONS)[number]['value']
|
|
79
|
+
export type RevenueRange = (typeof REVENUE_RANGE_OPTIONS)[number]['value']
|
|
80
|
+
export type ActivityType = (typeof ACTIVITY_TYPE_OPTIONS)[number]['value']
|
|
81
|
+
export type ActivityOutcome = (typeof ACTIVITY_OUTCOME_OPTIONS)[number]['value']
|
|
82
|
+
export type LossReason = (typeof LOSS_REASON_OPTIONS)[number]['value']
|
|
83
|
+
export type DealStage = (typeof DEAL_STAGE_OPTIONS)[number]['value']
|