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

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.
@@ -17,12 +17,12 @@
17
17
  if (typeof QuickbaseJS === "undefined") {
18
18
  document.getElementById("result").textContent = "Error: Failed to load QuickbaseJS library.";
19
19
  } else {
20
- var qbClient = QuickbaseJS.quickbase({
20
+ var qb = QuickbaseJS.quickbase({
21
21
  realm,
22
22
  useTempTokens: true
23
23
  });
24
24
 
25
- qbClient.getApp({ appId })
25
+ qb.getApp({ appId })
26
26
  .then(function (app) {
27
27
  document.getElementById("result").textContent = "App Name: " + app.name;
28
28
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quickbase-js",
3
- "version": "0.1.0-beta.10",
3
+ "version": "0.1.0-beta.11",
4
4
  "description": "QuickBase JavaScript client for frameworks and vanilla JS",
5
5
  "type": "module",
6
6
  "main": "dist/esm/quickbase.js",
@@ -1,5 +1,4 @@
1
1
  // src/authorizationStrategy.ts
2
-
3
2
  import { TokenCache } from "./tokenCache";
4
3
 
5
4
  export interface AuthorizationStrategy {
@@ -15,9 +14,9 @@ export interface AuthorizationStrategy {
15
14
  ): Promise<string | null>;
16
15
  }
17
16
 
18
- // src/authorizationStrategy.ts (partial update)
19
-
20
17
  export class TempTokenStrategy implements AuthorizationStrategy {
18
+ private pendingFetches: Map<string, Promise<string>> = new Map();
19
+
21
20
  constructor(
22
21
  private tokenCache: TokenCache,
23
22
  private initialTempToken: string | undefined,
@@ -61,7 +60,15 @@ export class TempTokenStrategy implements AuthorizationStrategy {
61
60
  async getToken(dbid: string): Promise<string | undefined> {
62
61
  let token = this.tokenCache.get(dbid) || this.initialTempToken;
63
62
  if (!token && dbid) {
64
- token = await this.fetchTempToken(dbid);
63
+ if (this.pendingFetches.has(dbid)) {
64
+ console.log(`[getToken] Waiting for existing fetch for dbid: ${dbid}`);
65
+ return this.pendingFetches.get(dbid);
66
+ }
67
+ const fetchPromise = this.fetchTempToken(dbid).finally(() =>
68
+ this.pendingFetches.delete(dbid)
69
+ );
70
+ this.pendingFetches.set(dbid, fetchPromise);
71
+ token = await fetchPromise;
65
72
  }
66
73
  return token;
67
74
  }
@@ -96,14 +103,31 @@ export class TempTokenStrategy implements AuthorizationStrategy {
96
103
  return null;
97
104
  }
98
105
  if (debug) console.log(`Refreshing temp token for dbid: ${dbid}`);
99
- const newToken = await this.fetchTempToken(dbid);
100
- this.tokenCache.set(dbid, newToken);
101
- return newToken;
106
+ // Invalidate the cache for this dbid to force a fresh fetch
107
+ this.tokenCache.delete(dbid);
108
+ try {
109
+ const newToken = await this.getToken(dbid);
110
+ if (newToken) {
111
+ this.tokenCache.set(dbid, newToken);
112
+ return newToken;
113
+ }
114
+ return null;
115
+ } catch (error) {
116
+ if (debug)
117
+ console.log(
118
+ `[${methodName || "method"}] Failed to refresh token:`,
119
+ error
120
+ );
121
+ throw error;
122
+ }
102
123
  }
103
124
  }
104
125
 
105
126
  export class UserTokenStrategy implements AuthorizationStrategy {
106
- constructor(private userToken: string) {}
127
+ constructor(
128
+ private userToken: string,
129
+ private baseUrl: string = "https://api.quickbase.com/v1"
130
+ ) {}
107
131
 
108
132
  async getToken(_dbid: string): Promise<string> {
109
133
  return this.userToken;
@@ -134,23 +158,32 @@ export class UserTokenStrategy implements AuthorizationStrategy {
134
158
 
135
159
  export class SsoTokenStrategy implements AuthorizationStrategy {
136
160
  private currentToken: string | undefined;
161
+ private pendingFetches: Map<string, Promise<string>> = new Map();
137
162
 
138
163
  constructor(
139
164
  private samlToken: string,
140
165
  private realm: string,
141
166
  private fetchApi: typeof fetch,
142
- private debug: boolean = false
167
+ private debug: boolean = false,
168
+ private baseUrl: string = "https://api.quickbase.com/v1"
143
169
  ) {}
144
170
 
145
171
  async getToken(_dbid: string): Promise<string | undefined> {
146
172
  if (!this.currentToken) {
147
- this.currentToken = await this.fetchSsoToken({
173
+ if (this.pendingFetches.has("sso")) {
174
+ if (this.debug)
175
+ console.log("[getToken] Waiting for existing SSO fetch");
176
+ return this.pendingFetches.get("sso");
177
+ }
178
+ const fetchPromise = this.fetchSsoToken({
148
179
  grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
149
180
  requested_token_type:
150
181
  "urn:quickbase:params:oauth:token-type:temp_token",
151
182
  subject_token: this.samlToken,
152
183
  subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
153
- });
184
+ }).finally(() => this.pendingFetches.delete("sso"));
185
+ this.pendingFetches.set("sso", fetchPromise);
186
+ this.currentToken = await fetchPromise;
154
187
  }
155
188
  return this.currentToken;
156
189
  }
@@ -193,18 +226,15 @@ export class SsoTokenStrategy implements AuthorizationStrategy {
193
226
  subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
194
227
  };
195
228
 
196
- const response = await this.fetchApi(
197
- `https://api.quickbase.com/v1/auth/oauth/token`,
198
- {
199
- method: "POST",
200
- headers: {
201
- "QB-Realm-Hostname": `${this.realm}.quickbase.com`,
202
- "Content-Type": "application/json",
203
- },
204
- body: JSON.stringify(payload),
205
- credentials: "omit",
206
- }
207
- );
229
+ const response = await this.fetchApi(`${this.baseUrl}/auth/oauth/token`, {
230
+ method: "POST",
231
+ headers: {
232
+ "QB-Realm-Hostname": `${this.realm}.quickbase.com`,
233
+ "Content-Type": "application/json",
234
+ },
235
+ body: JSON.stringify(payload),
236
+ credentials: "omit",
237
+ });
208
238
 
209
239
  if (!response.ok) {
210
240
  const errorBody = await response.json().catch(() => ({}));
@@ -234,18 +264,15 @@ export class SsoTokenStrategy implements AuthorizationStrategy {
234
264
  subject_token: string;
235
265
  subject_token_type: string;
236
266
  }): Promise<string> {
237
- const response = await this.fetchApi(
238
- `https://api.quickbase.com/v1/auth/oauth/token`,
239
- {
240
- method: "POST",
241
- headers: {
242
- "QB-Realm-Hostname": `${this.realm}.quickbase.com`,
243
- "Content-Type": "application/json",
244
- },
245
- body: JSON.stringify(params),
246
- credentials: "omit",
247
- }
248
- );
267
+ const response = await this.fetchApi(`${this.baseUrl}/auth/oauth/token`, {
268
+ method: "POST",
269
+ headers: {
270
+ "QB-Realm-Hostname": `${this.realm}.quickbase.com`,
271
+ "Content-Type": "application/json",
272
+ },
273
+ body: JSON.stringify(params),
274
+ credentials: "omit",
275
+ });
249
276
 
250
277
  if (!response.ok) {
251
278
  const errorBody = await response.json().catch(() => ({}));
@@ -1,4 +1,4 @@
1
- // Generated on 2025-03-22T03:57:10.682Z
1
+ // Generated on 2025-03-23T19:11:12.725Z
2
2
  import { AddManagersToGroup200Response, AddManagersToGroupRequest, AddMembersToGroup200Response, AddMembersToGroupRequest, AddSubgroupsToGroup200Response, AddSubgroupsToGroupRequest, Audit200Response, ChangesetSolution200Response, ChangesetSolutionFromRecord200Response, CloneUserToken200Response, CopyApp200Response, CopyAppRequest, CreateApp200Response, CreateAppRequest, CreateField200Response, CreateFieldRequest, CreateRelationship200Response, CreateRelationshipRequest, CreateSolution200Response, CreateSolutionFromRecord200Response, CreateTable200Response, CreateTableRequest, DeactivateUserToken200Response, DeleteApp200Response, DeleteAppRequest, DeleteFields200Response, DeleteFieldsRequest, DeleteFile200Response, DeleteRecords200Response, DeleteRecordsRequest, DeleteRelationship200Response, DeleteTable200Response, DeleteUserToken200Response, DenyUsers200Response, DenyUsersAndGroups200Response, DenyUsersAndGroupsRequest, DenyUsersRequest, DownloadFile200Response, ExchangeSsoToken200Response, ExportSolution200Response, ExportSolutionToRecord200Response, GenerateDocument200Response, GetApp200Response, GetAppEvents200Response, GetAppTables200Response, GetField200Response, GetFieldUsage200Response, GetFields200Response, GetFieldsUsage200Response, GetRelationships200Response, GetReport200Response, GetTable200Response, GetTableReports200Response, GetTempTokenDBID200Response, GetUsers200Response, GetUsersRequest, PlatformAnalyticEventSummaries200Response, PlatformAnalyticReads200Response, RemoveManagersFromGroup200Response, RemoveManagersFromGroupRequest, RemoveMembersFromGroup200Response, RemoveMembersFromGroupRequest, RemoveSubgroupsFromGroup200Response, RemoveSubgroupsFromGroupRequest, RunFormula200Response, RunQuery200Response, RunQueryRequest, RunReport200Response, TransferUserToken200Response, UndenyUsers200Response, UndenyUsersRequest, UpdateApp200Response, UpdateAppRequest, UpdateField200Response, UpdateFieldRequest, UpdateRelationship200Response, UpdateRelationshipRequest, UpdateSolution200Response, UpdateSolutionToRecord200Response, UpdateTable200Response, UpdateTableRequest, Upsert200Response, Upsert207Response, UpsertRequest } from "../generated/models";
3
3
 
4
4
  export interface QuickbaseClient {
@@ -1,5 +1,4 @@
1
1
  // src/invokeMethod.ts
2
-
3
2
  import { AuthorizationStrategy, extractDbid } from "./authorizationStrategy";
4
3
  import { RateLimiter } from "./rateLimiter";
5
4
  import { RateLimitError } from "./RateLimitError";
@@ -31,10 +30,10 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
31
30
  rateLimiter: RateLimiter,
32
31
  transformDates: (obj: any, convertStringsToDates: boolean) => any,
33
32
  debug: boolean | undefined,
34
- convertDates: boolean
33
+ convertDates: boolean,
34
+ attempt: number = 0,
35
+ maxAttempts: number = rateLimiter.maxRetries + 1
35
36
  ): Promise<ReturnType<QuickbaseClient[K]>> {
36
- console.log("[invokeMethod] Starting for method:", methodName);
37
-
38
37
  const methodInfo = methodMap[methodName];
39
38
  if (!methodInfo) throw new Error(`Method ${methodName} not found`);
40
39
 
@@ -56,42 +55,27 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
56
55
  };
57
56
 
58
57
  let dbid: string | undefined = extractDbid(params);
59
- console.log("[invokeMethod] Extracted dbid:", dbid);
60
-
61
58
  let token = dbid
62
59
  ? await authStrategy.getToken(dbid)
63
60
  : await authStrategy.getToken("");
64
- console.log("[invokeMethod] Initial token:", token);
65
61
 
66
62
  if (token) {
67
63
  authStrategy.applyHeaders(baseHeaders, token);
68
64
  requestOptions.headers = { ...baseHeaders };
69
- console.log(
70
- "[invokeMethod] Token found, headers set:",
71
- requestOptions.headers
72
- );
73
65
  if (methodName === "getTempTokenDBID") {
74
- console.log("[invokeMethod] Using cached token:", token);
75
66
  return { temporaryAuthorization: token } as ReturnType<
76
67
  QuickbaseClient[K]
77
68
  >;
78
69
  }
79
70
  }
80
71
 
81
- if (debug) {
82
- console.log(`[${methodName}] requestParameters:`, requestParameters);
83
- console.log(`[${methodName}] requestOptions:`, requestOptions);
84
- }
85
-
86
72
  async function processResponse(
87
73
  rawResponse: any
88
74
  ): Promise<ReturnType<QuickbaseClient[K]>> {
89
- if (debug) console.log(`[${methodName}] rawResponse:`, rawResponse);
90
75
  if (rawResponse instanceof Response) {
91
76
  const contentType = rawResponse.headers
92
77
  .get("Content-Type")
93
78
  ?.toLowerCase();
94
- if (debug) console.log(`[${methodName}] contentType:`, contentType);
95
79
  if (contentType?.includes("application/json")) {
96
80
  const jsonResponse = await rawResponse.json();
97
81
  return transformDates(jsonResponse, convertDates) as ReturnType<
@@ -102,8 +86,6 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
102
86
  }
103
87
  if (rawResponse && typeof rawResponse.value === "function") {
104
88
  const response = await rawResponse.value();
105
- if (debug)
106
- console.log(`[${methodName}] Resolved JSONApiResponse:`, response);
107
89
  return transformDates(response, convertDates) as ReturnType<
108
90
  QuickbaseClient[K]
109
91
  >;
@@ -140,36 +122,20 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
140
122
  message = (await response.text()) || message;
141
123
  }
142
124
  } catch (e) {
143
- if (debug) console.log(`[${methodName}] Error parsing response body:`, e);
144
125
  message = "Failed to parse error response";
145
126
  }
146
- if (debug)
147
- console.log(
148
- "[invokeMethod] Parsed error - status:",
149
- status,
150
- "message:",
151
- message
152
- );
153
127
  return { message, status };
154
128
  }
155
129
 
156
- let attempt = 0;
157
- const maxAttempts = rateLimiter.maxRetries + 1;
158
-
159
130
  while (attempt < maxAttempts) {
160
- console.log("[invokeMethod] Attempt:", attempt + 1, "of", maxAttempts);
161
131
  try {
162
132
  await rateLimiter.throttle();
163
- console.log("[invokeMethod] About to call API for method:", methodName);
164
133
  const response = await methodInfo.method(
165
134
  requestParameters,
166
135
  requestOptions
167
136
  );
168
- console.log("[invokeMethod] API call completed for method:", methodName);
169
137
  return await processResponse(response);
170
138
  } catch (error: unknown) {
171
- if (debug) console.log("[invokeMethod] Caught error:", error);
172
-
173
139
  let status: number;
174
140
  let message: string;
175
141
  let response: Response | undefined;
@@ -181,29 +147,14 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
181
147
  response = error;
182
148
  ({ message, status } = await parseErrorResponse(response));
183
149
  } else {
184
- if (debug) console.log(`[${methodName}] Unexpected error:`, error);
185
- // Attempt to handle as a fetch error with a response
186
- if (error instanceof Error && "response" in error) {
187
- response = (error as any).response;
188
- if (response instanceof Response) {
189
- ({ message, status } = await parseErrorResponse(response));
190
- } else {
191
- throw error; // Rethrow if no response
192
- }
193
- } else {
194
- throw error; // Rethrow truly unexpected errors
195
- }
150
+ throw error; // Rethrow truly unexpected errors
196
151
  }
197
152
 
198
- if (debug)
199
- console.log("[invokeMethod] Handling error with status:", status);
200
-
201
153
  if (status === 429) {
202
154
  if (!(error instanceof ResponseError)) {
203
155
  throw new Error("Expected ResponseError for 429 handling");
204
156
  }
205
157
  const delay = await rateLimiter.handle429(error, attempt + 1);
206
- if (debug) console.log(`[${methodName}] 429 delay: ${delay}ms`);
207
158
  if (attempt + 1 === maxAttempts) {
208
159
  throw new RateLimitError(
209
160
  `API Error: ${message} (Status: ${status})`,
@@ -218,26 +169,35 @@ export async function invokeMethod<K extends keyof QuickbaseClient>(
218
169
  continue;
219
170
  }
220
171
 
221
- const newToken = await authStrategy.handleError(
222
- status,
223
- params,
224
- attempt,
225
- maxAttempts,
226
- debug,
227
- methodName
228
- );
229
- if (newToken) {
172
+ // Handle authentication errors
173
+ let newToken: string | null;
174
+ try {
175
+ newToken = await authStrategy.handleError(
176
+ status,
177
+ params,
178
+ attempt,
179
+ maxAttempts,
180
+ debug,
181
+ methodName
182
+ );
183
+ } catch (authError) {
184
+ throw authError; // Propagate the fetchTempToken error and exit immediately
185
+ }
186
+
187
+ // Only proceed with retry if we have a valid new token
188
+ if (newToken !== null) {
230
189
  token = newToken;
231
190
  authStrategy.applyHeaders(baseHeaders, token);
232
191
  requestOptions.headers = { ...baseHeaders };
233
- attempt++;
234
192
  if (debug)
235
193
  console.log(
236
194
  `[${methodName}] Retrying with token: ${token.substring(0, 10)}...`
237
195
  );
196
+ attempt++;
238
197
  continue;
239
198
  }
240
199
 
200
+ // If no new token was provided, throw the original error
241
201
  throw new Error(`API Error: ${message} (Status: ${status})`);
242
202
  }
243
203
  }
@@ -1,5 +1,4 @@
1
1
  // src/quickbaseClient.ts
2
-
3
2
  import { QuickbaseClient as IQuickbaseClient } from "./generated-unified/QuickbaseClient";
4
3
  import { Configuration, HTTPHeaders } from "./generated/runtime";
5
4
  import * as apis from "./generated/apis";
@@ -9,7 +8,7 @@ import {
9
8
  getParamNames,
10
9
  transformDates,
11
10
  extractHttpMethod,
12
- } from "./utils"; // Updated import
11
+ } from "./utils";
13
12
  import { invokeMethod, MethodInfo } from "./invokeMethod";
14
13
  import {
15
14
  TempTokenStrategy,
@@ -82,7 +81,13 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
82
81
  }
83
82
 
84
83
  const authStrategy: AuthorizationStrategy = useSso
85
- ? new SsoTokenStrategy(samlToken || "", realm, effectiveFetch, debug)
84
+ ? new SsoTokenStrategy(
85
+ samlToken || "",
86
+ realm,
87
+ effectiveFetch,
88
+ debug,
89
+ baseUrl
90
+ )
86
91
  : useTempTokens
87
92
  ? new TempTokenStrategy(
88
93
  tokenCache,
@@ -91,7 +96,7 @@ export function quickbase(config: QuickbaseConfig): QuickbaseClient {
91
96
  realm,
92
97
  baseUrl
93
98
  )
94
- : new UserTokenStrategy(userToken || "");
99
+ : new UserTokenStrategy(userToken || "", baseUrl);
95
100
 
96
101
  const baseHeaders: HTTPHeaders = {
97
102
  "QB-Realm-Hostname": `${realm}.quickbase.com`,
package/src/tokenCache.ts CHANGED
@@ -29,6 +29,10 @@ export class TokenCache {
29
29
  });
30
30
  }
31
31
 
32
+ delete(dbid: string): void {
33
+ this.cache.delete(dbid);
34
+ }
35
+
32
36
  clear(): void {
33
37
  this.cache.clear();
34
38
  }
@@ -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
+ });