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.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +46 -130
  3. package/dist/esm/quickbase.d.ts +822 -119
  4. package/dist/esm/quickbase.js +373 -236
  5. package/dist/esm/quickbase.js.map +1 -1
  6. package/dist/esm/quickbase.min.js +1 -1
  7. package/dist/esm/quickbase.min.js.map +1 -1
  8. package/dist/umd/quickbase.umd.js +373 -236
  9. package/dist/umd/quickbase.umd.js.map +1 -1
  10. package/dist/umd/quickbase.umd.min.js +1 -1
  11. package/dist/umd/quickbase.umd.min.js.map +1 -1
  12. package/open-api/generate-unified-interface.ts +97 -17
  13. package/open-api/utils/generateJsDoc.ts +96 -0
  14. package/package.json +3 -1
  15. package/src/ThrottleBucket.ts +17 -12
  16. package/src/authorizationStrategy.ts +246 -0
  17. package/src/generated-unified/QuickbaseClient.ts +809 -119
  18. package/src/invokeMethod.ts +150 -265
  19. package/src/quickbaseClient.ts +85 -58
  20. package/src/rateLimiter.ts +25 -0
  21. package/src/tokenCache.ts +10 -26
  22. package/tests/vitest/qb/auth/mockQuickbaseServer.ts +60 -0
  23. package/tests/vitest/qb/auth/retryUserToken401.test.ts +59 -42
  24. package/tests/vitest/qb/auth/ssoTokenIntegrationWithServer.test.ts +65 -0
  25. package/tests/vitest/qb/fields/getFieldUsage.test.ts +58 -150
  26. package/tests/vitest/qb/formulas/runFormula.test.ts +96 -0
  27. package/tests/vitest/qb/tables/createRelationship.test.ts +102 -0
  28. package/tests/vitest/qb/tables/deleteRelationship.test.ts +143 -0
  29. package/tests/vitest/qb/tables/updateRelationship.test.ts +118 -145
  30. package/tests/vitest/unit/ThrottleBucket.test.ts +95 -0
  31. package/tests/vitest/unit/apps/copyApp.test.ts +133 -40
  32. package/tests/vitest/unit/apps/updateApp.test.ts +42 -11
  33. package/tests/vitest/unit/auth/fetchTempToken401.test.ts +3 -2
  34. package/tests/vitest/unit/auth/getTempToken.test.ts +42 -18
  35. package/tests/vitest/unit/auth/retryTempToken401.test.ts +46 -64
  36. package/tests/vitest/unit/auth/retryTempTokenMaxRetries.test.ts +235 -0
  37. package/tests/vitest/unit/auth/retryUserToken401.test.ts +56 -43
  38. package/tests/vitest/unit/auth/ssoTokenRefresh.test.ts +325 -0
  39. package/tests/vitest/unit/fields/createField.test.ts +180 -116
  40. package/tests/vitest/unit/fields/getFieldUsage.test.ts +17 -3
  41. package/tests/vitest/unit/fields/updateField.test.ts +12 -7
  42. package/tests/vitest/unit/formulas/runFormula.test.ts +205 -0
  43. package/tests/vitest/unit/index.ts +2 -1
  44. package/tests/vitest/unit/inferHttpMethod.test.ts +131 -0
  45. package/tests/vitest/unit/rateLimit.test.ts +4 -2
  46. package/tests/vitest/unit/tables/createRelationship.test.ts +212 -0
  47. package/tests/vitest/unit/tables/deleteRelationship.test.ts +97 -0
  48. package/tests/vitest/unit/tables/updateRelationship.test.ts +12 -10
  49. package/tests/vitest/unit/tokenLifespan.test.ts +149 -83
  50. package/tools/log-gen.ts +14 -5
  51. package/tools/log-gen.yaml +710 -268
  52. package/tests/vitest/unit/auth/retryTempTokenTwo401.test.ts +0 -81
