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.
- package/CHANGELOG.md +16 -0
- package/README.md +56 -24
- package/dist/esm/quickbase.d.ts +1 -0
- package/dist/esm/quickbase.js +125 -87
- 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 +125 -87
- 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/examples/quicbase-jsCDN.html +2 -2
- package/package.json +1 -1
- package/src/ThrottleBucket.ts +52 -16
- package/src/authorizationStrategy.ts +62 -35
- package/src/generated-unified/QuickbaseClient.ts +1 -1
- package/src/invokeMethod.ts +33 -66
- package/src/quickbaseClient.ts +12 -7
- package/src/rateLimiter.ts +8 -3
- package/src/tokenCache.ts +4 -0
- package/tests/playwright/qb/auth/getAppWithTempTokenConcurrency.test.ts +208 -0
- package/tests/vitest/qb/ThrottleBucket.test.ts +77 -0
- package/tests/vitest/qb/auth/ssoTokenConcurrency.test.ts +68 -0
- package/tests/vitest/qb/auth/userTokenConcurrency.test.ts +63 -0
- package/tests/vitest/qb/fields/getFieldsUsage.test.ts +2 -2
- package/tests/vitest/unit/ThrottleBucket.test.ts +16 -10
- package/tests/vitest/unit/apps/getAppConcurrency.test.ts +151 -0
- package/tests/vitest/unit/auth/fetchTempToken401.test.ts +20 -2
- package/tests/vitest/unit/auth/userTokenConcurrency.test.ts +62 -0
- package/tests/vitest/unit/index.ts +1 -1
- package/tests/vitest/unit/quickbaseClientHttpMethods.test.ts +3 -3
- package/tools/log-gen.ts +1 -1
- package/tools/log-gen.yaml +130 -78
- package/api-examples.md +0 -49
- 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
|
-
},
|
|
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
|
-
},
|
|
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 {
|
|
3
|
+
import { ConcurrentThrottleBucket } from "@/ThrottleBucket"; // Should work with your alias
|
|
3
4
|
|
|
4
|
-
describe("
|
|
5
|
+
describe("ConcurrentThrottleBucket Unit Tests", () => {
|
|
5
6
|
it("allows burst capacity immediately", async () => {
|
|
6
|
-
const bucket = new
|
|
7
|
+
const bucket = new ConcurrentThrottleBucket(5, 3); // rate: 5, burst: 3
|
|
7
8
|
const startTime = Date.now();
|
|
8
9
|
|
|
9
|
-
await Promise.all([
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
);
|