quickbase-js 0.1.0-beta.7 → 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 (82) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +46 -130
  3. package/dist/esm/quickbase.d.ts +1292 -125
  4. package/dist/esm/quickbase.js +666 -190
  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 +674 -189
  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/fix-spec-main.ts +69 -68
  13. package/open-api/generate-override-report.ts +214 -0
  14. package/open-api/generate-unified-interface.ts +162 -103
  15. package/open-api/output/override-report.html +11 -0
  16. package/open-api/output/override-report.json +14395 -58
  17. package/open-api/output/quickbase-fixed.json +583 -2
  18. package/open-api/schema/tags/fields.ts +331 -16
  19. package/open-api/schema/tags/index.ts +2 -4
  20. package/open-api/types/spec.ts +2 -1
  21. package/open-api/utils/generateJsDoc.ts +96 -0
  22. package/package.json +33 -33
  23. package/src/RateLimitError.ts +12 -0
  24. package/src/ThrottleBucket.ts +41 -0
  25. package/src/authorizationStrategy.ts +246 -0
  26. package/src/generated-unified/QuickbaseClient.ts +809 -119
  27. package/src/invokeMethod.ts +242 -0
  28. package/src/quickbaseClient.ts +117 -248
  29. package/src/rateLimiter.ts +25 -0
  30. package/src/tokenCache.ts +10 -26
  31. package/tests/setup.ts +2 -0
  32. package/tests/vitest/qb/apps/getApp.test.ts +6 -2
  33. package/tests/vitest/qb/apps/getAppEvents.test.ts +67 -0
  34. package/tests/vitest/qb/apps/updateApp.test.ts +58 -0
  35. package/tests/vitest/qb/auth/mockQuickbaseServer.ts +60 -0
  36. package/tests/vitest/qb/auth/retryUserToken401.test.ts +73 -0
  37. package/tests/vitest/qb/auth/ssoTokenIntegrationWithServer.test.ts +65 -0
  38. package/tests/vitest/qb/fields/createField.test.ts +1 -2
  39. package/tests/vitest/qb/fields/deleteFields.test.ts +10 -16
  40. package/tests/vitest/qb/fields/getFieldUsage.test.ts +58 -150
  41. package/tests/vitest/qb/fields/updateField.test.ts +93 -0
  42. package/tests/vitest/qb/formulas/runFormula.test.ts +96 -0
  43. package/tests/vitest/qb/index.ts +1 -0
  44. package/tests/vitest/qb/tables/createRelationship.test.ts +102 -0
  45. package/tests/vitest/qb/tables/deleteRelationship.test.ts +143 -0
  46. package/tests/vitest/qb/tables/updateRelationship.test.ts +250 -0
  47. package/tests/vitest/unit/ThrottleBucket.test.ts +95 -0
  48. package/tests/vitest/unit/apps/copyApp.test.ts +133 -40
  49. package/tests/vitest/unit/apps/getAppEvents.test.ts +193 -0
  50. package/tests/vitest/unit/apps/updateApp.test.ts +412 -0
  51. package/tests/vitest/unit/auth/fetchTempToken401.test.ts +7 -26
  52. package/tests/vitest/unit/auth/getTempToken.test.ts +42 -18
  53. package/tests/vitest/unit/auth/retryTempToken401.test.ts +95 -0
  54. package/tests/vitest/unit/auth/retryTempTokenMaxRetries.test.ts +235 -0
  55. package/tests/vitest/unit/auth/retryUserToken401.test.ts +69 -0
  56. package/tests/vitest/unit/auth/ssoTokenRefresh.test.ts +325 -0
  57. package/tests/vitest/unit/fields/createField.test.ts +180 -120
  58. package/tests/vitest/unit/fields/getFieldUsage.test.ts +47 -14
  59. package/tests/vitest/unit/fields/updateField.test.ts +285 -0
  60. package/tests/vitest/unit/formulas/runFormula.test.ts +205 -0
  61. package/tests/vitest/unit/index.ts +2 -1
  62. package/tests/vitest/unit/inferHttpMethod.test.ts +131 -0
  63. package/tests/vitest/unit/rateLimit.test.ts +191 -0
  64. package/tests/vitest/unit/tables/createRelationship.test.ts +212 -0
  65. package/tests/vitest/unit/tables/deleteRelationship.test.ts +97 -0
  66. package/tests/vitest/unit/tables/updateRelationship.test.ts +307 -0
  67. package/tests/vitest/unit/tokenLifespan.test.ts +204 -0
  68. package/tools/log-gen.ts +20 -7
  69. package/tools/log-gen.yaml +3446 -1848
  70. package/tsconfig.json +5 -2
  71. package/vitest.config.ts +2 -2
  72. package/notes.txt +0 -158
  73. package/tests/vitest/unit/auth/retryOn401.test.ts +0 -116
  74. package/tests/vitest/unit/auth/two401s.test.ts +0 -86
  75. package/tools/generate-types.ts +0 -29
  76. package/tools/openapi-schema-validator.ts +0 -38
  77. package/tools/spec-analyzer.ts +0 -163
  78. package/tools/split-spec.ts +0 -148
  79. package/tools/test-import.ts +0 -3
  80. package/tools/validate-spec.ts +0 -40
  81. /package/tests/playwright/qb/auth/{tokenPrefetch.test.ts → tempTokenPrefetch.test.ts} +0 -0
  82. /package/tests/playwright/qb/auth/{tokenRenewal.test.ts → tempTokenRenewal.test.ts} +0 -0
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { createClient, mockFetch } from "@tests/setup.ts";
3
+
4
+ describe("QuickbaseClient - Temp Token Retry Limit", () => {
5
+ let client: ReturnType<typeof createClient>;
6
+
7
+ beforeEach(() => {
8
+ mockFetch.mockClear();
9
+ });
10
+
11
+ it("exhausts retries after maxRetries (3) and throws an error", async () => {
12
+ client = createClient(mockFetch, {
13
+ useTempTokens: true,
14
+ debug: true,
15
+ maxRetries: 3, // Explicitly set maxRetries to 3
16
+ });
17
+
18
+ const mockDbid = "mockDbid123";
19
+ let callCount = 0;
20
+
21
+ mockFetch.mockImplementation((url) => {
22
+ callCount++;
23
+ // Initial temp token fetch
24
+ if (url.includes("auth/temporary") && callCount === 1) {
25
+ return Promise.resolve({
26
+ ok: true,
27
+ status: 200,
28
+ json: () => Promise.resolve({ temporaryAuthorization: "token_1" }),
29
+ });
30
+ }
31
+ // First attempt: 401
32
+ if (url.includes("fields") && callCount === 2) {
33
+ return Promise.resolve({
34
+ ok: false,
35
+ status: 401,
36
+ json: () => Promise.resolve({ message: "Unauthorized 1" }),
37
+ });
38
+ }
39
+ // Second temp token fetch
40
+ if (url.includes("auth/temporary") && callCount === 3) {
41
+ return Promise.resolve({
42
+ ok: true,
43
+ status: 200,
44
+ json: () => Promise.resolve({ temporaryAuthorization: "token_2" }),
45
+ });
46
+ }
47
+ // Second attempt: 401
48
+ if (url.includes("fields") && callCount === 4) {
49
+ return Promise.resolve({
50
+ ok: false,
51
+ status: 401,
52
+ json: () => Promise.resolve({ message: "Unauthorized 2" }),
53
+ });
54
+ }
55
+ // Third temp token fetch
56
+ if (url.includes("auth/temporary") && callCount === 5) {
57
+ return Promise.resolve({
58
+ ok: true,
59
+ status: 200,
60
+ json: () => Promise.resolve({ temporaryAuthorization: "token_3" }),
61
+ });
62
+ }
63
+ // Third attempt: 401
64
+ if (url.includes("fields") && callCount === 6) {
65
+ return Promise.resolve({
66
+ ok: false,
67
+ status: 401,
68
+ json: () => Promise.resolve({ message: "Unauthorized 3" }),
69
+ });
70
+ }
71
+ // Fourth temp token fetch
72
+ if (url.includes("auth/temporary") && callCount === 7) {
73
+ return Promise.resolve({
74
+ ok: true,
75
+ status: 200,
76
+ json: () => Promise.resolve({ temporaryAuthorization: "token_4" }),
77
+ });
78
+ }
79
+ // Fourth attempt: 401
80
+ if (url.includes("fields") && callCount === 8) {
81
+ return Promise.resolve({
82
+ ok: false,
83
+ status: 401,
84
+ json: () => Promise.resolve({ message: "Unauthorized 4" }),
85
+ });
86
+ }
87
+ return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
88
+ });
89
+
90
+ const consoleSpy = vi.spyOn(console, "log");
91
+
92
+ await expect(client.getFields({ tableId: mockDbid })).rejects.toThrow(
93
+ "API Error: Unauthorized 4 (Status: 401)"
94
+ );
95
+
96
+ expect(mockFetch).toHaveBeenCalledTimes(8); // 4 token fetches + 4 API attempts
97
+
98
+ const tokenLogs = consoleSpy.mock.calls.filter((call) =>
99
+ call[0].includes("Fetched and cached new token")
100
+ );
101
+ expect(tokenLogs).toHaveLength(4);
102
+ expect(tokenLogs).toContainEqual([
103
+ "Fetched and cached new token for dbid: mockDbid123",
104
+ "token_1",
105
+ ]);
106
+ expect(tokenLogs).toContainEqual([
107
+ "Fetched and cached new token for dbid: mockDbid123",
108
+ "token_2",
109
+ ]);
110
+ expect(tokenLogs).toContainEqual([
111
+ "Fetched and cached new token for dbid: mockDbid123",
112
+ "token_3",
113
+ ]);
114
+ expect(tokenLogs).toContainEqual([
115
+ "Fetched and cached new token for dbid: mockDbid123",
116
+ "token_4",
117
+ ]);
118
+
119
+ expect(consoleSpy).toHaveBeenCalledWith(
120
+ "Authorization error for getFields (temp token), refreshing token:"
121
+ );
122
+ expect(consoleSpy).toHaveBeenCalledWith(
123
+ "[getFields] Retrying with token: token_2..."
124
+ );
125
+ expect(consoleSpy).toHaveBeenCalledWith(
126
+ "[getFields] Retrying with token: token_3..."
127
+ );
128
+ expect(consoleSpy).toHaveBeenCalledWith(
129
+ "[getFields] Retrying with token: token_4..."
130
+ );
131
+
132
+ consoleSpy.mockRestore();
133
+ });
134
+
135
+ it("exhausts retries after maxRetries (2) and throws an error", async () => {
136
+ client = createClient(mockFetch, {
137
+ useTempTokens: true,
138
+ debug: true,
139
+ maxRetries: 2, // Explicitly set maxRetries to 2
140
+ });
141
+
142
+ const mockDbid = "mockDbid123";
143
+ let callCount = 0;
144
+
145
+ mockFetch.mockImplementation((url) => {
146
+ callCount++;
147
+ // Initial temp token fetch
148
+ if (url.includes("auth/temporary") && callCount === 1) {
149
+ return Promise.resolve({
150
+ ok: true,
151
+ status: 200,
152
+ json: () => Promise.resolve({ temporaryAuthorization: "token_1" }),
153
+ });
154
+ }
155
+ // First attempt: 401
156
+ if (url.includes("fields") && callCount === 2) {
157
+ return Promise.resolve({
158
+ ok: false,
159
+ status: 401,
160
+ json: () => Promise.resolve({ message: "Unauthorized 1" }),
161
+ });
162
+ }
163
+ // Second temp token fetch
164
+ if (url.includes("auth/temporary") && callCount === 3) {
165
+ return Promise.resolve({
166
+ ok: true,
167
+ status: 200,
168
+ json: () => Promise.resolve({ temporaryAuthorization: "token_2" }),
169
+ });
170
+ }
171
+ // Second attempt: 401
172
+ if (url.includes("fields") && callCount === 4) {
173
+ return Promise.resolve({
174
+ ok: false,
175
+ status: 401,
176
+ json: () => Promise.resolve({ message: "Unauthorized 2" }),
177
+ });
178
+ }
179
+ // Third temp token fetch
180
+ if (url.includes("auth/temporary") && callCount === 5) {
181
+ return Promise.resolve({
182
+ ok: true,
183
+ status: 200,
184
+ json: () => Promise.resolve({ temporaryAuthorization: "token_3" }),
185
+ });
186
+ }
187
+ // Third attempt: 401
188
+ if (url.includes("fields") && callCount === 6) {
189
+ return Promise.resolve({
190
+ ok: false,
191
+ status: 401,
192
+ json: () => Promise.resolve({ message: "Unauthorized 3" }),
193
+ });
194
+ }
195
+ return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
196
+ });
197
+
198
+ const consoleSpy = vi.spyOn(console, "log");
199
+
200
+ await expect(client.getFields({ tableId: mockDbid })).rejects.toThrow(
201
+ "API Error: Unauthorized 3 (Status: 401)"
202
+ );
203
+
204
+ expect(mockFetch).toHaveBeenCalledTimes(6); // 3 token fetches + 3 API attempts
205
+
206
+ const tokenLogs = consoleSpy.mock.calls.filter((call) =>
207
+ call[0].includes("Fetched and cached new token")
208
+ );
209
+ expect(tokenLogs).toHaveLength(3);
210
+ expect(tokenLogs).toContainEqual([
211
+ "Fetched and cached new token for dbid: mockDbid123",
212
+ "token_1",
213
+ ]);
214
+ expect(tokenLogs).toContainEqual([
215
+ "Fetched and cached new token for dbid: mockDbid123",
216
+ "token_2",
217
+ ]);
218
+ expect(tokenLogs).toContainEqual([
219
+ "Fetched and cached new token for dbid: mockDbid123",
220
+ "token_3",
221
+ ]);
222
+
223
+ expect(consoleSpy).toHaveBeenCalledWith(
224
+ "Authorization error for getFields (temp token), refreshing token:"
225
+ );
226
+ expect(consoleSpy).toHaveBeenCalledWith(
227
+ "[getFields] Retrying with token: token_2..."
228
+ );
229
+ expect(consoleSpy).toHaveBeenCalledWith(
230
+ "[getFields] Retrying with token: token_3..."
231
+ );
232
+
233
+ consoleSpy.mockRestore();
234
+ });
235
+ });
@@ -0,0 +1,69 @@
1
+ // tests/vitest/unit/auth/retryUserToken401.test.ts
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import {
5
+ createClient,
6
+ mockFetch,
7
+ QB_REALM,
8
+ QB_TABLE_ID_1,
9
+ } from "@tests/setup.ts";
10
+
11
+ describe("QuickbaseClient Unit - User Token Retry on 401", () => {
12
+ it("retries with the same user token on 401 and succeeds", async () => {
13
+ const userToken = "b9f3pk_q4jd_0_b4qu5eebyvuix3xs57ysd7zn3";
14
+ mockFetch
15
+ .mockResolvedValueOnce(
16
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
17
+ status: 401,
18
+ })
19
+ )
20
+ .mockResolvedValueOnce(
21
+ new Response(JSON.stringify([{ id: 1, label: "Field1" }]), {
22
+ status: 200,
23
+ })
24
+ );
25
+
26
+ const consoleSpy = vi.spyOn(console, "log");
27
+ const client = createClient(mockFetch, {
28
+ realm: QB_REALM,
29
+ userToken,
30
+ useTempTokens: false,
31
+ debug: true,
32
+ });
33
+
34
+ const result = await client.getFields({ tableId: QB_TABLE_ID_1 });
35
+
36
+ expect(result).toEqual([{ id: 1, label: "Field1" }]);
37
+ expect(mockFetch).toHaveBeenCalledTimes(2);
38
+ expect(mockFetch).toHaveBeenNthCalledWith(
39
+ 1,
40
+ `https://api.quickbase.com/v1/fields?tableId=${QB_TABLE_ID_1}`,
41
+ expect.objectContaining({
42
+ method: "GET",
43
+ headers: expect.objectContaining({
44
+ Authorization: `QB-USER-TOKEN ${userToken}`,
45
+ "QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
46
+ "Content-Type": "application/json",
47
+ }),
48
+ })
49
+ );
50
+ expect(mockFetch).toHaveBeenNthCalledWith(
51
+ 2,
52
+ `https://api.quickbase.com/v1/fields?tableId=${QB_TABLE_ID_1}`,
53
+ expect.objectContaining({
54
+ method: "GET",
55
+ headers: expect.objectContaining({
56
+ Authorization: `QB-USER-TOKEN ${userToken}`,
57
+ "QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
58
+ "Content-Type": "application/json",
59
+ }),
60
+ })
61
+ );
62
+ expect(consoleSpy).toHaveBeenCalledWith(
63
+ `Retrying getFields with existing user token: b9f3pk_q4j...`
64
+ );
65
+ expect(consoleSpy).toHaveBeenCalledWith(
66
+ "[getFields] Retrying with token: b9f3pk_q4j..."
67
+ );
68
+ });
69
+ });
@@ -0,0 +1,325 @@
1
+ // tests/vitest/unit/auth/ssoTokenRefresh.test.ts
2
+
3
+ import { describe, it, expect, beforeEach, vi } from "vitest";
4
+ import { createClient, mockFetch, QB_REALM, QB_APP_ID } from "@tests/setup.ts";
5
+
6
+ describe("QuickbaseClient Unit - SsoTokenStrategy", () => {
7
+ let client: ReturnType<typeof createClient>;
8
+ const mockSamlToken = "mock_saml_token_base64url";
9
+ const mockSsoToken = "example_token_1NiIsImtpZCI6IjllciJ9w";
10
+
11
+ beforeEach(() => {
12
+ mockFetch.mockClear();
13
+ client = createClient(mockFetch, {
14
+ useSso: true,
15
+ samlToken: mockSamlToken,
16
+ realm: QB_REALM,
17
+ debug: true,
18
+ });
19
+ });
20
+
21
+ it("initializes without errors", () => {
22
+ expect(client).toBeDefined();
23
+ });
24
+
25
+ it("has exchangeSsoToken method", () => {
26
+ expect(typeof client.exchangeSsoToken).toBe("function");
27
+ });
28
+
29
+ it("fetches SSO token on first call", async () => {
30
+ const mockResponse = {
31
+ access_token: mockSsoToken,
32
+ issued_token_type: "urn:quickbase:params:oauth:token-type:temp_token",
33
+ token_type: "N_A",
34
+ };
35
+
36
+ mockFetch.mockResolvedValueOnce(
37
+ new Response(JSON.stringify(mockResponse), {
38
+ status: 200,
39
+ headers: { "Content-Type": "application/json" },
40
+ })
41
+ );
42
+
43
+ mockFetch.mockResolvedValueOnce(
44
+ new Response(JSON.stringify({ id: QB_APP_ID, name: "Test App" }), {
45
+ status: 200,
46
+ headers: { "Content-Type": "application/json" },
47
+ })
48
+ );
49
+
50
+ const result = await client.getApp({ appId: QB_APP_ID });
51
+
52
+ expect(mockFetch).toHaveBeenCalledTimes(2);
53
+ expect(mockFetch).toHaveBeenNthCalledWith(
54
+ 1,
55
+ "https://api.quickbase.com/v1/auth/oauth/token",
56
+ expect.objectContaining({
57
+ method: "POST",
58
+ headers: expect.objectContaining({
59
+ "QB-Realm-Hostname": `${QB_REALM}.quickbase.com`,
60
+ "Content-Type": "application/json",
61
+ }),
62
+ body: JSON.stringify({
63
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
64
+ requested_token_type:
65
+ "urn:quickbase:params:oauth:token-type:temp_token",
66
+ subject_token: mockSamlToken,
67
+ subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
68
+ }),
69
+ })
70
+ );
71
+ expect(mockFetch).toHaveBeenNthCalledWith(
72
+ 2,
73
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
74
+ expect.objectContaining({
75
+ headers: expect.objectContaining({
76
+ Authorization: `QB-TEMP-TOKEN ${mockSsoToken}`,
77
+ }),
78
+ })
79
+ );
80
+ expect(result).toEqual({ id: QB_APP_ID, name: "Test App" });
81
+ });
82
+
83
+ it("refreshes SSO token on 401 and retries", async () => {
84
+ const newToken = "new_token_abcdef123";
85
+ const appResponse = { id: QB_APP_ID, name: "Test App" };
86
+
87
+ mockFetch
88
+ .mockResolvedValueOnce(
89
+ new Response(
90
+ JSON.stringify({
91
+ access_token: mockSsoToken,
92
+ issued_token_type:
93
+ "urn:quickbase:params:oauth:token-type:temp_token",
94
+ token_type: "N_A",
95
+ }),
96
+ { status: 200, headers: { "Content-Type": "application/json" } }
97
+ )
98
+ )
99
+ .mockResolvedValueOnce(
100
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
101
+ status: 401,
102
+ headers: { "Content-Type": "application/json" },
103
+ })
104
+ )
105
+ .mockResolvedValueOnce(
106
+ new Response(
107
+ JSON.stringify({
108
+ access_token: newToken,
109
+ issued_token_type:
110
+ "urn:quickbase:params:oauth:token-type:temp_token",
111
+ token_type: "N_A",
112
+ }),
113
+ { status: 200, headers: { "Content-Type": "application/json" } }
114
+ )
115
+ )
116
+ .mockResolvedValueOnce(
117
+ new Response(JSON.stringify(appResponse), {
118
+ status: 200,
119
+ headers: { "Content-Type": "application/json" },
120
+ })
121
+ );
122
+
123
+ const result = await client.getApp({ appId: QB_APP_ID });
124
+
125
+ expect(result).toEqual(appResponse);
126
+ expect(mockFetch).toHaveBeenCalledTimes(4);
127
+ expect(mockFetch).toHaveBeenNthCalledWith(
128
+ 1,
129
+ "https://api.quickbase.com/v1/auth/oauth/token",
130
+ expect.any(Object)
131
+ );
132
+ expect(mockFetch).toHaveBeenNthCalledWith(
133
+ 2,
134
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
135
+ expect.objectContaining({
136
+ headers: expect.objectContaining({
137
+ Authorization: `QB-TEMP-TOKEN ${mockSsoToken}`,
138
+ }),
139
+ })
140
+ );
141
+ expect(mockFetch).toHaveBeenNthCalledWith(
142
+ 3,
143
+ "https://api.quickbase.com/v1/auth/oauth/token",
144
+ expect.any(Object)
145
+ );
146
+ expect(mockFetch).toHaveBeenNthCalledWith(
147
+ 4,
148
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
149
+ expect.objectContaining({
150
+ headers: expect.objectContaining({
151
+ Authorization: `QB-TEMP-TOKEN ${newToken}`,
152
+ }),
153
+ })
154
+ );
155
+ });
156
+
157
+ it("handles API error on token exchange", async () => {
158
+ // Reset client to avoid interference from beforeEach
159
+ client = createClient(mockFetch, {
160
+ useSso: true,
161
+ samlToken: mockSamlToken,
162
+ realm: QB_REALM,
163
+ debug: true,
164
+ });
165
+
166
+ mockFetch
167
+ .mockResolvedValueOnce(
168
+ new Response(
169
+ JSON.stringify({
170
+ access_token: mockSsoToken,
171
+ issued_token_type:
172
+ "urn:quickbase:params:oauth:token-type:temp_token",
173
+ token_type: "N_A",
174
+ }),
175
+ { status: 200, headers: { "Content-Type": "application/json" } }
176
+ )
177
+ )
178
+ .mockResolvedValueOnce(
179
+ new Response(JSON.stringify({ message: "Invalid SAML token" }), {
180
+ status: 400,
181
+ headers: { "Content-Type": "application/json" },
182
+ })
183
+ );
184
+
185
+ await expect(
186
+ client.exchangeSsoToken({
187
+ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
188
+ requested_token_type:
189
+ "urn:quickbase:params:oauth:token-type:temp_token",
190
+ subject_token: mockSamlToken,
191
+ subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
192
+ })
193
+ ).rejects.toThrow("API Error: Invalid SAML token (Status: 400)");
194
+
195
+ expect(mockFetch).toHaveBeenCalledTimes(2);
196
+ expect(mockFetch).toHaveBeenNthCalledWith(
197
+ 1,
198
+ "https://api.quickbase.com/v1/auth/oauth/token",
199
+ expect.any(Object)
200
+ );
201
+ expect(mockFetch).toHaveBeenNthCalledWith(
202
+ 2,
203
+ "https://api.quickbase.com/v1/auth/oauth/token",
204
+ expect.any(Object)
205
+ );
206
+ });
207
+
208
+ it("exhausts retries on repeated 401s", async () => {
209
+ mockFetch
210
+ .mockResolvedValueOnce(
211
+ new Response(
212
+ JSON.stringify({
213
+ access_token: mockSsoToken,
214
+ issued_token_type:
215
+ "urn:quickbase:params:oauth:token-type:temp_token",
216
+ token_type: "N_A",
217
+ }),
218
+ { status: 200, headers: { "Content-Type": "application/json" } }
219
+ )
220
+ )
221
+ .mockResolvedValueOnce(
222
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
223
+ status: 401,
224
+ headers: { "Content-Type": "application/json" },
225
+ })
226
+ )
227
+ .mockResolvedValueOnce(
228
+ new Response(
229
+ JSON.stringify({
230
+ access_token: "retry_token_1",
231
+ issued_token_type:
232
+ "urn:quickbase:params:oauth:token-type:temp_token",
233
+ token_type: "N_A",
234
+ }),
235
+ { status: 200, headers: { "Content-Type": "application/json" } }
236
+ )
237
+ )
238
+ .mockResolvedValueOnce(
239
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
240
+ status: 401,
241
+ headers: { "Content-Type": "application/json" },
242
+ })
243
+ )
244
+ .mockResolvedValueOnce(
245
+ new Response(
246
+ JSON.stringify({
247
+ access_token: "retry_token_2",
248
+ issued_token_type:
249
+ "urn:quickbase:params:oauth:token-type:temp_token",
250
+ token_type: "N_A",
251
+ }),
252
+ { status: 200, headers: { "Content-Type": "application/json" } }
253
+ )
254
+ )
255
+ .mockResolvedValueOnce(
256
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
257
+ status: 401,
258
+ headers: { "Content-Type": "application/json" },
259
+ })
260
+ )
261
+ .mockResolvedValueOnce(
262
+ new Response(
263
+ JSON.stringify({
264
+ access_token: "retry_token_3",
265
+ issued_token_type:
266
+ "urn:quickbase:params:oauth:token-type:temp_token",
267
+ token_type: "N_A",
268
+ }),
269
+ { status: 200, headers: { "Content-Type": "application/json" } }
270
+ )
271
+ )
272
+ .mockResolvedValueOnce(
273
+ new Response(JSON.stringify({ message: "Unauthorized" }), {
274
+ status: 401,
275
+ headers: { "Content-Type": "application/json" },
276
+ })
277
+ );
278
+
279
+ await expect(client.getApp({ appId: QB_APP_ID })).rejects.toThrow(
280
+ "API Error: Unauthorized (Status: 401)"
281
+ );
282
+
283
+ expect(mockFetch).toHaveBeenCalledTimes(8);
284
+ expect(mockFetch).toHaveBeenNthCalledWith(
285
+ 1,
286
+ "https://api.quickbase.com/v1/auth/oauth/token",
287
+ expect.any(Object)
288
+ );
289
+ expect(mockFetch).toHaveBeenNthCalledWith(
290
+ 2,
291
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
292
+ expect.any(Object)
293
+ );
294
+ expect(mockFetch).toHaveBeenNthCalledWith(
295
+ 3,
296
+ "https://api.quickbase.com/v1/auth/oauth/token",
297
+ expect.any(Object)
298
+ );
299
+ expect(mockFetch).toHaveBeenNthCalledWith(
300
+ 4,
301
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
302
+ expect.any(Object)
303
+ );
304
+ expect(mockFetch).toHaveBeenNthCalledWith(
305
+ 5,
306
+ "https://api.quickbase.com/v1/auth/oauth/token",
307
+ expect.any(Object)
308
+ );
309
+ expect(mockFetch).toHaveBeenNthCalledWith(
310
+ 6,
311
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
312
+ expect.any(Object)
313
+ );
314
+ expect(mockFetch).toHaveBeenNthCalledWith(
315
+ 7,
316
+ "https://api.quickbase.com/v1/auth/oauth/token",
317
+ expect.any(Object)
318
+ );
319
+ expect(mockFetch).toHaveBeenNthCalledWith(
320
+ 8,
321
+ `https://api.quickbase.com/v1/apps/${QB_APP_ID}`,
322
+ expect.any(Object)
323
+ );
324
+ });
325
+ });