quickbase-js 0.1.0-beta.10 → 0.1.0-beta.12

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 (35) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +56 -24
  3. package/dist/esm/quickbase.d.ts +1 -0
  4. package/dist/esm/quickbase.js +125 -87
  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 +125 -87
  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/examples/quicbase-jsCDN.html +2 -2
  13. package/package.json +1 -1
  14. package/src/ThrottleBucket.ts +52 -16
  15. package/src/authorizationStrategy.ts +62 -35
  16. package/src/generated-unified/QuickbaseClient.ts +1 -1
  17. package/src/invokeMethod.ts +33 -66
  18. package/src/quickbaseClient.ts +12 -7
  19. package/src/rateLimiter.ts +8 -3
  20. package/src/tokenCache.ts +4 -0
  21. package/tests/playwright/qb/auth/getAppWithTempTokenConcurrency.test.ts +208 -0
  22. package/tests/vitest/qb/ThrottleBucket.test.ts +77 -0
  23. package/tests/vitest/qb/auth/ssoTokenConcurrency.test.ts +68 -0
  24. package/tests/vitest/qb/auth/userTokenConcurrency.test.ts +63 -0
  25. package/tests/vitest/qb/fields/getFieldsUsage.test.ts +2 -2
  26. package/tests/vitest/unit/ThrottleBucket.test.ts +16 -10
  27. package/tests/vitest/unit/apps/getAppConcurrency.test.ts +151 -0
  28. package/tests/vitest/unit/auth/fetchTempToken401.test.ts +20 -2
  29. package/tests/vitest/unit/auth/userTokenConcurrency.test.ts +62 -0
  30. package/tests/vitest/unit/index.ts +1 -1
  31. package/tests/vitest/unit/quickbaseClientHttpMethods.test.ts +3 -3
  32. package/tools/log-gen.ts +1 -1
  33. package/tools/log-gen.yaml +130 -78
  34. package/api-examples.md +0 -49
  35. package/markdown-examples.md +0 -85
