@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/api",
3
- "version": "0.5.8",
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
- "@types/dompurify": "^3.0.5",
27
- "isomorphic-dompurify": "^2.36.0",
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.0.0",
48
- "@types/node": "^20.11.0",
49
- "@types/react": "^18.2.0",
50
- "next": "^15.5.12",
51
- "react": "^18.2.0",
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": "^5.3.3",
54
- "vitest": "^1.2.0"
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('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');
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',
@@ -113,25 +113,39 @@ export class FetchWrapper {
113
113
  });
114
114
  }
115
115
 
116
- const newToken = await this.refreshPromise;
117
-
118
- if (newToken) {
119
- // Retry original request with new token
120
- const retryHeaders = new Headers(headers);
121
- retryHeaders.set('Authorization', `Bearer ${newToken}`);
122
-
123
- response = await fetch(url, {
124
- method,
125
- headers: retryHeaders,
126
- credentials: 'include',
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
- if (response.status === 401) {
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();
@@ -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
  });