@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.
Files changed (38) hide show
  1. package/README.md +329 -0
  2. package/package.json +42 -0
  3. package/src/__tests__/jwt-refresh.test.ts +195 -0
  4. package/src/__tests__/query-params.test.ts +144 -0
  5. package/src/__tests__/url-builder.test.ts +121 -0
  6. package/src/constants/endpoints.ts +39 -0
  7. package/src/index.ts +109 -0
  8. package/src/lib/api-client.ts +89 -0
  9. package/src/lib/contacts-api.ts +111 -0
  10. package/src/lib/cors.ts +122 -0
  11. package/src/lib/entities-api.ts +123 -0
  12. package/src/lib/env.ts +35 -0
  13. package/src/lib/error-handler.ts +138 -0
  14. package/src/lib/errors.ts +381 -0
  15. package/src/lib/fetch-wrapper.ts +188 -0
  16. package/src/lib/llm-sanitize.ts +145 -0
  17. package/src/lib/messages-api.ts +273 -0
  18. package/src/lib/messages-api.ts.backup +273 -0
  19. package/src/lib/organizations-api.ts +132 -0
  20. package/src/lib/rate-limit.ts +91 -0
  21. package/src/lib/sanitize.ts +39 -0
  22. package/src/lib/workflows-api.ts +159 -0
  23. package/src/middleware/index.ts +12 -0
  24. package/src/middleware/with-auth.ts +90 -0
  25. package/src/middleware/with-error-handling.ts +83 -0
  26. package/src/middleware/with-validation.ts +110 -0
  27. package/src/types/api.ts +38 -0
  28. package/src/types/contact.ts +49 -0
  29. package/src/types/entity.ts +153 -0
  30. package/src/types/error.ts +129 -0
  31. package/src/types/funnel.ts +133 -0
  32. package/src/types/index.ts +95 -0
  33. package/src/types/organization.ts +49 -0
  34. package/src/types/response.ts +44 -0
  35. package/src/types/workflow.ts +69 -0
  36. package/src/utils/index.ts +13 -0
  37. package/src/utils/query-params.ts +79 -0
  38. 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
+ });