@@ -0,0 +1,208 @@
1
+ // tests/playwright/qb/auth/getAppWithTempTokenConcurrency.test.ts
2
+ import { test, expect } from "@playwright/test";
3
+ import { quickbase } from "../../../../src/quickbaseClient.ts"; // Keep .ts for now
4
+ import dotenv from "dotenv";
5
+
6
+ dotenv.config();
7
+
8
+ const loginToQuickbase = async (
9
+ page: import("@playwright/test").Page,
10
+ quickbaseUrl: string,
11
+ username: string,
12
+ password: string
13
+ ): Promise<boolean> => {
14
+ let loginSuccess = false;
15
+ const maxLoginAttempts = 3;
16
+ let loginAttempt = 0;
17
+
18
+ while (!loginSuccess && loginAttempt < maxLoginAttempts) {
19
+ try {
20
+ loginAttempt++;
21
+ if (loginAttempt > 1) console.log(`Login attempt ${loginAttempt}`);
22
+
23
+ await page.goto(quickbaseUrl, { timeout: 60000 });
24
+ await page.waitForSelector("input[name='loginid']", { timeout: 60000 });
25
+ await page.fill("input[name='loginid']", username);
26
+ await page.fill("input[name='password']", password);
27
+ await page.click("#signin");
28
+ await page.waitForURL(`https://*.quickbase.com/**`, {
29
+ waitUntil: "networkidle",
30
+ timeout: 60000,
31
+ });
32
+
33
+ const loginError = await page.$(".login-error");
34
+ if (loginError) {
35
+ throw new Error("Login failed. Please check your credentials.");
36
+ }
37
+
38
+ console.log("Signed In to QuickBase.");
39
+ loginSuccess = true;
40
+ } catch (error) {
41
+ console.error(
42
+ `Login attempt ${loginAttempt} failed: ${(error as Error).message}`
43
+ );
44
+ if (loginAttempt >= maxLoginAttempts) {
45
+ console.error("Max login attempts reached. Exiting.");
46
+ await page.context().browser()!.close();
47
+ return false;
48
+ }
49
+ await new Promise((resolve) => setTimeout(resolve, 5000));
50
+ }
51
+ }
52
+ return loginSuccess;
53
+ };
54
+
55
+ test.describe("QuickbaseClient Integration - getApp with Temporary Token Concurrency", () => {
56
+ test("handles concurrent getApp requests with a single token fetch", async ({
57
+ page,
58
+ }) => {
59
+ const realm = process.env.QB_REALM;
60
+ const appId = process.env.QB_APP_ID;
61
+ const username = process.env.QB_USERNAME;
62
+ const password = process.env.QB_PASSWORD;
63
+
64
+ if (!realm) throw new Error("QB_REALM is not defined in .env");
65
+ if (!appId) throw new Error("QB_APP_ID is not defined in .env");
66
+ if (!username) throw new Error("QB_USERNAME is not defined in .env");
67
+ if (!password) throw new Error("QB_PASSWORD is not defined in .env");
68
+
69
+ const fetchInits: { url: string; init: RequestInit }[] = [];
70
+
71
+ const quickbaseUrl = `https://${realm}.quickbase.com/db/main?a=SignIn`;
72
+ const loginSuccess = await loginToQuickbase(
73
+ page,
74
+ quickbaseUrl,
75
+ username,
76
+ password
77
+ );
78
+ if (!loginSuccess) {
79
+ throw new Error("Failed to log in to QuickBase after max attempts.");
80
+ }
81
+
82
+ await page.goto(`https://${realm}.quickbase.com/db/${appId}`, {
83
+ waitUntil: "networkidle",
84
+ timeout: 60000,
85
+ });
86
+
87
+ const client = quickbase({
88
+ realm,
89
+ useTempTokens: true,
90
+ debug: true, // Enable debug logs to verify concurrency behavior
91
+ fetchApi: async (url: RequestInfo | URL, init?: RequestInit) => {
92
+ const effectiveInit = init || {};
93
+ fetchInits.push({ url: String(url), init: effectiveInit });
94
+ const response = await page.evaluate(
95
+ async (args: [string, RequestInit]) => {
96
+ const [fetchUrl, fetchInit] = args;
97
+ const res = await fetch(fetchUrl, fetchInit);
98
+ const body = await res.text();
99
+ const headersObj: Record<string, string> = {};
100
+ res.headers.forEach((value, key) => {
101
+ headersObj[key] = value;
102
+ });
103
+ return {
104
+ ok: res.ok,
105
+ status: res.status,
106
+ statusText: res.statusText,
107
+ headers: headersObj,
108
+ body,
109
+ };
110
+ },
111
+ [String(url), effectiveInit] as [string, RequestInit]
112
+ );
113
+
114
+ const fetchResponse = new Response(response.body || null, {
115
+ status: response.status,
116
+ statusText: response.statusText,
117
+ headers: new Headers(response.headers),
118
+ });
119
+
120
+ fetchResponse.json = async () => {
121
+ if (!response.body) {
122
+ throw new Error("Empty response body from API");
123
+ }
124
+ try {
125
+ return JSON.parse(response.body);
126
+ } catch (e) {
127
+ throw new SyntaxError(`Invalid JSON response: ${response.body}`);
128
+ }
129
+ };
130
+
131
+ return fetchResponse;
132
+ },
133
+ });
134
+
135
+ // Make three concurrent getApp requests
136
+ const concurrentRequests = [
137
+ client.getApp({ appId }),
138
+ client.getApp({ appId }),
139
+ client.getApp({ appId }),
140
+ ];
141
+
142
+ // Wait for all responses to complete
143
+ await Promise.all([
144
+ page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
145
+ timeout: 30000,
146
+ }),
147
+ page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
148
+ timeout: 30000,
149
+ }),
150
+ page.waitForResponse(`https://api.quickbase.com/v1/apps/${appId}`, {
151
+ timeout: 30000,
152
+ }),
153
+ ]);
154
+
155
+ const results = await Promise.all(concurrentRequests);
156
+
157
+ // Expected app data (based on your previous test)
158
+ const expectedAppData = {
159
+ id: appId,
160
+ name: "qb-copy",
161
+ created: expect.any(Date),
162
+ updated: expect.any(Date),
163
+ description: "",
164
+ timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
165
+ dateFormat: "MM-DD-YYYY",
166
+ hasEveryoneOnTheInternet: false,
167
+ memoryInfo: { estMemory: 0, estMemoryInclDependentApps: 0 },
168
+ securityProperties: {
169
+ allowClone: false,
170
+ allowExport: true,
171
+ enableAppTokens: true,
172
+ hideFromPublic: false,
173
+ mustBeRealmApproved: false,
174
+ useIPFilter: false,
175
+ },
176
+ ancestorId: undefined,
177
+ dataClassification: undefined,
178
+ variables: [{ name: "TestVar", value: "TestValue" }],
179
+ };
180
+
181
+ // Assert all results match the expected data
182
+ expect(results).toHaveLength(3);
183
+ results.forEach((result) => {
184
+ expect(result).toEqual(expectedAppData);
185
+ });
186
+
187
+ // Verify token fetch behavior
188
+ const tokenFetchInits = fetchInits.filter((entry) =>
189
+ entry.url.includes(`/auth/temporary/${appId}`)
190
+ );
191
+ expect(tokenFetchInits).toHaveLength(1); // Only one token fetch should occur
192
+ expect(tokenFetchInits[0].init.credentials).toBe("include");
193
+
194
+ const appFetchInits = fetchInits.filter((entry) =>
195
+ entry.url.includes(`/apps/${appId}`)
196
+ );
197
+ expect(appFetchInits).toHaveLength(3); // Three getApp requests
198
+ appFetchInits.forEach((init) => {
199
+ expect(init.init.credentials).toBe("omit");
200
+ expect(init.init.headers).toHaveProperty("Authorization");
201
+ expect(
202
+ (init.init.headers as Record<string, string>)["Authorization"]
203
+ ).toMatch(/^QB-TEMP-TOKEN .+$/);
204
+ });
205
+
206
+ console.log("Test completed. Check debug logs for concurrency details.");
207
+ });
208
+ });
@@ -0,0 +1,77 @@
1
+ // tests/vitest/qb/ThrottleBucket.test.ts
2
+ import { describe, it, expect } from "vitest";
3
+ import { quickbase } from "../../../src/quickbaseClient";
4
+
5
+ describe("QuickbaseClient with ConcurrentThrottleBucket Integration Tests", () => {
6
+ it("handles concurrent API calls with burst and rate limiting in strict order", async () => {
7
+ const mockFetch = async (
8
+ url: string,
9
+ options: RequestInit
10
+ ): Promise<Response> => {
11
+ await new Promise((resolve) => setTimeout(resolve, 500));
12
+ return new Response(JSON.stringify({ id: "mock-app" }), {
13
+ status: 200,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+ };
17
+
18
+ const qbClient = quickbase({
19
+ realm: "test",
20
+ userToken: "mock-token",
21
+ throttle: { rate: 5, burst: 3 },
22
+ fetchApi: mockFetch,
23
+ debug: true,
24
+ });
25
+
26
+ const throttleTimes: number[] = [];
27
+ const startTimes: number[] = new Array(6).fill(0);
28
+ const completionTimes: number[] = [];
29
+ const recordTime = async (index: number) => {
30
+ const preThrottle = Date.now();
31
+ throttleTimes[index] = preThrottle;
32
+ await qbClient.getApp({
33
+ appId: "mock-app-id",
34
+ startTime: startTimes[index],
35
+ });
36
+ completionTimes[index] = Date.now();
37
+ startTimes[index] = startTimes[index] || Date.now() - 500;
38
+ };
39
+
40
+ const startTime = Date.now();
41
+ const promises = [];
42
+ for (let i = 0; i < 6; i++) {
43
+ promises.push(recordTime(i));
44
+ await new Promise((resolve) => setTimeout(resolve, 1));
45
+ }
46
+ await Promise.all(promises);
47
+
48
+ const relativeThrottleTimes = throttleTimes.map((t) => t - startTime);
49
+ const relativeStartTimes = startTimes.map((t) => t - startTime);
50
+ const durations = completionTimes.map((t) => t - startTime);
51
+ const timings = completionTimes.map((t, i) => t - startTimes[i]);
52
+
53
+ console.log("Relative Throttle Times:", relativeThrottleTimes);
54
+ console.log("Relative Start Times (post-throttle):", relativeStartTimes);
55
+ console.log("Durations (completion times):", durations);
56
+ console.log("Timings (fetch durations):", timings);
57
+ console.log("Total Duration:", Date.now() - startTime);
58
+
59
+ const totalDuration = Date.now() - startTime;
60
+ expect(totalDuration).toBeGreaterThanOrEqual(1000);
61
+ expect(totalDuration).toBeLessThan(1200); // Adjusted back to ~1100ms
62
+
63
+ expect(durations.length).toBe(6);
64
+ expect(durations.slice(0, 3).every((d) => d >= 500 && d < 550)).toBe(true);
65
+
66
+ // Sort durations to handle out-of-order completion
67
+ const sortedDurations = [...durations].sort((a, b) => a - b);
68
+ expect(sortedDurations[3]).toBeGreaterThanOrEqual(650);
69
+ expect(sortedDurations[3]).toBeLessThan(750);
70
+ expect(sortedDurations[4]).toBeGreaterThanOrEqual(850);
71
+ expect(sortedDurations[4]).toBeLessThan(950);
72
+ expect(sortedDurations[5]).toBeGreaterThanOrEqual(1050);
73
+ expect(sortedDurations[5]).toBeLessThan(1150);
74
+
75
+ expect(timings.every((t) => t >= 500 && t < 550)).toBe(true);
76
+ });
77
+ });
@@ -0,0 +1,68 @@
1
+ // tests/vitest/qb/auth/ssoTokenConcurrency.test.ts
2
+ import { test, expect, vi } from "vitest";
3
+ import { quickbase } from "../../../../src/quickbaseClient";
4
+ import { startMockQuickbaseServer } from "./mockQuickbaseServer";
5
+ import { QB_APP_ID, QB_REALM } from "@tests/setup.ts";
6
+
7
+ test(
8
+ "QuickbaseClient Integration - getApp with SsoTokenStrategy Concurrency",
9
+ async () => {
10
+ const mockServer = startMockQuickbaseServer(3000);
11
+
12
+ // Spy on console.log before any actions
13
+ const consoleSpy = vi.spyOn(console, "log");
14
+
15
+ try {
16
+ const client = quickbase({
17
+ realm: QB_REALM,
18
+ useSso: true,
19
+ samlToken: "mock-saml-token",
20
+ debug: true,
21
+ baseUrl: "http://localhost:3000/v1",
22
+ });
23
+
24
+ const concurrentRequests = [
25
+ client.getApp({ appId: QB_APP_ID }),
26
+ client.getApp({ appId: QB_APP_ID }),
27
+ client.getApp({ appId: QB_APP_ID }),
28
+ ];
29
+
30
+ const results = await Promise.all(concurrentRequests);
31
+
32
+ expect(results).toHaveLength(3);
33
+ results.forEach((result) =>
34
+ expect(result).toEqual({
35
+ id: QB_APP_ID,
36
+ name: "Mock App",
37
+ created: expect.any(Date),
38
+ updated: expect.any(Date),
39
+ })
40
+ );
41
+
42
+ // Verify token fetch and waits using console logs
43
+ const logs = consoleSpy.mock.calls;
44
+ const tokenFetchLogs = logs.filter((call) =>
45
+ call[0].includes("[fetchSsoToken] Fetched token")
46
+ );
47
+ expect(tokenFetchLogs).toHaveLength(1); // Single fetch with synchronization
48
+
49
+ const waitLogs = logs.filter((call) =>
50
+ call[0].includes("Waiting for existing SSO fetch")
51
+ );
52
+ expect(waitLogs.length).toBeGreaterThanOrEqual(2); // At least two waits
53
+
54
+ const appRequestLogs = logs.filter((call) =>
55
+ call[0].includes("Received getApp request")
56
+ );
57
+ expect(appRequestLogs).toHaveLength(3);
58
+
59
+ console.log(
60
+ "SsoTokenStrategy: Single token fetch with concurrent waits confirmed."
61
+ );
62
+ } finally {
63
+ mockServer.close();
64
+ consoleSpy.mockRestore();
65
+ }
66
+ },
67
+ { timeout: 20000 }
68
+ );
@@ -0,0 +1,63 @@
1
+ // tests/vitest/qb/auth/userTokenConcurrency.test.ts
2
+ import { test, expect } from "vitest";
3
+ import { quickbase } from "../../../../src/quickbaseClient";
4
+
5
+ test(
6
+ "QuickbaseClient Integration - getApp with UserToken Concurrency",
7
+ async () => {
8
+ const realm = process.env.QB_REALM || "";
9
+ const appId = process.env.QB_APP_ID || "";
10
+ const userToken = process.env.QB_USER_TOKEN || "";
11
+
12
+ if (!realm || !appId || !userToken) {
13
+ throw new Error(
14
+ "QB_REALM, QB_APP_ID, and QB_USER_TOKEN must be set in .env"
15
+ );
16
+ }
17
+
18
+ const client = quickbase({
19
+ realm,
20
+ userToken,
21
+ debug: true,
22
+ });
23
+
24
+ const concurrentRequests = [
25
+ client.getApp({ appId }),
26
+ client.getApp({ appId }),
27
+ client.getApp({ appId }),
28
+ ];
29
+
30
+ const results = await Promise.all(concurrentRequests);
31
+
32
+ const expectedAppData = {
33
+ id: appId,
34
+ name: "qb-copy",
35
+ created: expect.any(Date),
36
+ updated: expect.any(Date),
37
+ description: "",
38
+ timeZone: "(UTC-08:00) Pacific Time (US & Canada)",
39
+ dateFormat: "MM-DD-YYYY",
40
+ hasEveryoneOnTheInternet: false,
41
+ memoryInfo: { estMemory: 0, estMemoryInclDependentApps: 0 },
42
+ securityProperties: {
43
+ allowClone: false,
44
+ allowExport: true,
45
+ enableAppTokens: true,
46
+ hideFromPublic: false,
47
+ mustBeRealmApproved: false,
48
+ useIPFilter: false,
49
+ },
50
+ ancestorId: undefined,
51
+ dataClassification: undefined,
52
+ variables: [{ name: "TestVar", value: "TestValue" }],
53
+ };
54
+
55
+ expect(results).toHaveLength(3);
56
+ results.forEach((result) => expect(result).toEqual(expectedAppData));
57
+
58
+ console.log(
59
+ "UserTokenStrategy: All concurrent requests used static token."
60
+ );
61
+ },
62
+ { timeout: 20000 }
63
+ );
@@ -28,7 +28,7 @@ describe("QuickbaseClient Integration - getFieldsUsage", () => {
28
28
  console.log(
29
29
  `Retrieved ${response.length} field usage entries for table ${QB_TABLE_ID_1}`
30
30
  );
31
- }, 15000); // 15-second timeout
31
+ }, 30000); // 15-second timeout
32
32
 
