@startsimpli/api 0.5.8 → 0.5.11
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/package.json +12 -13
- package/src/__tests__/funnels-api.test.ts +16 -15
- package/src/__tests__/jwt-refresh.test.ts +27 -0
- package/src/lib/fetch-wrapper.ts +30 -16
- package/src/types/error.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/api",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"description": "Type-safe Django REST API client for StartSimpli apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -23,14 +23,13 @@
|
|
|
23
23
|
"clean": "rm -rf dist"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"zod": "^3.22.4"
|
|
26
|
+
"isomorphic-dompurify": "^3.10.0",
|
|
27
|
+
"zod": "^4.3.6"
|
|
29
28
|
},
|
|
30
29
|
"peerDependencies": {
|
|
30
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
31
31
|
"next": ">=14.0.0",
|
|
32
|
-
"react": ">=18.0.0"
|
|
33
|
-
"@tanstack/react-query": ">=5.0.0"
|
|
32
|
+
"react": ">=18.0.0"
|
|
34
33
|
},
|
|
35
34
|
"peerDependenciesMeta": {
|
|
36
35
|
"next": {
|
|
@@ -44,13 +43,13 @@
|
|
|
44
43
|
}
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
|
-
"@tanstack/react-query": "^5.
|
|
48
|
-
"@types/node": "^20.
|
|
49
|
-
"@types/react": "^
|
|
50
|
-
"next": "^15.5.
|
|
51
|
-
"react": "^
|
|
46
|
+
"@tanstack/react-query": "^5.99.2",
|
|
47
|
+
"@types/node": "^20.19.39",
|
|
48
|
+
"@types/react": "^19.2.14",
|
|
49
|
+
"next": "^15.5.15",
|
|
50
|
+
"react": "^19.2.5",
|
|
52
51
|
"tsup": "^8.5.1",
|
|
53
|
-
"typescript": "^
|
|
54
|
-
"vitest": "^1.
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vitest": "^4.1.5"
|
|
55
54
|
}
|
|
56
55
|
}
|
|
@@ -72,7 +72,7 @@ describe('FunnelsApi', () => {
|
|
|
72
72
|
it('calls GET funnels with no params when no filters given', async () => {
|
|
73
73
|
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockFunnel]));
|
|
74
74
|
await api.list();
|
|
75
|
-
expect(client.get).toHaveBeenCalledWith('funnels');
|
|
75
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnels');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it('passes status filter as query param', async () => {
|
|
@@ -102,7 +102,7 @@ describe('FunnelsApi', () => {
|
|
|
102
102
|
it('calls GET funnels/:id', async () => {
|
|
103
103
|
vi.mocked(client.get).mockResolvedValue(mockFunnel);
|
|
104
104
|
const result = await api.get('funnel-1');
|
|
105
|
-
expect(client.get).toHaveBeenCalledWith('funnels/funnel-1');
|
|
105
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnels/funnel-1');
|
|
106
106
|
expect(result).toEqual(mockFunnel);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
@@ -112,7 +112,7 @@ describe('FunnelsApi', () => {
|
|
|
112
112
|
vi.mocked(client.post).mockResolvedValue(mockFunnel);
|
|
113
113
|
const input = { name: 'New Funnel', entityType: 'contact' as const, tags: ['campaign:abc'] };
|
|
114
114
|
await api.create(input);
|
|
115
|
-
expect(client.post).toHaveBeenCalledWith('funnels', input);
|
|
115
|
+
expect(client.post).toHaveBeenCalledWith('api/v1/funnels', input);
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -120,7 +120,7 @@ describe('FunnelsApi', () => {
|
|
|
120
120
|
it('calls PATCH funnels/:id with data', async () => {
|
|
121
121
|
vi.mocked(client.patch).mockResolvedValue(mockFunnel);
|
|
122
122
|
await api.update('funnel-1', { name: 'Renamed' });
|
|
123
|
-
expect(client.patch).toHaveBeenCalledWith('funnels/funnel-1', { name: 'Renamed' });
|
|
123
|
+
expect(client.patch).toHaveBeenCalledWith('api/v1/funnels/funnel-1', { name: 'Renamed' });
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
|
|
@@ -128,7 +128,7 @@ describe('FunnelsApi', () => {
|
|
|
128
128
|
it('calls DELETE funnels/:id', async () => {
|
|
129
129
|
vi.mocked(client.delete).mockResolvedValue(undefined);
|
|
130
130
|
await api.delete('funnel-1');
|
|
131
|
-
expect(client.delete).toHaveBeenCalledWith('funnels/funnel-1');
|
|
131
|
+
expect(client.delete).toHaveBeenCalledWith('api/v1/funnels/funnel-1');
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
@@ -136,13 +136,13 @@ describe('FunnelsApi', () => {
|
|
|
136
136
|
it('calls POST funnels/:id/run with empty body by default', async () => {
|
|
137
137
|
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
138
138
|
await api.run('funnel-1');
|
|
139
|
-
expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', {});
|
|
139
|
+
expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/run', {});
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
it('passes entityIds in body when provided', async () => {
|
|
143
143
|
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
144
144
|
await api.run('funnel-1', { entityIds: ['e1', 'e2'] });
|
|
145
|
-
expect(client.post).toHaveBeenCalledWith('funnels/funnel-1/run', { entityIds: ['e1', 'e2'] });
|
|
145
|
+
expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/run', { entityIds: ['e1', 'e2'] });
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
148
|
|
|
@@ -150,7 +150,7 @@ describe('FunnelsApi', () => {
|
|
|
150
150
|
it('calls GET funnels/:id/runs', async () => {
|
|
151
151
|
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
|
|
152
152
|
await api.getRuns('funnel-1');
|
|
153
|
-
expect(client.get).toHaveBeenCalledWith('funnels/funnel-1/runs');
|
|
153
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnels/funnel-1/runs');
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
it('passes page and pageSize filters', async () => {
|
|
@@ -166,14 +166,15 @@ describe('FunnelsApi', () => {
|
|
|
166
166
|
it('calls GET funnel-runs/:runId (global endpoint, funnelId ignored)', async () => {
|
|
167
167
|
vi.mocked(client.get).mockResolvedValue(mockRun);
|
|
168
168
|
await api.getRun('funnel-1', 'run-1');
|
|
169
|
-
expect(client.get).toHaveBeenCalledWith('funnel-runs/run-1');
|
|
169
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnel-runs/run-1');
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
172
|
|
|
173
173
|
describe('preview', () => {
|
|
174
|
-
it('
|
|
175
|
-
|
|
176
|
-
await
|
|
174
|
+
it('calls POST funnels/:id/preview with stages', async () => {
|
|
175
|
+
vi.mocked(client.post).mockResolvedValue({} as never);
|
|
176
|
+
await api.preview('funnel-1', []);
|
|
177
|
+
expect(client.post).toHaveBeenCalledWith('api/v1/funnels/funnel-1/preview', { stages: [] });
|
|
177
178
|
});
|
|
178
179
|
});
|
|
179
180
|
|
|
@@ -181,7 +182,7 @@ describe('FunnelsApi', () => {
|
|
|
181
182
|
it('calls POST funnel-runs/:runId/cancel', async () => {
|
|
182
183
|
vi.mocked(client.post).mockResolvedValue(mockRun);
|
|
183
184
|
await api.cancelRun('run-1');
|
|
184
|
-
expect(client.post).toHaveBeenCalledWith('funnel-runs/run-1/cancel', {});
|
|
185
|
+
expect(client.post).toHaveBeenCalledWith('api/v1/funnel-runs/run-1/cancel', {});
|
|
185
186
|
});
|
|
186
187
|
});
|
|
187
188
|
|
|
@@ -189,7 +190,7 @@ describe('FunnelsApi', () => {
|
|
|
189
190
|
it('calls GET funnel-runs with no params when no filters given', async () => {
|
|
190
191
|
vi.mocked(client.get).mockResolvedValue(mockPaginated([mockRun]));
|
|
191
192
|
await api.listRunsGlobal();
|
|
192
|
-
expect(client.get).toHaveBeenCalledWith('funnel-runs');
|
|
193
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnel-runs');
|
|
193
194
|
});
|
|
194
195
|
|
|
195
196
|
it('passes funnel filter as query param', async () => {
|
|
@@ -203,7 +204,7 @@ describe('FunnelsApi', () => {
|
|
|
203
204
|
it('calls GET funnels/templates', async () => {
|
|
204
205
|
vi.mocked(client.get).mockResolvedValue(mockPaginated([]));
|
|
205
206
|
await api.listTemplates();
|
|
206
|
-
expect(client.get).toHaveBeenCalledWith('funnels/templates');
|
|
207
|
+
expect(client.get).toHaveBeenCalledWith('api/v1/funnels/templates');
|
|
207
208
|
});
|
|
208
209
|
|
|
209
210
|
it('passes category filter', async () => {
|
|
@@ -123,6 +123,33 @@ describe('JWT Refresh Flow', () => {
|
|
|
123
123
|
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('does NOT fire onUnauthorized when onTokenRefresh THROWS (transient 5xx / network)', async () => {
|
|
127
|
+
// This is the "backend is down, not session expired" case. If refresh
|
|
128
|
+
// throws (TransientRefreshError), we must surface the original 401 as an
|
|
129
|
+
// API error WITHOUT logging the user out.
|
|
130
|
+
const wrapper = new FetchWrapper({
|
|
131
|
+
baseUrl: 'http://localhost:8000',
|
|
132
|
+
getToken: () => 'stale-token',
|
|
133
|
+
onTokenRefresh: refreshCallback,
|
|
134
|
+
onUnauthorized: unauthorizedCallback,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
mockFetch.mockResolvedValueOnce({
|
|
138
|
+
status: 401,
|
|
139
|
+
ok: false,
|
|
140
|
+
headers: new Headers(),
|
|
141
|
+
text: async () => '',
|
|
142
|
+
});
|
|
143
|
+
refreshCallback.mockRejectedValueOnce(new Error('transient 5xx'));
|
|
144
|
+
|
|
145
|
+
await expect(wrapper.get('/api/v1/messages/')).rejects.toBeTruthy();
|
|
146
|
+
|
|
147
|
+
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
148
|
+
// CRITICAL: a transient refresh failure must NOT call onUnauthorized.
|
|
149
|
+
// Backend being down should not log users out.
|
|
150
|
+
expect(unauthorizedCallback).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
126
153
|
it('should fire onUnauthorized at most once when many parallel 401s fail refresh', async () => {
|
|
127
154
|
const wrapper = new FetchWrapper({
|
|
128
155
|
baseUrl: 'http://localhost:8000',
|
package/src/lib/fetch-wrapper.ts
CHANGED
|
@@ -113,25 +113,39 @@ export class FetchWrapper {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
...fetchOptions,
|
|
128
|
-
});
|
|
116
|
+
// A throw from onTokenRefresh means transient (5xx/network) — surface
|
|
117
|
+
// the original 401 as a normal API error rather than treating it as
|
|
118
|
+
// session-dead. Callers (components doing a fetch) will see an
|
|
119
|
+
// ApiException they can retry; the user stays logged in.
|
|
120
|
+
let newToken: string | null = null;
|
|
121
|
+
let refreshThrew = false;
|
|
122
|
+
try {
|
|
123
|
+
newToken = await this.refreshPromise;
|
|
124
|
+
} catch {
|
|
125
|
+
refreshThrew = true;
|
|
126
|
+
}
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
if (!refreshThrew) {
|
|
129
|
+
if (newToken) {
|
|
130
|
+
// Retry original request with new token
|
|
131
|
+
const retryHeaders = new Headers(headers);
|
|
132
|
+
retryHeaders.set('Authorization', `Bearer ${newToken}`);
|
|
133
|
+
|
|
134
|
+
response = await fetch(url, {
|
|
135
|
+
method,
|
|
136
|
+
headers: retryHeaders,
|
|
137
|
+
credentials: 'include',
|
|
138
|
+
...fetchOptions,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (response.status === 401) {
|
|
142
|
+
this.fireUnauthorizedOnce();
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// newToken=null is the "session is dead" signal from the refresh
|
|
146
|
+
// implementation. Only this path should log the user out.
|
|
131
147
|
this.fireUnauthorizedOnce();
|
|
132
148
|
}
|
|
133
|
-
} else {
|
|
134
|
-
this.fireUnauthorizedOnce();
|
|
135
149
|
}
|
|
136
150
|
} else {
|
|
137
151
|
this.fireUnauthorizedOnce();
|
package/src/types/error.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const StandardErrorResponseSchema = z.object({
|
|
|
55
55
|
code: z.string(),
|
|
56
56
|
statusCode: z.number().int().min(400).max(599),
|
|
57
57
|
fieldErrors: z.array(FieldErrorSchema).optional(),
|
|
58
|
-
details: z.record(z.unknown()).optional(),
|
|
58
|
+
details: z.record(z.string(), z.unknown()).optional(),
|
|
59
59
|
requestId: z.string().optional(),
|
|
60
60
|
timestamp: z.string().datetime().optional(),
|
|
61
61
|
});
|