quickbase-js 0.1.0-beta.8 → 0.1.0-beta.9
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/CHANGELOG.md +29 -0
- package/README.md +46 -130
- package/dist/esm/quickbase.d.ts +822 -119
- package/dist/esm/quickbase.js +373 -236
- package/dist/esm/quickbase.js.map +1 -1
- package/dist/esm/quickbase.min.js +1 -1
- package/dist/esm/quickbase.min.js.map +1 -1
- package/dist/umd/quickbase.umd.js +373 -236
- package/dist/umd/quickbase.umd.js.map +1 -1
- package/dist/umd/quickbase.umd.min.js +1 -1
- package/dist/umd/quickbase.umd.min.js.map +1 -1
- package/open-api/generate-unified-interface.ts +97 -17
- package/open-api/utils/generateJsDoc.ts +96 -0
- package/package.json +3 -1
- package/src/ThrottleBucket.ts +17 -12
- package/src/authorizationStrategy.ts +246 -0
- package/src/generated-unified/QuickbaseClient.ts +809 -119
- package/src/invokeMethod.ts +150 -265
- package/src/quickbaseClient.ts +85 -58
- package/src/rateLimiter.ts +25 -0
- package/src/tokenCache.ts +10 -26
- package/tests/vitest/qb/auth/mockQuickbaseServer.ts +60 -0
- package/tests/vitest/qb/auth/retryUserToken401.test.ts +59 -42
- package/tests/vitest/qb/auth/ssoTokenIntegrationWithServer.test.ts +65 -0
- package/tests/vitest/qb/fields/getFieldUsage.test.ts +58 -150
- package/tests/vitest/qb/formulas/runFormula.test.ts +96 -0
- package/tests/vitest/qb/tables/createRelationship.test.ts +102 -0
- package/tests/vitest/qb/tables/deleteRelationship.test.ts +143 -0
- package/tests/vitest/qb/tables/updateRelationship.test.ts +118 -145
- package/tests/vitest/unit/ThrottleBucket.test.ts +95 -0
- package/tests/vitest/unit/apps/copyApp.test.ts +133 -40
- package/tests/vitest/unit/apps/updateApp.test.ts +42 -11
- package/tests/vitest/unit/auth/fetchTempToken401.test.ts +3 -2
- package/tests/vitest/unit/auth/getTempToken.test.ts +42 -18
- package/tests/vitest/unit/auth/retryTempToken401.test.ts +46 -64
- package/tests/vitest/unit/auth/retryTempTokenMaxRetries.test.ts +235 -0
- package/tests/vitest/unit/auth/retryUserToken401.test.ts +56 -43
- package/tests/vitest/unit/auth/ssoTokenRefresh.test.ts +325 -0
- package/tests/vitest/unit/fields/createField.test.ts +180 -116
- package/tests/vitest/unit/fields/getFieldUsage.test.ts +17 -3
- package/tests/vitest/unit/fields/updateField.test.ts +12 -7
- package/tests/vitest/unit/formulas/runFormula.test.ts +205 -0
- package/tests/vitest/unit/index.ts +2 -1
- package/tests/vitest/unit/inferHttpMethod.test.ts +131 -0
- package/tests/vitest/unit/rateLimit.test.ts +4 -2
- package/tests/vitest/unit/tables/createRelationship.test.ts +212 -0
- package/tests/vitest/unit/tables/deleteRelationship.test.ts +97 -0
- package/tests/vitest/unit/tables/updateRelationship.test.ts +12 -10
- package/tests/vitest/unit/tokenLifespan.test.ts +149 -83
- package/tools/log-gen.ts +14 -5
- package/tools/log-gen.yaml +710 -268
- package/tests/vitest/unit/auth/retryTempTokenTwo401.test.ts +0 -81
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
// tests/vitest/qb/tables/updateRelationship.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
2
4
|
import {
|
|
3
5
|
createClient,
|
|
4
6
|
QB_REALM,
|
|
@@ -19,35 +21,23 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
19
21
|
let generatedLookupFieldId: number | undefined;
|
|
20
22
|
let generatedSummaryFieldId: number | undefined;
|
|
21
23
|
|
|
22
|
-
beforeAll
|
|
23
|
-
client = createClient(fetch, {
|
|
24
|
-
realm: QB_REALM,
|
|
25
|
-
userToken: QB_USER_TOKEN,
|
|
26
|
-
debug: true,
|
|
27
|
-
});
|
|
24
|
+
// No beforeAll/afterAll since we'll create and delete within the test
|
|
28
25
|
|
|
26
|
+
it("creates, updates, deletes, and verifies a relationship", async () => {
|
|
27
|
+
// Validate environment variables
|
|
29
28
|
if (!QB_REALM) throw new Error("QB_REALM is not defined in .env");
|
|
30
29
|
if (!QB_USER_TOKEN) throw new Error("QB_USER_TOKEN is not defined in .env");
|
|
30
|
+
if (!QB_TABLE_ID_1) throw new Error("QB_TABLE_ID_1 is not defined in .env");
|
|
31
|
+
if (!QB_TABLE_ID_2) throw new Error("QB_TABLE_ID_2 is not defined in .env");
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Initialize client
|
|
34
|
+
client = createClient(fetch, {
|
|
35
|
+
realm: QB_REALM,
|
|
36
|
+
userToken: QB_USER_TOKEN,
|
|
37
|
+
debug: true,
|
|
35
38
|
});
|
|
36
|
-
console.log(
|
|
37
|
-
"Initial relationships response:",
|
|
38
|
-
JSON.stringify(relationshipsResponse, null, 2)
|
|
39
|
-
);
|
|
40
|
-
const relationship = relationshipsResponse.relationships.find(
|
|
41
|
-
(r) => r.parentTableId === QB_TABLE_ID_1
|
|
42
|
-
);
|
|
43
|
-
if (!relationship) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`No relationship found between parent ${QB_TABLE_ID_1} and child ${QB_TABLE_ID_2}`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
relationshipId = relationship.id;
|
|
49
|
-
console.log(`Identified relationshipId: ${relationshipId}`);
|
|
50
39
|
|
|
40
|
+
// Step 1: Create fields for the relationship update
|
|
51
41
|
const lookupFieldRequest: CreateFieldRequest = {
|
|
52
42
|
label: `Test Lookup Field_${Date.now()}`,
|
|
53
43
|
fieldType: "text",
|
|
@@ -80,103 +70,40 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
80
70
|
`Created child field with ID ${newChildFieldId} in child table ${QB_TABLE_ID_2}`
|
|
81
71
|
);
|
|
82
72
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
JSON.stringify(deleteLookupResponse, null, 2)
|
|
98
|
-
);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
console.error(
|
|
101
|
-
`Failed to delete original lookup field ${newLookupFieldId}:`,
|
|
102
|
-
error
|
|
103
|
-
);
|
|
104
|
-
throw error;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (newChildFieldId) {
|
|
108
|
-
try {
|
|
109
|
-
const deleteChildResponse = await client.deleteFields({
|
|
110
|
-
tableId: QB_TABLE_ID_2,
|
|
111
|
-
body: { fieldIds: [newChildFieldId] },
|
|
112
|
-
});
|
|
113
|
-
console.log(
|
|
114
|
-
`Deleted original child field ${newChildFieldId} from ${QB_TABLE_ID_2}:`,
|
|
115
|
-
JSON.stringify(deleteChildResponse, null, 2)
|
|
116
|
-
);
|
|
117
|
-
} catch (error) {
|
|
118
|
-
console.error(
|
|
119
|
-
`Failed to delete original child field ${newChildFieldId}:`,
|
|
120
|
-
error
|
|
121
|
-
);
|
|
122
|
-
throw error;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (generatedLookupFieldId) {
|
|
126
|
-
try {
|
|
127
|
-
const deleteGeneratedLookupResponse = await client.deleteFields({
|
|
128
|
-
tableId: QB_TABLE_ID_2,
|
|
129
|
-
body: { fieldIds: [generatedLookupFieldId] },
|
|
130
|
-
});
|
|
131
|
-
console.log(
|
|
132
|
-
`Deleted generated lookup field ${generatedLookupFieldId} from ${QB_TABLE_ID_2}:`,
|
|
133
|
-
JSON.stringify(deleteGeneratedLookupResponse, null, 2)
|
|
134
|
-
);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
console.error(
|
|
137
|
-
`Failed to delete generated lookup field ${generatedLookupFieldId}:`,
|
|
138
|
-
error
|
|
139
|
-
);
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
if (generatedSummaryFieldId) {
|
|
144
|
-
try {
|
|
145
|
-
const deleteGeneratedSummaryResponse = await client.deleteFields({
|
|
146
|
-
tableId: QB_TABLE_ID_1,
|
|
147
|
-
body: { fieldIds: [generatedSummaryFieldId] },
|
|
148
|
-
});
|
|
149
|
-
console.log(
|
|
150
|
-
`Deleted generated summary field ${generatedSummaryFieldId} from ${QB_TABLE_ID_1}:`,
|
|
151
|
-
JSON.stringify(deleteGeneratedSummaryResponse, null, 2)
|
|
152
|
-
);
|
|
153
|
-
} catch (error) {
|
|
154
|
-
console.error(
|
|
155
|
-
`Failed to delete generated summary field ${generatedSummaryFieldId}:`,
|
|
156
|
-
error
|
|
157
|
-
);
|
|
158
|
-
throw error;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}, 30000);
|
|
73
|
+
// Step 2: Create a new relationship
|
|
74
|
+
const uniqueLabel = `TestRelationship_${Date.now()}`;
|
|
75
|
+
const createRequestBody = {
|
|
76
|
+
parentTableId: QB_TABLE_ID_1,
|
|
77
|
+
foreignKeyField: { label: uniqueLabel },
|
|
78
|
+
lookupFieldIds: [6], // Assuming field 6 exists in QB_TABLE_ID_1
|
|
79
|
+
summaryFields: [
|
|
80
|
+
{
|
|
81
|
+
summaryFid: 6, // Assuming field 6 in QB_TABLE_ID_2 is numeric
|
|
82
|
+
label: `Sum_${uniqueLabel}`,
|
|
83
|
+
accumulationType: "SUM",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
162
87
|
|
|
163
|
-
|
|
164
|
-
const
|
|
88
|
+
console.log("Creating relationship with:", createRequestBody);
|
|
89
|
+
const createResponse = await client.createRelationship({
|
|
165
90
|
tableId: QB_TABLE_ID_2,
|
|
91
|
+
body: createRequestBody,
|
|
166
92
|
});
|
|
167
|
-
const relationship = relationshipsResponse.relationships.find(
|
|
168
|
-
(r) => r.id === relationshipId
|
|
169
|
-
);
|
|
170
|
-
if (!relationship) {
|
|
171
|
-
throw new Error(`Relationship ${relationshipId} not found before test`);
|
|
172
|
-
}
|
|
173
93
|
console.log(
|
|
174
|
-
"
|
|
175
|
-
JSON.stringify(
|
|
94
|
+
"Created relationship response:",
|
|
95
|
+
JSON.stringify(createResponse, null, 2)
|
|
176
96
|
);
|
|
177
|
-
}, 30000);
|
|
178
97
|
|
|
179
|
-
|
|
98
|
+
expect(createResponse).toBeDefined();
|
|
99
|
+
expect(createResponse.id).toBeDefined();
|
|
100
|
+
expect(createResponse.parentTableId).toBe(QB_TABLE_ID_1);
|
|
101
|
+
expect(createResponse.childTableId).toBe(QB_TABLE_ID_2);
|
|
102
|
+
expect(createResponse.foreignKeyField?.label).toBe(uniqueLabel);
|
|
103
|
+
|
|
104
|
+
relationshipId = createResponse.id;
|
|
105
|
+
|
|
106
|
+
// Step 3: Update the relationship - Add a lookup field
|
|
180
107
|
const tableId = QB_TABLE_ID_2;
|
|
181
108
|
const initialResponse = await client.getRelationships({ tableId });
|
|
182
109
|
const initialRelationship = initialResponse.relationships.find(
|
|
@@ -187,22 +114,25 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
187
114
|
);
|
|
188
115
|
console.log("Current lookupFields before update:", currentLookupFields);
|
|
189
116
|
|
|
190
|
-
const
|
|
117
|
+
const updateLookupRequest: UpdateRelationshipRequest = {
|
|
191
118
|
lookupFieldIds: [newLookupFieldId!],
|
|
192
119
|
};
|
|
193
120
|
console.log(
|
|
194
121
|
"Adding lookup field request:",
|
|
195
|
-
JSON.stringify(
|
|
122
|
+
JSON.stringify(updateLookupRequest, null, 2)
|
|
196
123
|
);
|
|
197
124
|
|
|
198
|
-
const
|
|
125
|
+
const updateLookupResponse = await client.updateRelationship({
|
|
199
126
|
tableId,
|
|
200
127
|
relationshipId,
|
|
201
|
-
body:
|
|
128
|
+
body: updateLookupRequest,
|
|
202
129
|
});
|
|
203
|
-
console.log(
|
|
130
|
+
console.log(
|
|
131
|
+
"Update lookup response:",
|
|
132
|
+
JSON.stringify(updateLookupResponse, null, 2)
|
|
133
|
+
);
|
|
204
134
|
|
|
205
|
-
const newLookup =
|
|
135
|
+
const newLookup = updateLookupResponse.lookupFields.find(
|
|
206
136
|
(f) =>
|
|
207
137
|
f.label.includes("Test Lookup Field") &&
|
|
208
138
|
!currentLookupFields.includes(f.id)
|
|
@@ -212,29 +142,22 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
212
142
|
`Captured generated lookup field ID: ${generatedLookupFieldId}`
|
|
213
143
|
);
|
|
214
144
|
|
|
215
|
-
expect(
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
145
|
+
expect(updateLookupResponse.id).toBe(relationshipId);
|
|
146
|
+
expect(updateLookupResponse.childTableId).toBe(QB_TABLE_ID_2);
|
|
147
|
+
expect(updateLookupResponse.parentTableId).toBe(QB_TABLE_ID_1);
|
|
218
148
|
expect(
|
|
219
|
-
|
|
149
|
+
updateLookupResponse.lookupFields.some((f) =>
|
|
220
150
|
f.label.includes("Test Lookup Field")
|
|
221
151
|
)
|
|
222
152
|
).toBe(true);
|
|
223
|
-
expect(updateResponse.lookupFields.some((f) => f.id === 19)).toBe(true);
|
|
224
|
-
}, 30000);
|
|
225
153
|
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
const initialResponse = await client.getRelationships({ tableId });
|
|
229
|
-
const initialRelationship = initialResponse.relationships.find(
|
|
230
|
-
(r) => r.id === relationshipId
|
|
231
|
-
);
|
|
232
|
-
const currentSummaryFields = (initialRelationship?.summaryFields || []).map(
|
|
154
|
+
// Step 4: Update the relationship - Add a summary field
|
|
155
|
+
const currentSummaryFields = (updateLookupResponse.summaryFields || []).map(
|
|
233
156
|
(f) => f.id
|
|
234
157
|
);
|
|
235
158
|
console.log("Current summaryFields before update:", currentSummaryFields);
|
|
236
159
|
|
|
237
|
-
const
|
|
160
|
+
const updateSummaryRequest: UpdateRelationshipRequest = {
|
|
238
161
|
summaryFields: [
|
|
239
162
|
{
|
|
240
163
|
summaryFid: newChildFieldId!,
|
|
@@ -245,17 +168,20 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
245
168
|
};
|
|
246
169
|
console.log(
|
|
247
170
|
"Adding summary field request:",
|
|
248
|
-
JSON.stringify(
|
|
171
|
+
JSON.stringify(updateSummaryRequest, null, 2)
|
|
249
172
|
);
|
|
250
173
|
|
|
251
|
-
const
|
|
174
|
+
const updateSummaryResponse = await client.updateRelationship({
|
|
252
175
|
tableId,
|
|
253
176
|
relationshipId,
|
|
254
|
-
body:
|
|
177
|
+
body: updateSummaryRequest,
|
|
255
178
|
});
|
|
256
|
-
console.log(
|
|
179
|
+
console.log(
|
|
180
|
+
"Update summary response:",
|
|
181
|
+
JSON.stringify(updateSummaryResponse, null, 2)
|
|
182
|
+
);
|
|
257
183
|
|
|
258
|
-
const newSummary =
|
|
184
|
+
const newSummary = updateSummaryResponse.summaryFields.find(
|
|
259
185
|
(f) =>
|
|
260
186
|
f.label.includes("Test Summary Field") &&
|
|
261
187
|
!currentSummaryFields.includes(f.id)
|
|
@@ -265,13 +191,60 @@ describe("QuickbaseClient Integration - updateRelationship", () => {
|
|
|
265
191
|
`Captured generated summary field ID: ${generatedSummaryFieldId}`
|
|
266
192
|
);
|
|
267
193
|
|
|
268
|
-
expect(
|
|
269
|
-
expect(
|
|
270
|
-
expect(
|
|
194
|
+
expect(updateSummaryResponse.id).toBe(relationshipId);
|
|
195
|
+
expect(updateSummaryResponse.childTableId).toBe(QB_TABLE_ID_2);
|
|
196
|
+
expect(updateSummaryResponse.parentTableId).toBe(QB_TABLE_ID_1);
|
|
271
197
|
expect(
|
|
272
|
-
|
|
198
|
+
updateSummaryResponse.summaryFields.some((f) =>
|
|
273
199
|
f.label.includes("Test Summary Field")
|
|
274
200
|
)
|
|
275
201
|
).toBe(true);
|
|
276
|
-
|
|
202
|
+
|
|
203
|
+
// Step 5: Delete the relationship
|
|
204
|
+
console.log("Deleting relationship with ID:", relationshipId);
|
|
205
|
+
const deleteResponse = await client.deleteRelationship({
|
|
206
|
+
tableId: QB_TABLE_ID_2,
|
|
207
|
+
relationshipId,
|
|
208
|
+
});
|
|
209
|
+
console.log(
|
|
210
|
+
"Delete relationship response:",
|
|
211
|
+
JSON.stringify(deleteResponse, null, 2)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(deleteResponse).toBeDefined();
|
|
215
|
+
expect(deleteResponse.relationshipId).toBe(relationshipId);
|
|
216
|
+
|
|
217
|
+
// Step 6: Verify deletion
|
|
218
|
+
const relationships = await client.getRelationships({
|
|
219
|
+
tableId: QB_TABLE_ID_2,
|
|
220
|
+
});
|
|
221
|
+
console.log(
|
|
222
|
+
"Relationships after deletion:",
|
|
223
|
+
JSON.stringify(relationships, null, 2)
|
|
224
|
+
);
|
|
225
|
+
const deletedRelationship = relationships.relationships.find(
|
|
226
|
+
(r) => r.id === relationshipId
|
|
227
|
+
);
|
|
228
|
+
expect(deletedRelationship).toBeUndefined(); // Confirm relationship is gone
|
|
229
|
+
|
|
230
|
+
// Cleanup remaining fields from setup
|
|
231
|
+
if (newLookupFieldId) {
|
|
232
|
+
await client.deleteFields({
|
|
233
|
+
tableId: QB_TABLE_ID_1,
|
|
234
|
+
body: { fieldIds: [newLookupFieldId] },
|
|
235
|
+
});
|
|
236
|
+
console.log(
|
|
237
|
+
`Deleted lookup field ${newLookupFieldId} from ${QB_TABLE_ID_1}`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (newChildFieldId) {
|
|
241
|
+
await client.deleteFields({
|
|
242
|
+
tableId: QB_TABLE_ID_2,
|
|
243
|
+
body: { fieldIds: [newChildFieldId] },
|
|
244
|
+
});
|
|
245
|
+
console.log(
|
|
246
|
+
`Deleted child field ${newChildFieldId} from ${QB_TABLE_ID_2}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}, 60000);
|
|
277
250
|
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { ThrottleBucket } from "@/ThrottleBucket"; // Adjust path as needed
|
|
3
|
+
|
|
4
|
+
describe("ThrottleBucket Unit Tests", () => {
|
|
5
|
+
it("allows burst capacity immediately", async () => {
|
|
6
|
+
const bucket = new ThrottleBucket(5, 3); // rate: 5, burst: 3
|
|
7
|
+
const startTime = Date.now();
|
|
8
|
+
|
|
9
|
+
await Promise.all([bucket.acquire(), bucket.acquire(), bucket.acquire()]);
|
|
10
|
+
|
|
11
|
+
const duration = Date.now() - startTime;
|
|
12
|
+
expect(duration).toBeLessThan(50); // All 3 should be instant (within 50ms)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("delays calls beyond burst at correct rate", async () => {
|
|
16
|
+
const bucket = new ThrottleBucket(5, 3); // rate: 5 tokens/sec, burst: 3
|
|
17
|
+
const timings: number[] = [];
|
|
18
|
+
const recordTime = async () => {
|
|
19
|
+
await bucket.acquire();
|
|
20
|
+
timings.push(Date.now());
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
await Promise.all([
|
|
25
|
+
recordTime(),
|
|
26
|
+
recordTime(),
|
|
27
|
+
recordTime(),
|
|
28
|
+
recordTime(), // 4th call should delay
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const durations = timings.map((t) => t - startTime);
|
|
32
|
+
expect(durations.length).toBe(4);
|
|
33
|
+
expect(durations[0]).toBeLessThan(50); // 1st call instant
|
|
34
|
+
expect(durations[1]).toBeLessThan(50); // 2nd call instant
|
|
35
|
+
expect(durations[2]).toBeLessThan(50); // 3rd call instant
|
|
36
|
+
expect(durations[3]).toBeGreaterThanOrEqual(180); // 4th call delayed ~200ms
|
|
37
|
+
expect(durations[3]).toBeLessThan(250); // Shouldn’t exceed 250ms
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("maintains rate over sustained calls", async () => {
|
|
41
|
+
const bucket = new ThrottleBucket(2, 2); // rate: 2 tokens/sec, burst: 2
|
|
42
|
+
const timings: number[] = [];
|
|
43
|
+
const recordTime = async () => {
|
|
44
|
+
await bucket.acquire();
|
|
45
|
+
timings.push(Date.now());
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const startTime = Date.now();
|
|
49
|
+
await Promise.all([
|
|
50
|
+
recordTime(),
|
|
51
|
+
recordTime(),
|
|
52
|
+
recordTime(),
|
|
53
|
+
recordTime(),
|
|
54
|
+
recordTime(), // 5 calls total
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const durations = timings.map((t) => t - startTime);
|
|
58
|
+
expect(durations.length).toBe(5);
|
|
59
|
+
expect(durations[0]).toBeLessThan(50); // 1st instant
|
|
60
|
+
expect(durations[1]).toBeLessThan(50); // 2nd instant
|
|
61
|
+
expect(durations[2]).toBeGreaterThanOrEqual(450); // 3rd delayed ~500ms (1/2 sec)
|
|
62
|
+
expect(durations[2]).toBeLessThan(550);
|
|
63
|
+
expect(durations[3]).toBeGreaterThanOrEqual(950); // 4th delayed ~1000ms
|
|
64
|
+
expect(durations[3]).toBeLessThan(1050);
|
|
65
|
+
expect(durations[4]).toBeGreaterThanOrEqual(1450); // 5th delayed ~1500ms
|
|
66
|
+
expect(durations[4]).toBeLessThan(1550);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles high concurrency without exceeding burst", async () => {
|
|
70
|
+
const bucket = new ThrottleBucket(5, 3); // rate: 5, burst: 3
|
|
71
|
+
const timings: number[] = [];
|
|
72
|
+
const recordTime = async () => {
|
|
73
|
+
await bucket.acquire();
|
|
74
|
+
timings.push(Date.now());
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const startTime = Date.now();
|
|
78
|
+
await Promise.all(
|
|
79
|
+
Array(10)
|
|
80
|
+
.fill(null)
|
|
81
|
+
.map(() => recordTime())
|
|
82
|
+
); // 10 concurrent calls
|
|
83
|
+
|
|
84
|
+
const durations = timings.map((t) => t - startTime);
|
|
85
|
+
expect(durations.length).toBe(10);
|
|
86
|
+
// First 3 should be instant
|
|
87
|
+
expect(durations.slice(0, 3).every((d) => d < 50)).toBe(true);
|
|
88
|
+
// Next calls should be staggered at ~200ms intervals
|
|
89
|
+
for (let i = 3; i < 10; i++) {
|
|
90
|
+
const expectedDelay = (i - 2) * 200; // After burst, 200ms per token
|
|
91
|
+
expect(durations[i]).toBeGreaterThanOrEqual(expectedDelay - 50);
|
|
92
|
+
expect(durations[i]).toBeLessThan(expectedDelay + 50);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// tests/vitest/unit/apps/copyApp.test.ts
|
|
2
|
-
|
|
3
1
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
4
2
|
import {
|
|
5
3
|
createClient,
|
|
@@ -45,8 +43,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
45
43
|
id: "bpqe82s1",
|
|
46
44
|
name: "Copied App",
|
|
47
45
|
description: "A copy of the original app",
|
|
48
|
-
created: new Date("2025-03-11T10:00:00Z"),
|
|
49
|
-
updated: new Date("2025-03-11T10:00:00Z"),
|
|
46
|
+
created: new Date("2025-03-11T10:00:00Z"),
|
|
47
|
+
updated: new Date("2025-03-11T10:00:00Z"),
|
|
50
48
|
dateFormat: "MM-DD-YYYY",
|
|
51
49
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
52
50
|
hasEveryoneOnTheInternet: false,
|
|
@@ -97,8 +95,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
97
95
|
id: "bpqe82s1",
|
|
98
96
|
name: "Copied App",
|
|
99
97
|
description: "A copy of the original app",
|
|
100
|
-
created: "2025-03-11T10:00:00Z",
|
|
101
|
-
updated: "2025-03-11T10:00:00Z",
|
|
98
|
+
created: "2025-03-11T10:00:00Z",
|
|
99
|
+
updated: "2025-03-11T10:00:00Z",
|
|
102
100
|
dateFormat: "MM-DD-YYYY",
|
|
103
101
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
104
102
|
hasEveryoneOnTheInternet: false,
|
|
@@ -149,8 +147,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
149
147
|
id: "bpqe82s2",
|
|
150
148
|
name: "Copied App with Temp Token",
|
|
151
149
|
description: "A copy using temp token",
|
|
152
|
-
created: new Date("2025-03-11T11:00:00Z"),
|
|
153
|
-
updated: new Date("2025-03-11T11:00:00Z"),
|
|
150
|
+
created: new Date("2025-03-11T11:00:00Z"),
|
|
151
|
+
updated: new Date("2025-03-11T11:00:00Z"),
|
|
154
152
|
dateFormat: "MM-DD-YYYY",
|
|
155
153
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
156
154
|
hasEveryoneOnTheInternet: true,
|
|
@@ -178,27 +176,15 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
178
176
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
179
177
|
1,
|
|
180
178
|
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
181
|
-
expect.
|
|
182
|
-
method: "GET",
|
|
183
|
-
headers: expect.objectContaining({
|
|
184
|
-
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
185
|
-
"Content-Type": "application/json",
|
|
186
|
-
}),
|
|
187
|
-
credentials: "include",
|
|
188
|
-
})
|
|
179
|
+
expect.any(Object)
|
|
189
180
|
);
|
|
190
181
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
191
182
|
2,
|
|
192
183
|
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
193
184
|
expect.objectContaining({
|
|
194
|
-
method: "POST",
|
|
195
185
|
headers: expect.objectContaining({
|
|
196
|
-
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
197
186
|
Authorization: "QB-TEMP-TOKEN temp_token",
|
|
198
|
-
"Content-Type": "application/json",
|
|
199
187
|
}),
|
|
200
|
-
credentials: "omit",
|
|
201
|
-
body: JSON.stringify(request),
|
|
202
188
|
})
|
|
203
189
|
);
|
|
204
190
|
});
|
|
@@ -225,8 +211,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
225
211
|
id: "bpqe82s2",
|
|
226
212
|
name: "Copied App with Temp Token",
|
|
227
213
|
description: "A copy using temp token",
|
|
228
|
-
created: "2025-03-11T11:00:00Z",
|
|
229
|
-
updated: "2025-03-11T11:00:00Z",
|
|
214
|
+
created: "2025-03-11T11:00:00Z",
|
|
215
|
+
updated: "2025-03-11T11:00:00Z",
|
|
230
216
|
dateFormat: "MM-DD-YYYY",
|
|
231
217
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
232
218
|
hasEveryoneOnTheInternet: true,
|
|
@@ -254,27 +240,15 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
254
240
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
255
241
|
1,
|
|
256
242
|
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
257
|
-
expect.
|
|
258
|
-
method: "GET",
|
|
259
|
-
headers: expect.objectContaining({
|
|
260
|
-
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
261
|
-
"Content-Type": "application/json",
|
|
262
|
-
}),
|
|
263
|
-
credentials: "include",
|
|
264
|
-
})
|
|
243
|
+
expect.any(Object)
|
|
265
244
|
);
|
|
266
245
|
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
267
246
|
2,
|
|
268
247
|
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
269
248
|
expect.objectContaining({
|
|
270
|
-
method: "POST",
|
|
271
249
|
headers: expect.objectContaining({
|
|
272
|
-
"QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
|
|
273
250
|
Authorization: "QB-TEMP-TOKEN temp_token",
|
|
274
|
-
"Content-Type": "application/json",
|
|
275
251
|
}),
|
|
276
|
-
credentials: "omit",
|
|
277
|
-
body: JSON.stringify(request),
|
|
278
252
|
})
|
|
279
253
|
);
|
|
280
254
|
});
|
|
@@ -297,8 +271,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
297
271
|
id: "bpqe82s3",
|
|
298
272
|
name: "Retry Copied App",
|
|
299
273
|
description: "Retry after 401",
|
|
300
|
-
created: new Date("2025-03-11T12:00:00Z"),
|
|
301
|
-
updated: new Date("2025-03-11T12:00:00Z"),
|
|
274
|
+
created: new Date("2025-03-11T12:00:00Z"),
|
|
275
|
+
updated: new Date("2025-03-11T12:00:00Z"),
|
|
302
276
|
dateFormat: "MM-DD-YYYY",
|
|
303
277
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
304
278
|
hasEveryoneOnTheInternet: false,
|
|
@@ -386,8 +360,8 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
386
360
|
id: "bpqe82s3",
|
|
387
361
|
name: "Retry Copied App",
|
|
388
362
|
description: "Retry after 401",
|
|
389
|
-
created: "2025-03-11T12:00:00Z",
|
|
390
|
-
updated: "2025-03-11T12:00:00Z",
|
|
363
|
+
created: "2025-03-11T12:00:00Z",
|
|
364
|
+
updated: "2025-03-11T12:00:00Z",
|
|
391
365
|
dateFormat: "MM-DD-YYYY",
|
|
392
366
|
timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
|
|
393
367
|
hasEveryoneOnTheInternet: false,
|
|
@@ -485,4 +459,123 @@ describe("QuickbaseClient Unit - copyApp", () => {
|
|
|
485
459
|
})
|
|
486
460
|
);
|
|
487
461
|
});
|
|
462
|
+
|
|
463
|
+
it("exhausts default maxRetries (3) with temp token and throws error", async () => {
|
|
464
|
+
client = createClient(mockFetch, { useTempTokens: true, debug: true }); // No explicit maxRetries, uses default 3
|
|
465
|
+
|
|
466
|
+
const request: CopyAppRequest = {
|
|
467
|
+
name: "Fail App",
|
|
468
|
+
description: "Test retry limit",
|
|
469
|
+
properties: {
|
|
470
|
+
keepData: false,
|
|
471
|
+
excludeFiles: true,
|
|
472
|
+
usersAndRoles: false,
|
|
473
|
+
assignUserToken: true,
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
mockFetch
|
|
478
|
+
.mockResolvedValueOnce({
|
|
479
|
+
ok: true,
|
|
480
|
+
status: 200,
|
|
481
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_1" }),
|
|
482
|
+
})
|
|
483
|
+
.mockResolvedValueOnce({
|
|
484
|
+
ok: false,
|
|
485
|
+
status: 401,
|
|
486
|
+
json: () => Promise.resolve({ message: "Unauthorized 1" }),
|
|
487
|
+
})
|
|
488
|
+
.mockResolvedValueOnce({
|
|
489
|
+
ok: true,
|
|
490
|
+
status: 200,
|
|
491
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_2" }),
|
|
492
|
+
})
|
|
493
|
+
.mockResolvedValueOnce({
|
|
494
|
+
ok: false,
|
|
495
|
+
status: 401,
|
|
496
|
+
json: () => Promise.resolve({ message: "Unauthorized 2" }),
|
|
497
|
+
})
|
|
498
|
+
.mockResolvedValueOnce({
|
|
499
|
+
ok: true,
|
|
500
|
+
status: 200,
|
|
501
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_3" }),
|
|
502
|
+
})
|
|
503
|
+
.mockResolvedValueOnce({
|
|
504
|
+
ok: false,
|
|
505
|
+
status: 401,
|
|
506
|
+
json: () => Promise.resolve({ message: "Unauthorized 3" }),
|
|
507
|
+
})
|
|
508
|
+
.mockResolvedValueOnce({
|
|
509
|
+
ok: true,
|
|
510
|
+
status: 200,
|
|
511
|
+
json: () => Promise.resolve({ temporaryAuthorization: "token_4" }),
|
|
512
|
+
})
|
|
513
|
+
.mockResolvedValueOnce({
|
|
514
|
+
ok: false,
|
|
515
|
+
status: 401,
|
|
516
|
+
json: () => Promise.resolve({ message: "Unauthorized 4" }),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
await expect(
|
|
520
|
+
client.copyApp({ appId: QB_APP_ID, body: request })
|
|
521
|
+
).rejects.toThrow("API Error: Unauthorized 4 (Status: 401)");
|
|
522
|
+
|
|
523
|
+
expect(mockFetch).toHaveBeenCalledTimes(8); // 4 token fetches + 4 API attempts
|
|
524
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
525
|
+
1,
|
|
526
|
+
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
527
|
+
expect.any(Object)
|
|
528
|
+
);
|
|
529
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
530
|
+
2,
|
|
531
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
532
|
+
expect.objectContaining({
|
|
533
|
+
headers: expect.objectContaining({
|
|
534
|
+
Authorization: "QB-TEMP-TOKEN token_1",
|
|
535
|
+
}),
|
|
536
|
+
})
|
|
537
|
+
);
|
|
538
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
539
|
+
3,
|
|
540
|
+
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
541
|
+
expect.any(Object)
|
|
542
|
+
);
|
|
543
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
544
|
+
4,
|
|
545
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
546
|
+
expect.objectContaining({
|
|
547
|
+
headers: expect.objectContaining({
|
|
548
|
+
Authorization: "QB-TEMP-TOKEN token_2",
|
|
549
|
+
}),
|
|
550
|
+
})
|
|
551
|
+
);
|
|
552
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
553
|
+
5,
|
|
554
|
+
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
555
|
+
expect.any(Object)
|
|
556
|
+
);
|
|
557
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
558
|
+
6,
|
|
559
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
560
|
+
expect.objectContaining({
|
|
561
|
+
headers: expect.objectContaining({
|
|
562
|
+
Authorization: "QB-TEMP-TOKEN token_3",
|
|
563
|
+
}),
|
|
564
|
+
})
|
|
565
|
+
);
|
|
566
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
567
|
+
7,
|
|
568
|
+
`https://api.quickbase.com/v1/auth/temporary/${QB_APP_ID}`,
|
|
569
|
+
expect.any(Object)
|
|
570
|
+
);
|
|
571
|
+
expect(mockFetch).toHaveBeenNthCalledWith(
|
|
572
|
+
8,
|
|
573
|
+
`https://api.quickbase.com/v1/apps/${QB_APP_ID}/copy`,
|
|
574
|
+
expect.objectContaining({
|
|
575
|
+
headers: expect.objectContaining({
|
|
576
|
+
Authorization: "QB-TEMP-TOKEN token_4",
|
|
577
|
+
}),
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
});
|
|
488
581
|
});
|