33
33
  it("retrieves field usage with skip and top parameters", async () => {
34
34
  console.log(
@@ -73,5 +73,5 @@ describe("QuickbaseClient Integration - getFieldsUsage", () => {
73
73
  ),
74
74
  });
75
75
  console.log(`Confirmed 401 for invalid table ID ${invalidTableId}`);
76
- }, 15000); // 15-second timeout
76
+ }, 30000); // 15-second timeout
77
77
  });
@@ -1,23 +1,29 @@
1
+ // test/vitest/unit/ThrottleBucket.test.ts
1
2
  import { describe, it, expect, vi } from "vitest";
2
- import { ThrottleBucket } from "@/ThrottleBucket"; // Adjust path as needed
3
+ import { ConcurrentThrottleBucket } from "@/ThrottleBucket"; // Should work with your alias
3
4
 
4
- describe("ThrottleBucket Unit Tests", () => {
5
+ describe("ConcurrentThrottleBucket Unit Tests", () => {
5
6
  it("allows burst capacity immediately", async () => {
6
- const bucket = new ThrottleBucket(5, 3); // rate: 5, burst: 3
7
+ const bucket = new ConcurrentThrottleBucket(5, 3); // rate: 5, burst: 3
7
8
  const startTime = Date.now();
8
9
 
9
- await Promise.all([bucket.acquire(), bucket.acquire(), bucket.acquire()]);
10
+ await Promise.all([
11
+ bucket.acquire().then(() => bucket.release()),
12
+ bucket.acquire().then(() => bucket.release()),
13
+ bucket.acquire().then(() => bucket.release()),
14
+ ]);
10
15
 
11
16
  const duration = Date.now() - startTime;
12
17
  expect(duration).toBeLessThan(50); // All 3 should be instant (within 50ms)
13
18
  });
14
19
 
15
20
  it("delays calls beyond burst at correct rate", async () => {
16
- const bucket = new ThrottleBucket(5, 3); // rate: 5 tokens/sec, burst: 3
21
+ const bucket = new ConcurrentThrottleBucket(5, 3); // rate: 5 tokens/sec, burst: 3
17
22
  const timings: number[] = [];
18
23
  const recordTime = async () => {
19
24
  await bucket.acquire();
20
25
  timings.push(Date.now());
26
+ bucket.release(); // Release slot
21
27
  };
22
28
 
23
29
  const startTime = Date.now();
@@ -38,11 +44,12 @@ describe("ThrottleBucket Unit Tests", () => {
38
44
  });
39
45
 
40
46
  it("maintains rate over sustained calls", async () => {
41
- const bucket = new ThrottleBucket(2, 2); // rate: 2 tokens/sec, burst: 2
47
+ const bucket = new ConcurrentThrottleBucket(2, 2); // rate: 2 tokens/sec, burst: 2
42
48
  const timings: number[] = [];
43
49
  const recordTime = async () => {
44
50
  await bucket.acquire();
45
51
  timings.push(Date.now());
52
+ bucket.release(); // Release slot
46
53
  };
47
54
 
48
55
  const startTime = Date.now();
@@ -67,11 +74,12 @@ describe("ThrottleBucket Unit Tests", () => {
67
74
  });
68
75
 
69
76
  it("handles high concurrency without exceeding burst", async () => {
70
- const bucket = new ThrottleBucket(5, 3); // rate: 5, burst: 3
77
+ const bucket = new ConcurrentThrottleBucket(5, 3); // rate: 5, burst: 3
71
78
  const timings: number[] = [];
72
79
  const recordTime = async () => {
73
80
  await bucket.acquire();
74
81
  timings.push(Date.now());
82
+ bucket.release(); // Release slot
75
83
  };
76
84
 
77
85
  const startTime = Date.now();
@@ -83,9 +91,7 @@ describe("ThrottleBucket Unit Tests", () => {
83
91
 
84
92
  const durations = timings.map((t) => t - startTime);
85
93
  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
94
+ expect(durations.slice(0, 3).every((d) => d < 50)).toBe(true); // First 3 instant
89
95
  for (let i = 3; i < 10; i++) {
90
96
  const expectedDelay = (i - 2) * 200; // After burst, 200ms per token
91
97
  expect(durations[i]).toBeGreaterThanOrEqual(expectedDelay - 50);
@@ -0,0 +1,151 @@
1
+ // tests/vitest/unit/apps/getAppConcurrency.test.ts
2
+
3
+ import { describe, it, expect, beforeEach, vi } from "vitest";
4
+ import { createClient, mockFetch, QB_APP_ID, QB_REALM } from "@tests/setup.ts";
5
+
6
+ describe("QuickbaseClient Unit - getApp Concurrency with Temp Tokens", () => {
7
+ let client: ReturnType<typeof createClient>;
8
+
9
+ beforeEach(() => {
10
+ mockFetch.mockClear();
11
+ // Reset client before each test
12
+ client = createClient(mockFetch, {
13
+ realm: QB_REALM,
14
+ useTempTokens: true, // Use TempTokenStrategy
15
+ debug: true,
16
+ });
17
+ });
18
+
19
+ it("handles concurrent getApp requests with a single token fetch", async () => {
20
+ // Mock the token fetch response
21
+ const tempTokenResponse = {
22
+ temporaryAuthorization: "temp-token-123",
23
+ };
24
+ mockFetch.mockImplementation((url: string, options: RequestInit) => {
25
+ if (url.includes(`/auth/temporary/${QB_APP_ID}`)) {
26
+ return Promise.resolve({
27
+ ok: true,
28
+ status: 200,
29
+ json: () => Promise.resolve(tempTokenResponse),
30
+ });
31
+ }
32
+ // Mock the getApp response
33
+ return Promise.resolve({
34
+ ok: true,
35
+ status: 200,
36
+ json: () =>
37
+ Promise.resolve({
38
+ id: QB_APP_ID,
39
+ name: "test-app",
40
+ created: "2025-02-13T18:22:33Z",
41
+ updated: "2025-03-04T04:25:51Z",
42
+ }),
43
+ });
44
+ });
45
+
46
+ // Simulate concurrent getApp requests
47
+ const concurrentRequests = [
48
+ client.getApp({ appId: QB_APP_ID }),
49
+ client.getApp({ appId: QB_APP_ID }),
50
+ client.getApp({ appId: QB_APP_ID }),
51
+ ];
52
+
53
+ const results = await Promise.all(concurrentRequests);
54
+
55
+ // Assertions
56
+ expect(results).toHaveLength(3); // All requests completed
57
+ results.forEach((result) => {
58
+ expect(result).toEqual({
59
+ id: QB_APP_ID,
60
+ name: "test-app",
61
+ created: expect.any(Date), // Assuming convertDates is true
62
+ updated: expect.any(Date),
63
+ });
64
+ });
65
+
66
+ // Verify token fetch was called exactly once
67
+ const tokenFetchCalls = mockFetch.mock.calls.filter((call) =>
68
+ call[0].includes(`/auth/temporary/${QB_APP_ID}`)
69
+ );
70
+ expect(tokenFetchCalls).toHaveLength(1); // Only one token fetch despite 3 requests
71
+
72
+ // Verify getApp calls were made with the temp token
73
+ const getAppCalls = mockFetch.mock.calls.filter((call) =>
74
+ call[0].includes(`/apps/${QB_APP_ID}`)
75
+ );
76
+ expect(getAppCalls).toHaveLength(3); // Three getApp requests
77
+ getAppCalls.forEach((call) => {
78
+ expect(call[1].headers).toMatchObject({
79
+ Authorization: `QB-TEMP-TOKEN ${tempTokenResponse.temporaryAuthorization}`,
80
+ "QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
81
+ "Content-Type": "application/json",
82
+ });
83
+ });
84
+ });
85
+
86
+ it("handles concurrent getApp requests with cached token", async () => {
87
+ // Pre-populate the token cache by making an initial request
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ status: 200,
91
+ json: () =>
92
+ Promise.resolve({ temporaryAuthorization: "cached-token-456" }),
93
+ });
94
+ await client.getApp({ appId: QB_APP_ID }); // This caches the token
95
+
96
+ // Reset mock to clear initial call tracking
97
+ mockFetch.mockClear();
98
+
99
+ // Mock only the getApp response now (no token fetch needed)
100
+ mockFetch.mockResolvedValue({
101
+ ok: true,
102
+ status: 200,
103
+ json: () =>
104
+ Promise.resolve({
105
+ id: QB_APP_ID,
106
+ name: "test-app",
107
+ created: "2025-02-13T18:22:33Z",
108
+ updated: "2025-03-04T04:25:51Z",
109
+ }),
110
+ });
111
+
112
+ // Simulate concurrent getApp requests with cached token
113
+ const concurrentRequests = [
114
+ client.getApp({ appId: QB_APP_ID }),
115
+ client.getApp({ appId: QB_APP_ID }),
116
+ client.getApp({ appId: QB_APP_ID }),
117
+ ];
118
+
119
+ const results = await Promise.all(concurrentRequests);
120
+
121
+ // Assertions
122
+ expect(results).toHaveLength(3); // All requests completed
123
+ results.forEach((result) => {
124
+ expect(result).toEqual({
125
+ id: QB_APP_ID,
126
+ name: "test-app",
127
+ created: expect.any(Date),
128
+ updated: expect.any(Date),
129
+ });
130
+ });
131
+
132
+ // Verify no token fetches occurred (cache was used)
133
+ const tokenFetchCalls = mockFetch.mock.calls.filter((call) =>
134
+ call[0].includes(`/auth/temporary/${QB_APP_ID}`)
135
+ );
136
+ expect(tokenFetchCalls).toHaveLength(0); // No token fetches
137
+
138
+ // Verify getApp calls were made with the cached token
139
+ const getAppCalls = mockFetch.mock.calls.filter((call) =>
140
+ call[0].includes(`/apps/${QB_APP_ID}`)
141
+ );
142
+ expect(getAppCalls).toHaveLength(3); // Three getApp requests
143
+ getAppCalls.forEach((call) => {
144
+ expect(call[1].headers).toMatchObject({
145
+ Authorization: `QB-TEMP-TOKEN cached-token-456`,
146
+ "QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
147
+ "Content-Type": "application/json",
148
+ });
149
+ });
150
+ });
151
+ });
@@ -42,7 +42,7 @@ describe("QuickbaseClient Unit - 401 with fetchTempToken 401", () => {
42
42
  json: () => Promise.resolve({ message: "Temp token fetch failed" }),
43
43
  });
44
44
  }
45
- return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
45
+ throw new Error(`Unexpected fetch call: ${url}`); // Use throw to stop further retries
46
46
  });
47
47
 
48
48
  const consoleSpy = vi.spyOn(console, "log");
@@ -52,8 +52,26 @@ describe("QuickbaseClient Unit - 401 with fetchTempToken 401", () => {
52
52
  );
53
53
 
54
54
  expect(mockFetch).toHaveBeenCalledTimes(3);
55
+ expect(mockFetch).toHaveBeenNthCalledWith(
56
+ 1,
57
+ `https://api.quickbase.com/v1/auth/temporary/${QB_TABLE_ID_1}`,
58
+ expect.any(Object)
59
+ );
60
+ expect(mockFetch).toHaveBeenNthCalledWith(
61
+ 2,
62
+ `https://api.quickbase.com/v1/fields?tableId=${QB_TABLE_ID_1}`,
63
+ expect.objectContaining({
64
+ headers: expect.objectContaining({
65
+ Authorization: `QB-TEMP-TOKEN ${mockToken}`,
66
+ }),
67
+ })
68
+ );
69
+ expect(mockFetch).toHaveBeenNthCalledWith(
70
+ 3,
71
+ `https://api.quickbase.com/v1/auth/temporary/${QB_TABLE_ID_1}`,
72
+ expect.any(Object)
73
+ );
55
74
 
56
- // Updated to match the single-argument log from handleError
57
75
  expect(consoleSpy).toHaveBeenCalledWith(
58
76
  "Authorization error for getFields (temp token), refreshing token:"
59
77
  );