@@ -1,4 +1,6 @@
1
- import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
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(async () => {
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
- const relationshipsResponse = await client.getRelationships({
33
- tableId: QB_TABLE_ID_2,
34
- skip: 0,
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
- console.log(
84
- `Setup complete: relationshipId=${relationshipId}, lookupFieldId=${newLookupFieldId}, childFieldId=${newChildFieldId}`
85
- );
86
- }, 30000);
87
-
88
- afterAll(async () => {
89
- if (newLookupFieldId) {
90
- try {
91
- const deleteLookupResponse = await client.deleteFields({
92
- tableId: QB_TABLE_ID_1,
93
- body: { fieldIds: [newLookupFieldId] },
94
- });
95
- console.log(
96
- `Deleted original lookup field ${newLookupFieldId} from ${QB_TABLE_ID_1}:`,
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
- beforeEach(async () => {
164
- const relationshipsResponse = await client.getRelationships({
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
- "Relationship state before test:",
175
- JSON.stringify(relationship, null, 2)
94
+ "Created relationship response:",
95
+ JSON.stringify(createResponse, null, 2)
176
96
  );
177
- }, 30000);
178
97
 
179
- it("adds a lookup field to the relationship", async ({ expect }) => {
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 request: UpdateRelationshipRequest = {
117
+ const updateLookupRequest: UpdateRelationshipRequest = {
191
118
  lookupFieldIds: [newLookupFieldId!],
192
119
  };
193
120
  console.log(
194
121
  "Adding lookup field request:",
195
- JSON.stringify(request, null, 2)
122
+ JSON.stringify(updateLookupRequest, null, 2)
196
123
  );
197
124
 
198
- const updateResponse = await client.updateRelationship({
125
+ const updateLookupResponse = await client.updateRelationship({
199
126
  tableId,
200
127
  relationshipId,
201
- body: request,
128
+ body: updateLookupRequest,
202
129
  });
203
- console.log("Update response:", JSON.stringify(updateResponse, null, 2));
130
+ console.log(
131
+ "Update lookup response:",
132
+ JSON.stringify(updateLookupResponse, null, 2)
133
+ );
204
134
 
205
- const newLookup = updateResponse.lookupFields.find(
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(updateResponse.id).toBe(relationshipId);
216
- expect(updateResponse.childTableId).toBe(QB_TABLE_ID_2);
217
- expect(updateResponse.parentTableId).toBe(QB_TABLE_ID_1);
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
- updateResponse.lookupFields.some((f) =>
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
- it("adds a summary field to the parent table", async ({ expect }) => {
227
- const tableId = QB_TABLE_ID_2;
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 request: UpdateRelationshipRequest = {
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(request, null, 2)
171
+ JSON.stringify(updateSummaryRequest, null, 2)
249
172
  );
250
173
 
251
- const updateResponse = await client.updateRelationship({
174
+ const updateSummaryResponse = await client.updateRelationship({
252
175
  tableId,
253
176
  relationshipId,
254
- body: request,
177
+ body: updateSummaryRequest,
255
178
  });
256
- console.log("Update response:", JSON.stringify(updateResponse, null, 2));
179
+ console.log(
180
+ "Update summary response:",
181
+ JSON.stringify(updateSummaryResponse, null, 2)
182
+ );
257
183
 
258
- const newSummary = updateResponse.summaryFields.find(
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(updateResponse.id).toBe(relationshipId);
269
- expect(updateResponse.childTableId).toBe(QB_TABLE_ID_2);
270
- expect(updateResponse.parentTableId).toBe(QB_TABLE_ID_1);
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
- updateResponse.summaryFields.some((f) =>
198
+ updateSummaryResponse.summaryFields.some((f) =>
273
199
  f.label.includes("Test Summary Field")
274
200
  )
275
201
  ).toBe(true);
276
- }, 30000);
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"), // Expect Date object
49
- updated: new Date("2025-03-11T10:00:00Z"), // Expect Date object
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", // Expect string
101
- updated: "2025-03-11T10:00:00Z", // Expect string
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"), // Expect Date object
153
- updated: new Date("2025-03-11T11:00:00Z"), // Expect Date object
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.objectContaining({
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", // Expect string
229
- updated: "2025-03-11T11:00:00Z", // Expect string
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.objectContaining({
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"), // Expect Date object
301
- updated: new Date("2025-03-11T12:00:00Z"), // Expect Date object
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", // Expect string
390
- updated: "2025-03-11T12:00:00Z", // Expect string
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
  });