@startsimpli/api 0.1.0
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/README.md +329 -0
- package/package.json +42 -0
- package/src/__tests__/jwt-refresh.test.ts +195 -0
- package/src/__tests__/query-params.test.ts +144 -0
- package/src/__tests__/url-builder.test.ts +121 -0
- package/src/constants/endpoints.ts +39 -0
- package/src/index.ts +109 -0
- package/src/lib/api-client.ts +89 -0
- package/src/lib/contacts-api.ts +111 -0
- package/src/lib/cors.ts +122 -0
- package/src/lib/entities-api.ts +123 -0
- package/src/lib/env.ts +35 -0
- package/src/lib/error-handler.ts +138 -0
- package/src/lib/errors.ts +381 -0
- package/src/lib/fetch-wrapper.ts +188 -0
- package/src/lib/llm-sanitize.ts +145 -0
- package/src/lib/messages-api.ts +273 -0
- package/src/lib/messages-api.ts.backup +273 -0
- package/src/lib/organizations-api.ts +132 -0
- package/src/lib/rate-limit.ts +91 -0
- package/src/lib/sanitize.ts +39 -0
- package/src/lib/workflows-api.ts +159 -0
- package/src/middleware/index.ts +12 -0
- package/src/middleware/with-auth.ts +90 -0
- package/src/middleware/with-error-handling.ts +83 -0
- package/src/middleware/with-validation.ts +110 -0
- package/src/types/api.ts +38 -0
- package/src/types/contact.ts +49 -0
- package/src/types/entity.ts +153 -0
- package/src/types/error.ts +129 -0
- package/src/types/funnel.ts +133 -0
- package/src/types/index.ts +95 -0
- package/src/types/organization.ts +49 -0
- package/src/types/response.ts +44 -0
- package/src/types/workflow.ts +69 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/query-params.ts +79 -0
- package/src/utils/url-builder.ts +78 -0
package/README.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# @startsimpli/api
|
|
2
|
+
|
|
3
|
+
Type-safe Django REST API client for StartSimpli Next.js applications.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides a type-safe TypeScript client for the Django REST API backend (`start-simpli-api`). It handles authentication, error normalization, pagination, and provides endpoint wrappers for all Django models.
|
|
8
|
+
|
|
9
|
+
**IMPORTANT**: This is a Django REST API client - NO Prisma, NO database code. All data lives in the Django backend.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @startsimpli/api
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Basic Setup
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { createStartSimpliApi } from '@startsimpli/api';
|
|
23
|
+
|
|
24
|
+
const api = createStartSimpliApi({
|
|
25
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1',
|
|
26
|
+
getToken: async () => {
|
|
27
|
+
// Get token from your auth provider
|
|
28
|
+
const session = await getServerSession();
|
|
29
|
+
return session?.accessToken || null;
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Use API endpoints
|
|
34
|
+
const contacts = await api.contacts.list({ tier: 1 });
|
|
35
|
+
const orgs = await api.organizations.getByStage('seed');
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Contacts API
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// List contacts with filters
|
|
42
|
+
const contacts = await api.contacts.list(
|
|
43
|
+
{ tier: 1, has_linkedin: true }, // filters
|
|
44
|
+
{ page: 1, page_size: 20 }, // pagination
|
|
45
|
+
{ ordering: '-created_at' } // sorting
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Get single contact
|
|
49
|
+
const contact = await api.contacts.get('contact-uuid');
|
|
50
|
+
|
|
51
|
+
// Create contact with assertions
|
|
52
|
+
const newContact = await api.contacts.create({
|
|
53
|
+
name: 'John Doe',
|
|
54
|
+
email: 'john@example.com',
|
|
55
|
+
title: 'Partner',
|
|
56
|
+
write_tags: ['tier_1', 'focus:fintech'],
|
|
57
|
+
write_metrics: { enrichment_score: 0.95 },
|
|
58
|
+
write_profiles: { linkedin: 'https://linkedin.com/in/johndoe' },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Update contact
|
|
62
|
+
const updated = await api.contacts.update('contact-uuid', {
|
|
63
|
+
title: 'Managing Partner',
|
|
64
|
+
write_tags: ['tier_1', 'vip'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Search contacts
|
|
68
|
+
const results = await api.contacts.search('john');
|
|
69
|
+
|
|
70
|
+
// Get contacts by firm
|
|
71
|
+
const firmContacts = await api.contacts.getByFirm('firm-uuid');
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Organizations API
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
// List organizations with filters
|
|
78
|
+
const orgs = await api.organizations.list(
|
|
79
|
+
{
|
|
80
|
+
tier: 1,
|
|
81
|
+
stage: 'seed',
|
|
82
|
+
check_size_min_gte: 100000,
|
|
83
|
+
check_size_max_lte: 1000000,
|
|
84
|
+
},
|
|
85
|
+
{ page: 1, page_size: 20 },
|
|
86
|
+
{ ordering: '-aum' }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Get single organization
|
|
90
|
+
const org = await api.organizations.get('org-uuid');
|
|
91
|
+
|
|
92
|
+
// Create organization with assertions
|
|
93
|
+
const newOrg = await api.organizations.create({
|
|
94
|
+
name: 'Acme Ventures',
|
|
95
|
+
domain: 'acmevc.com',
|
|
96
|
+
location: 'San Francisco, CA',
|
|
97
|
+
write_tags: ['tier_1', 'stage:seed', 'focus:fintech'],
|
|
98
|
+
write_metrics: {
|
|
99
|
+
check_size_min: 500000,
|
|
100
|
+
check_size_max: 2000000,
|
|
101
|
+
aum: 100000000,
|
|
102
|
+
},
|
|
103
|
+
write_profiles: {
|
|
104
|
+
linkedin: 'https://linkedin.com/company/acme-ventures',
|
|
105
|
+
website: 'https://acmevc.com',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Get by check size range
|
|
110
|
+
const firms = await api.organizations.getByCheckSizeRange(100000, 1000000);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Entities API (Low-level)
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// Tags
|
|
117
|
+
const tags = await api.entities.listTags();
|
|
118
|
+
|
|
119
|
+
// Entity Tags
|
|
120
|
+
const entityTags = await api.entities.listEntityTags(
|
|
121
|
+
{ page: 1, page_size: 50 },
|
|
122
|
+
{ entity_id: 'some-uuid', category: 'focus' }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Metrics
|
|
126
|
+
const metrics = await api.entities.listMetrics(
|
|
127
|
+
undefined,
|
|
128
|
+
{ entity_id: 'some-uuid', type: 'financial' }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Profiles
|
|
132
|
+
const profiles = await api.entities.listProfiles(
|
|
133
|
+
undefined,
|
|
134
|
+
{ entity_id: 'some-uuid', type: 'professional' }
|
|
135
|
+
);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Using Direct Client
|
|
139
|
+
|
|
140
|
+
For custom endpoints not covered by wrappers:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { createApiClient } from '@startsimpli/api';
|
|
144
|
+
|
|
145
|
+
const client = createApiClient({
|
|
146
|
+
baseUrl: 'http://localhost:8000/api/v1',
|
|
147
|
+
getToken: async () => getAccessToken(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Direct fetch calls
|
|
151
|
+
const data = await client.fetch.get('/custom-endpoint/', {
|
|
152
|
+
params: { key: 'value' }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const created = await client.fetch.post('/custom-endpoint/', {
|
|
156
|
+
name: 'test'
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Next.js API Routes Middleware
|
|
161
|
+
|
|
162
|
+
### With Auth
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { withAuth } from '@startsimpli/api/middleware';
|
|
166
|
+
|
|
167
|
+
export const GET = withAuth(async (request, context) => {
|
|
168
|
+
const { userId, token, isAuthenticated } = context;
|
|
169
|
+
|
|
170
|
+
if (!isAuthenticated) {
|
|
171
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Use token to call Django API
|
|
175
|
+
return NextResponse.json({ userId, token });
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### With Validation
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { withValidation } from '@startsimpli/api/middleware';
|
|
183
|
+
import { z } from 'zod';
|
|
184
|
+
|
|
185
|
+
const createContactSchema = z.object({
|
|
186
|
+
name: z.string().min(1),
|
|
187
|
+
email: z.string().email().optional(),
|
|
188
|
+
tier: z.number().int().min(1).max(3).optional(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
export const POST = withValidation(
|
|
192
|
+
async (request, { body }) => {
|
|
193
|
+
// body is typed and validated
|
|
194
|
+
const contact = await createContact(body);
|
|
195
|
+
return NextResponse.json(contact);
|
|
196
|
+
},
|
|
197
|
+
{ body: createContactSchema }
|
|
198
|
+
);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### With Error Handling
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
import { withErrorHandling } from '@startsimpli/api/middleware';
|
|
205
|
+
|
|
206
|
+
export const GET = withErrorHandling(async (request) => {
|
|
207
|
+
// Errors are automatically caught and formatted
|
|
208
|
+
const data = await somethingThatMightThrow();
|
|
209
|
+
return NextResponse.json(data);
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Composing Middleware
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { withAuth, withErrorHandling, withValidation } from '@startsimpli/api/middleware';
|
|
217
|
+
import { z } from 'zod';
|
|
218
|
+
|
|
219
|
+
const schema = z.object({ name: z.string() });
|
|
220
|
+
|
|
221
|
+
export const POST = withErrorHandling(
|
|
222
|
+
withAuth(
|
|
223
|
+
withValidation(
|
|
224
|
+
async (request, { body }, authContext) => {
|
|
225
|
+
// Fully typed, validated, and authenticated
|
|
226
|
+
return NextResponse.json({ success: true });
|
|
227
|
+
},
|
|
228
|
+
{ body: schema }
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Error Handling
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import {
|
|
238
|
+
isApiException,
|
|
239
|
+
isValidationError,
|
|
240
|
+
isAuthError,
|
|
241
|
+
isNotFoundError,
|
|
242
|
+
} from '@startsimpli/api';
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const contact = await api.contacts.get('invalid-id');
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (isValidationError(error)) {
|
|
248
|
+
console.log('Validation errors:', error.errors);
|
|
249
|
+
} else if (isAuthError(error)) {
|
|
250
|
+
console.log('Auth error:', error.status);
|
|
251
|
+
} else if (isNotFoundError(error)) {
|
|
252
|
+
console.log('Contact not found');
|
|
253
|
+
} else if (isApiException(error)) {
|
|
254
|
+
console.log('API error:', error.message);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## TypeScript Types
|
|
260
|
+
|
|
261
|
+
All types are exported for use in your application:
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import type {
|
|
265
|
+
Contact,
|
|
266
|
+
Organization,
|
|
267
|
+
Entity,
|
|
268
|
+
Tag,
|
|
269
|
+
Metric,
|
|
270
|
+
Profile,
|
|
271
|
+
Attribute,
|
|
272
|
+
PaginatedResponse,
|
|
273
|
+
ApiError,
|
|
274
|
+
CreateContactRequest,
|
|
275
|
+
UpdateContactRequest,
|
|
276
|
+
ContactFilters,
|
|
277
|
+
} from '@startsimpli/api';
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Architecture
|
|
281
|
+
|
|
282
|
+
### Django Integration
|
|
283
|
+
|
|
284
|
+
This package is designed to work with the Django REST API:
|
|
285
|
+
|
|
286
|
+
- **Base URL**: `http://localhost:8000/api/v1/` (configurable)
|
|
287
|
+
- **Auth**: Bearer token from `@startsimpli/auth`
|
|
288
|
+
- **Pagination**: Django REST Framework format (`page`, `page_size`)
|
|
289
|
+
- **Filtering**: Django-filter query params (`field__gte=100`, `field__in=1,2,3`)
|
|
290
|
+
|
|
291
|
+
### Entity System
|
|
292
|
+
|
|
293
|
+
Django uses a generic Entity model with assertions (Tags, Metrics, Profiles, Attributes):
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
// Entity with assertions
|
|
297
|
+
{
|
|
298
|
+
id: 'uuid',
|
|
299
|
+
entity_type: 'contact',
|
|
300
|
+
|
|
301
|
+
// Canonical fields
|
|
302
|
+
name: 'John Doe',
|
|
303
|
+
email: 'john@example.com',
|
|
304
|
+
|
|
305
|
+
// Computed from assertions
|
|
306
|
+
tier: 1, // from tags
|
|
307
|
+
linkedin: 'https://...', // from profiles
|
|
308
|
+
enrichment_score: 0.95, // from metrics
|
|
309
|
+
|
|
310
|
+
// Full assertions
|
|
311
|
+
tags: [{ category: 'quality', name: 'tier_1', ... }],
|
|
312
|
+
metrics: [{ type: 'quality', subtype: 'enrichment_score', value: 0.95 }],
|
|
313
|
+
profiles: [{ type: 'professional', subtype: 'linkedin', identifier: '...' }],
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Development
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
# Run tests
|
|
321
|
+
npm test
|
|
322
|
+
|
|
323
|
+
# Type check
|
|
324
|
+
npm run type-check
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## License
|
|
328
|
+
|
|
329
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startsimpli/api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Type-safe Django REST API client for StartSimpli apps",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./client": "./src/lib/api-client.ts",
|
|
14
|
+
"./middleware": "./src/middleware/index.ts",
|
|
15
|
+
"./types": "./src/types/index.ts",
|
|
16
|
+
"./utils": "./src/utils/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"type-check": "tsc --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@types/dompurify": "^3.0.5",
|
|
25
|
+
"isomorphic-dompurify": "^2.36.0",
|
|
26
|
+
"zod": "^3.22.4"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"next": ">=14.0.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"next": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.11.0",
|
|
38
|
+
"next": "^15.5.12",
|
|
39
|
+
"typescript": "^5.3.3",
|
|
40
|
+
"vitest": "^1.2.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Refresh Token Flow Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the automatic token refresh interceptor that prevents
|
|
5
|
+
* users from being logged out every 30 minutes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
|
+
import { FetchWrapper } from '../lib/fetch-wrapper';
|
|
10
|
+
|
|
11
|
+
describe('JWT Refresh Flow', () => {
|
|
12
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
13
|
+
let refreshCallback: ReturnType<typeof vi.fn>;
|
|
14
|
+
let unauthorizedCallback: ReturnType<typeof vi.fn>;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockFetch = vi.fn();
|
|
18
|
+
refreshCallback = vi.fn();
|
|
19
|
+
unauthorizedCallback = vi.fn();
|
|
20
|
+
global.fetch = mockFetch;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should retry request after successful token refresh on 401', async () => {
|
|
24
|
+
const wrapper = new FetchWrapper({
|
|
25
|
+
baseUrl: 'http://localhost:8000',
|
|
26
|
+
getToken: () => 'old-token',
|
|
27
|
+
onTokenRefresh: refreshCallback,
|
|
28
|
+
onUnauthorized: unauthorizedCallback,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// First call returns 401
|
|
32
|
+
mockFetch.mockResolvedValueOnce({
|
|
33
|
+
status: 401,
|
|
34
|
+
ok: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Refresh returns new token
|
|
38
|
+
refreshCallback.mockResolvedValueOnce('new-token');
|
|
39
|
+
|
|
40
|
+
// Retry with new token succeeds
|
|
41
|
+
mockFetch.mockResolvedValueOnce({
|
|
42
|
+
status: 200,
|
|
43
|
+
ok: true,
|
|
44
|
+
json: async () => ({ data: 'success' }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await wrapper.get('/api/v1/messages/');
|
|
48
|
+
|
|
49
|
+
// Should have called refresh
|
|
50
|
+
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
51
|
+
|
|
52
|
+
// Should have made 2 fetch calls (original + retry)
|
|
53
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
54
|
+
|
|
55
|
+
// Second call should have new token
|
|
56
|
+
const retryCall = mockFetch.mock.calls[1];
|
|
57
|
+
const retryHeaders = retryCall[1]?.headers;
|
|
58
|
+
expect(retryHeaders.get('Authorization')).toBe('Bearer new-token');
|
|
59
|
+
|
|
60
|
+
// Should NOT have called unauthorized callback
|
|
61
|
+
expect(unauthorizedCallback).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should call onUnauthorized if refresh fails', async () => {
|
|
65
|
+
const wrapper = new FetchWrapper({
|
|
66
|
+
baseUrl: 'http://localhost:8000',
|
|
67
|
+
getToken: () => 'old-token',
|
|
68
|
+
onTokenRefresh: refreshCallback,
|
|
69
|
+
onUnauthorized: unauthorizedCallback,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// First call returns 401
|
|
73
|
+
mockFetch.mockResolvedValueOnce({
|
|
74
|
+
status: 401,
|
|
75
|
+
ok: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Refresh fails (returns null)
|
|
79
|
+
refreshCallback.mockResolvedValueOnce(null);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await wrapper.get('/api/v1/messages/');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Expected to throw
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Should have called refresh
|
|
88
|
+
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
89
|
+
|
|
90
|
+
// Should have called unauthorized callback
|
|
91
|
+
expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should prevent multiple simultaneous refresh attempts', async () => {
|
|
95
|
+
const wrapper = new FetchWrapper({
|
|
96
|
+
baseUrl: 'http://localhost:8000',
|
|
97
|
+
getToken: () => 'old-token',
|
|
98
|
+
onTokenRefresh: refreshCallback,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Both calls return 401
|
|
102
|
+
mockFetch.mockResolvedValue({
|
|
103
|
+
status: 401,
|
|
104
|
+
ok: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Refresh returns new token (with delay)
|
|
108
|
+
refreshCallback.mockImplementation(
|
|
109
|
+
() => new Promise((resolve) => setTimeout(() => resolve('new-token'), 100))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Retry succeeds
|
|
113
|
+
mockFetch.mockResolvedValueOnce({
|
|
114
|
+
status: 200,
|
|
115
|
+
ok: true,
|
|
116
|
+
json: async () => ({ data: 'success' }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Make 2 concurrent requests
|
|
120
|
+
const promise1 = wrapper.get('/api/v1/messages/');
|
|
121
|
+
const promise2 = wrapper.get('/api/v1/contacts/');
|
|
122
|
+
|
|
123
|
+
await Promise.all([promise1, promise2]);
|
|
124
|
+
|
|
125
|
+
// Should only have called refresh ONCE (shared promise)
|
|
126
|
+
expect(refreshCallback).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should work without refresh callback (backward compatible)', async () => {
|
|
130
|
+
const wrapper = new FetchWrapper({
|
|
131
|
+
baseUrl: 'http://localhost:8000',
|
|
132
|
+
getToken: () => 'token',
|
|
133
|
+
// No onTokenRefresh provided
|
|
134
|
+
onUnauthorized: unauthorizedCallback,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
mockFetch.mockResolvedValueOnce({
|
|
138
|
+
status: 401,
|
|
139
|
+
ok: false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await wrapper.get('/api/v1/messages/');
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Expected to throw
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Should NOT have attempted refresh (no callback)
|
|
149
|
+
// Should still call unauthorized callback
|
|
150
|
+
expect(unauthorizedCallback).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Integration Example: market-simpli
|
|
156
|
+
*
|
|
157
|
+
* This is how market-simpli implements JWT refresh:
|
|
158
|
+
*
|
|
159
|
+
* ```typescript
|
|
160
|
+
* // src/shared/lib/api/client.ts
|
|
161
|
+
* import { createStartSimpliApi } from '@startsimpli/api';
|
|
162
|
+
*
|
|
163
|
+
* async function refreshToken(): Promise<string | null> {
|
|
164
|
+
* const response = await fetch('/api/v1/auth/token/refresh/', {
|
|
165
|
+
* method: 'POST',
|
|
166
|
+
* credentials: 'include', // Sends refresh token cookie
|
|
167
|
+
* });
|
|
168
|
+
*
|
|
169
|
+
* if (!response.ok) return null;
|
|
170
|
+
*
|
|
171
|
+
* const data = await response.json();
|
|
172
|
+
* const access = data.access;
|
|
173
|
+
*
|
|
174
|
+
* localStorage.setItem('auth_token', access);
|
|
175
|
+
* return access;
|
|
176
|
+
* }
|
|
177
|
+
*
|
|
178
|
+
* export const api = createStartSimpliApi({
|
|
179
|
+
* baseUrl: process.env.NEXT_PUBLIC_API_URL,
|
|
180
|
+
* getToken: () => localStorage.getItem('auth_token'),
|
|
181
|
+
* onTokenRefresh: refreshToken,
|
|
182
|
+
* onUnauthorized: () => {
|
|
183
|
+
* localStorage.removeItem('auth_token');
|
|
184
|
+
* window.location.href = '/login';
|
|
185
|
+
* },
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* Now when any API call gets 401:
|
|
190
|
+
* 1. Interceptor calls refreshToken()
|
|
191
|
+
* 2. Gets new access token from /api/v1/auth/token/refresh/
|
|
192
|
+
* 3. Stores new token in localStorage
|
|
193
|
+
* 4. Retries original request with new token
|
|
194
|
+
* 5. User never sees "logged out" error
|
|
195
|
+
*/
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for query parameter utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { buildFilterParams, buildOrderingParam, mergeQueryParams } from '../utils/query-params';
|
|
7
|
+
|
|
8
|
+
describe('buildFilterParams', () => {
|
|
9
|
+
it('should convert filters to Django query params', () => {
|
|
10
|
+
const filters = {
|
|
11
|
+
tier: 1,
|
|
12
|
+
status: 'active',
|
|
13
|
+
search: 'test',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const params = buildFilterParams(filters);
|
|
17
|
+
|
|
18
|
+
expect(params).toEqual({
|
|
19
|
+
tier: 1,
|
|
20
|
+
status: 'active',
|
|
21
|
+
search: 'test',
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should skip undefined and null values', () => {
|
|
26
|
+
const filters = {
|
|
27
|
+
tier: 1,
|
|
28
|
+
status: undefined,
|
|
29
|
+
search: null,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const params = buildFilterParams(filters);
|
|
33
|
+
|
|
34
|
+
expect(params).toEqual({ tier: 1 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should handle boolean filters', () => {
|
|
38
|
+
const filters = {
|
|
39
|
+
has_email: true,
|
|
40
|
+
has_linkedin: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const params = buildFilterParams(filters);
|
|
44
|
+
|
|
45
|
+
expect(params).toEqual({
|
|
46
|
+
has_email: true,
|
|
47
|
+
has_linkedin: false,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should handle array filters with __in suffix', () => {
|
|
52
|
+
const filters = {
|
|
53
|
+
tier: [1, 2, 3],
|
|
54
|
+
status: ['active', 'pending'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const params = buildFilterParams(filters);
|
|
58
|
+
|
|
59
|
+
expect(params).toEqual({
|
|
60
|
+
tier__in: '1,2,3',
|
|
61
|
+
status__in: 'active,pending',
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should skip empty arrays', () => {
|
|
66
|
+
const filters = {
|
|
67
|
+
tier: [],
|
|
68
|
+
status: 'active',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const params = buildFilterParams(filters);
|
|
72
|
+
|
|
73
|
+
expect(params).toEqual({ status: 'active' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle range filters', () => {
|
|
77
|
+
const filters = {
|
|
78
|
+
check_size_min_gte: 100000,
|
|
79
|
+
check_size_max_lte: 1000000,
|
|
80
|
+
created_at_gt: '2024-01-01',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const params = buildFilterParams(filters);
|
|
84
|
+
|
|
85
|
+
expect(params).toEqual({
|
|
86
|
+
check_size_min_gte: 100000,
|
|
87
|
+
check_size_max_lte: 1000000,
|
|
88
|
+
created_at_gt: '2024-01-01',
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('buildOrderingParam', () => {
|
|
94
|
+
it('should build ascending ordering', () => {
|
|
95
|
+
expect(buildOrderingParam('name', 'asc')).toBe('name');
|
|
96
|
+
expect(buildOrderingParam('createdAt', 'asc')).toBe('createdAt');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should build descending ordering', () => {
|
|
100
|
+
expect(buildOrderingParam('name', 'desc')).toBe('-name');
|
|
101
|
+
expect(buildOrderingParam('createdAt', 'desc')).toBe('-createdAt');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should default to ascending', () => {
|
|
105
|
+
expect(buildOrderingParam('name')).toBe('name');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should return undefined for empty field', () => {
|
|
109
|
+
expect(buildOrderingParam(undefined)).toBeUndefined();
|
|
110
|
+
expect(buildOrderingParam('')).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('mergeQueryParams', () => {
|
|
115
|
+
it('should merge pagination, sorting, and filters', () => {
|
|
116
|
+
const pagination = { page: 2, pageSize: 50 };
|
|
117
|
+
const sorting = { ordering: '-createdAt' };
|
|
118
|
+
const filters = { tier: 1, status: 'active' };
|
|
119
|
+
|
|
120
|
+
const merged = mergeQueryParams(pagination, sorting, filters);
|
|
121
|
+
|
|
122
|
+
expect(merged).toEqual({
|
|
123
|
+
page: 2,
|
|
124
|
+
pageSize: 50,
|
|
125
|
+
ordering: '-createdAt',
|
|
126
|
+
tier: 1,
|
|
127
|
+
status: 'active',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should handle undefined params', () => {
|
|
132
|
+
const pagination = { page: 1 };
|
|
133
|
+
|
|
134
|
+
const merged = mergeQueryParams(pagination, undefined, undefined);
|
|
135
|
+
|
|
136
|
+
expect(merged).toEqual({ page: 1 });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle all undefined', () => {
|
|
140
|
+
const merged = mergeQueryParams();
|
|
141
|
+
|
|
142
|
+
expect(merged).toEqual({});
|
|
143
|
+
});
|
|
144
|
+
});
|