@taruvi/sdk 1.4.1 → 1.4.3
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/package.json +1 -1
- package/src/client.ts +5 -27
- package/src/index.ts +1 -1
- package/src/lib/auth/AuthClient.ts +7 -95
- package/src/lib/database/DatabaseClient.ts +24 -2
- package/src/lib-internal/errors/ErrorClient.ts +12 -0
- package/src/lib-internal/errors/index.ts +1 -1
- package/src/lib-internal/errors/types.ts +1 -0
- package/src/lib-internal/http/HttpClient.ts +35 -51
- package/src/lib-internal/routes/DatabaseRoutes.ts +2 -1
- package/src/lib-internal/token/TokenClient.ts +23 -197
- package/tests/unit/auth/AuthClient.test.ts +8 -48
- package/tests/unit/database/DatabaseClient.test.ts +78 -0
- package/tests/unit/errors/errors.test.ts +27 -0
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -33,11 +33,9 @@ export class Client {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Extracts
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* After successful extraction, the URL hash is cleared to prevent token exposure.
|
|
36
|
+
* Extracts session token from URL hash and stores it using TokenClient.
|
|
37
|
+
* Handles callback URLs like: #session_token=xxx
|
|
38
|
+
* After extraction, the URL hash is cleared.
|
|
41
39
|
*/
|
|
42
40
|
private extractTokensFromUrl(): void {
|
|
43
41
|
if (typeof window === "undefined" || typeof localStorage === "undefined") {
|
|
@@ -51,32 +49,12 @@ export class Client {
|
|
|
51
49
|
|
|
52
50
|
const params = new URLSearchParams(hash.substring(1))
|
|
53
51
|
const sessionToken = params.get("session_token")
|
|
54
|
-
const accessToken = params.get("access_token")
|
|
55
|
-
const refreshToken = params.get("refresh_token")
|
|
56
|
-
const expiresIn = params.get("expires_in")
|
|
57
|
-
const tokenType = params.get("token_type")
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
if (!accessToken || !refreshToken) {
|
|
53
|
+
if (!sessionToken) {
|
|
61
54
|
return
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
const tokens: AuthTokens = {
|
|
66
|
-
accessToken,
|
|
67
|
-
refreshToken,
|
|
68
|
-
tokenType: tokenType || "Bearer"
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (sessionToken) {
|
|
72
|
-
tokens.sessionToken = sessionToken
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (expiresIn) {
|
|
76
|
-
tokens.expiresIn = parseInt(expiresIn, 10)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
this._tokenClient.setTokens(tokens)
|
|
57
|
+
this._tokenClient.setTokens({ sessionToken })
|
|
80
58
|
|
|
81
59
|
// Clear hash from URL without reloading page
|
|
82
60
|
if (window.history && window.history.replaceState) {
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export { Client } from "./client.js"
|
|
3
3
|
|
|
4
4
|
// Export error classes
|
|
5
|
-
export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError } from "./lib-internal/errors/index.js"
|
|
5
|
+
export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, RateLimitError } from "./lib-internal/errors/index.js"
|
|
6
6
|
export { ErrorCode } from "./lib-internal/errors/index.js"
|
|
7
7
|
export type { ErrorResponseBody } from "./lib-internal/errors/index.js"
|
|
8
8
|
|
|
@@ -5,13 +5,8 @@ import { UserRoutes } from "../../lib-internal/routes/UserRoutes.js";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Auth Client - Handles user authentication using Web UI Flow
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Flow:
|
|
11
|
-
* 1. User calls login() → Redirects to backend /accounts/login/
|
|
12
|
-
* 2. User authenticates on backend
|
|
13
|
-
* 3. Backend redirects back with tokens in URL hash
|
|
14
|
-
* 4. Client extracts and stores tokens automatically
|
|
8
|
+
* Uses session token for API authentication via X-Session-Token header.
|
|
9
|
+
* On 401/403, tokens are cleared automatically by HttpClient interceptor.
|
|
15
10
|
*/
|
|
16
11
|
export class Auth {
|
|
17
12
|
private client: Client
|
|
@@ -22,7 +17,6 @@ export class Auth {
|
|
|
22
17
|
|
|
23
18
|
/**
|
|
24
19
|
* Redirect to login page (Web UI Flow)
|
|
25
|
-
* @param callbackUrl - URL to redirect to after successful login (defaults to current page)
|
|
26
20
|
*/
|
|
27
21
|
login(callbackUrl?: string): void {
|
|
28
22
|
if (typeof window === "undefined") {
|
|
@@ -33,11 +27,8 @@ export class Auth {
|
|
|
33
27
|
const config = this.client.getConfig()
|
|
34
28
|
const callback = callbackUrl || window.location.origin + window.location.pathname
|
|
35
29
|
const deskUrl = config.deskUrl || config.apiUrl
|
|
36
|
-
|
|
37
|
-
// Redirect to /accounts/login/ with redirect_to parameter
|
|
38
30
|
const loginUrl = `${deskUrl}/accounts/login/?redirect_to=${encodeURIComponent(callback)}`
|
|
39
31
|
|
|
40
|
-
// Optional: Store state before redirecting
|
|
41
32
|
if (typeof sessionStorage !== "undefined") {
|
|
42
33
|
sessionStorage.setItem("auth_state", JSON.stringify({
|
|
43
34
|
returnTo: window.location.pathname,
|
|
@@ -50,7 +41,6 @@ export class Auth {
|
|
|
50
41
|
|
|
51
42
|
/**
|
|
52
43
|
* Redirect to signup page (Web UI Flow)
|
|
53
|
-
* @param callbackUrl - URL to redirect to after successful signup (defaults to current page)
|
|
54
44
|
*/
|
|
55
45
|
signup(callbackUrl?: string): void {
|
|
56
46
|
if (typeof window === "undefined") {
|
|
@@ -60,11 +50,8 @@ export class Auth {
|
|
|
60
50
|
|
|
61
51
|
const config = this.client.getConfig()
|
|
62
52
|
const callback = callbackUrl || window.location.origin + window.location.pathname
|
|
63
|
-
|
|
64
|
-
// Redirect to /accounts/signup/ with redirect_to parameter
|
|
65
53
|
const signupUrl = `${config.apiUrl}/accounts/signup/?redirect_to=${encodeURIComponent(callback)}`
|
|
66
54
|
|
|
67
|
-
// Optional: Store state before redirecting
|
|
68
55
|
if (typeof sessionStorage !== "undefined") {
|
|
69
56
|
sessionStorage.setItem("auth_state", JSON.stringify({
|
|
70
57
|
returnTo: window.location.pathname,
|
|
@@ -77,8 +64,6 @@ export class Auth {
|
|
|
77
64
|
|
|
78
65
|
/**
|
|
79
66
|
* Logout user and redirect to logout page
|
|
80
|
-
* Fetches frontendUrl from site settings for redirect
|
|
81
|
-
* @param callbackUrl - URL to redirect to after logout (overrides frontendUrl from settings)
|
|
82
67
|
*/
|
|
83
68
|
async logout(callbackUrl?: string): Promise<void> {
|
|
84
69
|
if (typeof window === "undefined") {
|
|
@@ -86,14 +71,12 @@ export class Auth {
|
|
|
86
71
|
return
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
// Clear tokens immediately
|
|
90
74
|
this.client.tokenClient.clearTokens()
|
|
91
75
|
|
|
92
76
|
const config = this.client.getConfig()
|
|
93
77
|
const deskUrl = config.deskUrl || config.apiUrl
|
|
94
78
|
let callback: string = callbackUrl || ""
|
|
95
79
|
|
|
96
|
-
// If no callback provided, fetch frontendUrl from site settings
|
|
97
80
|
if (!callback) {
|
|
98
81
|
try {
|
|
99
82
|
const settings = await this.client.httpClient.get<{ frontend_url?: string }>(
|
|
@@ -106,93 +89,22 @@ export class Auth {
|
|
|
106
89
|
}
|
|
107
90
|
}
|
|
108
91
|
|
|
109
|
-
// Redirect to /accounts/logout/
|
|
110
92
|
const logoutUrl = `${deskUrl}/accounts/logout/?redirect_to=${encodeURIComponent(callback)}`
|
|
111
|
-
|
|
112
93
|
window.location.href = logoutUrl
|
|
113
94
|
}
|
|
114
95
|
|
|
115
96
|
/**
|
|
116
|
-
* Check if user is authenticated
|
|
97
|
+
* Check if user is authenticated (has session token)
|
|
117
98
|
*/
|
|
118
99
|
isUserAuthenticated(): boolean {
|
|
119
100
|
return this.client.tokenClient.isAuthenticated()
|
|
120
101
|
}
|
|
121
102
|
|
|
122
103
|
/**
|
|
123
|
-
* Get the current
|
|
104
|
+
* Get the current session token
|
|
124
105
|
*/
|
|
125
|
-
|
|
126
|
-
return this.client.tokenClient.
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Get the current refresh token
|
|
131
|
-
*/
|
|
132
|
-
getRefreshToken(): string | null {
|
|
133
|
-
return this.client.tokenClient.getRefreshToken()
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Check if the access token is expired
|
|
138
|
-
*/
|
|
139
|
-
isTokenExpired(): boolean {
|
|
140
|
-
return this.client.tokenClient.isTokenExpired()
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Refresh the access token using the refresh token
|
|
145
|
-
* ⚠️ IMPORTANT: Taruvi uses refresh token rotation
|
|
146
|
-
* You will receive BOTH a new access token AND a new refresh token
|
|
147
|
-
*
|
|
148
|
-
* @returns Promise with new tokens or null if refresh failed
|
|
149
|
-
*/
|
|
150
|
-
async refreshAccessToken(): Promise<{ access: string; refresh: string; expires_in: number } | null> {
|
|
151
|
-
const refreshToken = this.client.tokenClient.getRefreshToken()
|
|
152
|
-
|
|
153
|
-
if (!refreshToken) {
|
|
154
|
-
console.error("No refresh token available")
|
|
155
|
-
return null
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const config = this.client.getConfig()
|
|
160
|
-
const response = await fetch(`${config.apiUrl}/api/cloud/auth/jwt/token/refresh/`, {
|
|
161
|
-
method: "POST",
|
|
162
|
-
headers: { "Content-Type": "application/json" },
|
|
163
|
-
body: JSON.stringify({ refresh: refreshToken })
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
if (!response.ok) {
|
|
167
|
-
throw new Error(`Token refresh failed: ${response.statusText}`)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const data = await response.json()
|
|
171
|
-
|
|
172
|
-
// Update tokens in storage (both access and refresh due to rotation)
|
|
173
|
-
this.client.tokenClient.updateAccessToken(data.access, data.expires_in || 172800)
|
|
174
|
-
|
|
175
|
-
if (data.refresh) {
|
|
176
|
-
this.client.tokenClient.updateRefreshToken(data.refresh)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
access: data.access,
|
|
181
|
-
refresh: data.refresh || refreshToken,
|
|
182
|
-
expires_in: data.expires_in || 172800
|
|
183
|
-
}
|
|
184
|
-
} catch (error) {
|
|
185
|
-
console.error("Failed to refresh access token:", error)
|
|
186
|
-
|
|
187
|
-
// Clear tokens and redirect to login
|
|
188
|
-
this.client.tokenClient.clearTokens()
|
|
189
|
-
|
|
190
|
-
if (typeof window !== "undefined") {
|
|
191
|
-
this.login()
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return null
|
|
195
|
-
}
|
|
106
|
+
getSessionToken(): string | null {
|
|
107
|
+
return this.client.tokenClient.getSessionToken()
|
|
196
108
|
}
|
|
197
109
|
|
|
198
110
|
/**
|
|
@@ -211,4 +123,4 @@ export class Auth {
|
|
|
211
123
|
return null
|
|
212
124
|
}
|
|
213
125
|
}
|
|
214
|
-
}
|
|
126
|
+
}
|
|
@@ -22,8 +22,9 @@ export class Database<T = Record<string, unknown>> {
|
|
|
22
22
|
private queryParams: DatabaseFilters | undefined
|
|
23
23
|
private graphParams: GraphQueryParams
|
|
24
24
|
private isEdges: boolean
|
|
25
|
+
private isUpsert: boolean
|
|
25
26
|
|
|
26
|
-
constructor(client: Client, urlParams: UrlParams = {}, operation?: HttpMethod | undefined, body?: object | undefined, queryParams?: DatabaseFilters, graphParams: GraphQueryParams = {}, isEdges: boolean = false) {
|
|
27
|
+
constructor(client: Client, urlParams: UrlParams = {}, operation?: HttpMethod | undefined, body?: object | undefined, queryParams?: DatabaseFilters, graphParams: GraphQueryParams = {}, isEdges: boolean = false, isUpsert: boolean = false) {
|
|
27
28
|
this.client = client
|
|
28
29
|
this.urlParams = urlParams
|
|
29
30
|
this.operation = operation
|
|
@@ -32,6 +33,7 @@ export class Database<T = Record<string, unknown>> {
|
|
|
32
33
|
this.queryParams = queryParams
|
|
33
34
|
this.graphParams = graphParams
|
|
34
35
|
this.isEdges = isEdges
|
|
36
|
+
this.isUpsert = isUpsert
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
from<U = Record<string, unknown>>(dataTables: string): Database<U> {
|
|
@@ -135,10 +137,18 @@ export class Database<T = Record<string, unknown>> {
|
|
|
135
137
|
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.POST, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
upsert(body: Partial<T> | Partial<T>[]): Database<T> {
|
|
141
|
+
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.POST, body as object, this.queryParams, { ...this.graphParams }, this.isEdges, true)
|
|
142
|
+
}
|
|
143
|
+
|
|
138
144
|
update(body: Partial<T> | EdgeRequest): Database<T> {
|
|
139
145
|
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.PATCH, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
bulkUpdate(body: Partial<T>[]): Database<T> {
|
|
149
|
+
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.PATCH, body as object, this.queryParams, { ...this.graphParams }, this.isEdges)
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
delete(recordIdOrEdgeIds: string | number[]): Database<T> {
|
|
143
153
|
if (Array.isArray(recordIdOrEdgeIds)) {
|
|
144
154
|
const body: EdgeDeleteRequest = { edge_ids: recordIdOrEdgeIds }
|
|
@@ -147,6 +157,17 @@ export class Database<T = Record<string, unknown>> {
|
|
|
147
157
|
return new Database<T>(this.client, { ...this.urlParams, recordId: recordIdOrEdgeIds }, HttpMethod.DELETE, undefined, this.queryParams, { ...this.graphParams }, this.isEdges)
|
|
148
158
|
}
|
|
149
159
|
|
|
160
|
+
bulkDelete(ids: string[]): Database<T> {
|
|
161
|
+
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.DELETE, undefined, {
|
|
162
|
+
...this.queryParams,
|
|
163
|
+
ids: ids.join(',')
|
|
164
|
+
}, { ...this.graphParams }, this.isEdges)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
deleteFiltered(): Database<T> {
|
|
168
|
+
return new Database<T>(this.client, { ...this.urlParams }, HttpMethod.DELETE, undefined, this.queryParams, { ...this.graphParams }, this.isEdges)
|
|
169
|
+
}
|
|
170
|
+
|
|
150
171
|
async first(): Promise<T | null> {
|
|
151
172
|
const response = await this.execute()
|
|
152
173
|
const data = response.data
|
|
@@ -175,6 +196,7 @@ export class Database<T = Record<string, unknown>> {
|
|
|
175
196
|
const base = DatabaseRoutes.baseUrl(this.config.appSlug) +
|
|
176
197
|
DatabaseRoutes.dataTables(tableName) +
|
|
177
198
|
(this.urlParams.recordId ? DatabaseRoutes.recordId(this.urlParams.recordId) : '') +
|
|
199
|
+
(this.isUpsert ? DatabaseRoutes.upsert() : '') +
|
|
178
200
|
'/'
|
|
179
201
|
|
|
180
202
|
// Merge database filters and graph params into one query string
|
|
@@ -195,7 +217,7 @@ export class Database<T = Record<string, unknown>> {
|
|
|
195
217
|
return await this.client.httpClient.post(url, this.body)
|
|
196
218
|
|
|
197
219
|
case HttpMethod.PATCH:
|
|
198
|
-
if (!this.urlParams.recordId) {
|
|
220
|
+
if (!this.urlParams.recordId && !Array.isArray(this.body)) {
|
|
199
221
|
throw new Error('PATCH operation requires a record ID.')
|
|
200
222
|
}
|
|
201
223
|
return await this.client.httpClient.patch(url, this.body)
|
|
@@ -63,6 +63,16 @@ export class TimeoutError extends TaruviError {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export class RateLimitError extends TaruviError {
|
|
67
|
+
public readonly retryAfter: number | undefined
|
|
68
|
+
|
|
69
|
+
constructor(message = 'Rate limit exceeded', retryAfter?: number) {
|
|
70
|
+
super(message, 429, ErrorCode.RATE_LIMITED)
|
|
71
|
+
this.name = 'RateLimitError'
|
|
72
|
+
this.retryAfter = retryAfter
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export class NetworkError extends TaruviError {
|
|
67
77
|
constructor(message = 'Network error') {
|
|
68
78
|
super(message, 0, ErrorCode.NETWORK_ERROR)
|
|
@@ -94,6 +104,8 @@ export function createErrorFromResponse(statusCode: number, body?: ErrorResponse
|
|
|
94
104
|
return new NotFoundError(message)
|
|
95
105
|
case 409:
|
|
96
106
|
return new ConflictError(message, detail)
|
|
107
|
+
case 429:
|
|
108
|
+
return new RateLimitError(message)
|
|
97
109
|
case 504:
|
|
98
110
|
return new TimeoutError(message)
|
|
99
111
|
default:
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, createErrorFromResponse } from './ErrorClient.js'
|
|
1
|
+
export { TaruviError, ValidationError, AuthError, ForbiddenError, NotFoundError, ConflictError, TimeoutError, NetworkError, RateLimitError, createErrorFromResponse } from './ErrorClient.js'
|
|
2
2
|
export { ErrorCode } from './types.js'
|
|
3
3
|
export type { ErrorResponseBody } from './types.js'
|
|
@@ -1,40 +1,53 @@
|
|
|
1
1
|
import type { TaruviConfig } from "../../types.js";
|
|
2
2
|
import type { TokenClient } from "../token/TokenClient.js";
|
|
3
|
-
import axios, { AxiosError } from "axios";
|
|
3
|
+
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
|
|
4
4
|
import { createErrorFromResponse, NetworkError, TaruviError } from "../errors/index.js";
|
|
5
5
|
import type { ErrorResponseBody } from "../errors/index.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* HttpClient handles all HTTP requests to the Taruvi API.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Sends session token via X-Session-Token header.
|
|
10
|
+
* Clears tokens on 401/403 auth failures.
|
|
11
11
|
*
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
14
|
export class HttpClient {
|
|
15
15
|
private config: TaruviConfig
|
|
16
16
|
private tokenClient: TokenClient
|
|
17
|
+
private axiosInstance: AxiosInstance
|
|
17
18
|
|
|
18
19
|
constructor(config: TaruviConfig, tokenClient: TokenClient) {
|
|
19
20
|
this.config = config
|
|
20
21
|
this.tokenClient = tokenClient
|
|
22
|
+
this.axiosInstance = axios.create({ baseURL: config.apiUrl, withCredentials: true })
|
|
23
|
+
this.setupInterceptors()
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
26
|
+
private setupInterceptors(): void {
|
|
27
|
+
// Request interceptor: attach session token
|
|
28
|
+
this.axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
29
|
+
const isFormData = config.data instanceof FormData
|
|
30
|
+
if (!isFormData) {
|
|
31
|
+
config.headers['Content-Type'] = 'application/json'
|
|
32
|
+
}
|
|
33
|
+
const sessionToken = this.tokenClient.getSessionToken()
|
|
34
|
+
if (sessionToken) {
|
|
35
|
+
config.headers['X-Session-Token'] = sessionToken
|
|
36
|
+
}
|
|
37
|
+
return config
|
|
38
|
+
})
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
// Response interceptor: clear tokens on auth failure
|
|
41
|
+
this.axiosInstance.interceptors.response.use(
|
|
42
|
+
(response) => response,
|
|
43
|
+
(error: AxiosError) => {
|
|
44
|
+
const status = error.response?.status
|
|
45
|
+
if (status === 401 || status === 403) {
|
|
46
|
+
this.tokenClient.clearTokens()
|
|
47
|
+
}
|
|
48
|
+
return Promise.reject(error)
|
|
49
|
+
}
|
|
50
|
+
)
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
private handleError(error: unknown): never {
|
|
@@ -47,19 +60,15 @@ export class HttpClient {
|
|
|
47
60
|
const body = error.response.data as ErrorResponseBody | undefined
|
|
48
61
|
throw createErrorFromResponse(error.response.status, body)
|
|
49
62
|
}
|
|
50
|
-
// No response — network error
|
|
51
63
|
throw new NetworkError(error.message)
|
|
52
64
|
}
|
|
53
65
|
|
|
54
|
-
// Unknown error
|
|
55
66
|
throw error
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
async get<T>(endpoint: string): Promise<T> {
|
|
59
70
|
try {
|
|
60
|
-
const { data } = await
|
|
61
|
-
headers: this.getAuthHeaders()
|
|
62
|
-
})
|
|
71
|
+
const { data } = await this.axiosInstance.get<T>(`/${endpoint}`)
|
|
63
72
|
return data
|
|
64
73
|
} catch (error) {
|
|
65
74
|
this.handleError(error)
|
|
@@ -68,14 +77,7 @@ export class HttpClient {
|
|
|
68
77
|
|
|
69
78
|
async post<T, D = unknown>(endpoint: string, body: D): Promise<T> {
|
|
70
79
|
try {
|
|
71
|
-
const
|
|
72
|
-
const { data } = await axios.post<T>(
|
|
73
|
-
`${this.config.apiUrl}/${endpoint}`,
|
|
74
|
-
body,
|
|
75
|
-
{
|
|
76
|
-
headers: this.getAuthHeaders(isFormData)
|
|
77
|
-
}
|
|
78
|
-
)
|
|
80
|
+
const { data } = await this.axiosInstance.post<T>(`/${endpoint}`, body)
|
|
79
81
|
return data
|
|
80
82
|
} catch (error) {
|
|
81
83
|
this.handleError(error)
|
|
@@ -84,12 +86,7 @@ export class HttpClient {
|
|
|
84
86
|
|
|
85
87
|
async put<T, D = unknown>(endpoint: string, body: D): Promise<T> {
|
|
86
88
|
try {
|
|
87
|
-
const
|
|
88
|
-
const { data } = await axios.put<T>(`${this.config.apiUrl}/${endpoint}`,
|
|
89
|
-
body,
|
|
90
|
-
{
|
|
91
|
-
headers: this.getAuthHeaders(isFormData)
|
|
92
|
-
})
|
|
89
|
+
const { data } = await this.axiosInstance.put<T>(`/${endpoint}`, body)
|
|
93
90
|
return data
|
|
94
91
|
} catch (error) {
|
|
95
92
|
this.handleError(error)
|
|
@@ -98,13 +95,7 @@ export class HttpClient {
|
|
|
98
95
|
|
|
99
96
|
async delete<T, D = unknown>(endpoint: string, body?: D): Promise<T> {
|
|
100
97
|
try {
|
|
101
|
-
const { data } = await
|
|
102
|
-
`${this.config.apiUrl}/${endpoint}`,
|
|
103
|
-
{
|
|
104
|
-
headers: this.getAuthHeaders(),
|
|
105
|
-
data: body
|
|
106
|
-
}
|
|
107
|
-
)
|
|
98
|
+
const { data } = await this.axiosInstance.delete<T>(`/${endpoint}`, { data: body })
|
|
108
99
|
return data
|
|
109
100
|
} catch (error) {
|
|
110
101
|
this.handleError(error)
|
|
@@ -113,14 +104,7 @@ export class HttpClient {
|
|
|
113
104
|
|
|
114
105
|
async patch<T, D = unknown>(endpoint: string, body: D): Promise<T> {
|
|
115
106
|
try {
|
|
116
|
-
const
|
|
117
|
-
const { data } = await axios.patch<T>(
|
|
118
|
-
`${this.config.apiUrl}/${endpoint}`,
|
|
119
|
-
body,
|
|
120
|
-
{
|
|
121
|
-
headers: this.getAuthHeaders(isFormData)
|
|
122
|
-
}
|
|
123
|
-
)
|
|
107
|
+
const { data } = await this.axiosInstance.patch<T>(`/${endpoint}`, body)
|
|
124
108
|
return data
|
|
125
109
|
} catch (error) {
|
|
126
110
|
this.handleError(error)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export const DatabaseRoutes = {
|
|
2
2
|
baseUrl: (appSlug: string) => `api/apps/${appSlug}`,
|
|
3
3
|
dataTables: (tableName: string): string => `/datatables/${tableName}/data`,
|
|
4
|
-
recordId: (recordId: string): string => `/${recordId}
|
|
4
|
+
recordId: (recordId: string): string => `/${recordId}`,
|
|
5
|
+
upsert: (): string => `/upsert`
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
type AllRouteKeys = keyof typeof DatabaseRoutes
|
|
@@ -1,27 +1,14 @@
|
|
|
1
1
|
import { getRuntimeEnvironment } from "../../utils/utils.js"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* TokenClient - Manages session token for browser authentication
|
|
5
5
|
*/
|
|
6
6
|
export interface AuthTokens {
|
|
7
|
-
sessionToken
|
|
8
|
-
accessToken: string;
|
|
9
|
-
refreshToken: string;
|
|
10
|
-
expiresIn?: number | undefined;
|
|
11
|
-
expiresAt?: number | undefined;
|
|
12
|
-
tokenType?: string | undefined;
|
|
7
|
+
sessionToken: string;
|
|
13
8
|
}
|
|
14
9
|
|
|
15
|
-
/**
|
|
16
|
-
* TokenClient - Manages authentication tokens for both browser and server environments
|
|
17
|
-
* Implements Web UI Flow token handling as described in Taruvi documentation
|
|
18
|
-
*/
|
|
19
10
|
export class TokenClient {
|
|
20
|
-
private static readonly ACCESS_TOKEN_KEY = 'jwt';
|
|
21
|
-
private static readonly REFRESH_TOKEN_KEY = 'refresh_token';
|
|
22
11
|
private static readonly SESSION_TOKEN_KEY = 'session_token';
|
|
23
|
-
private static readonly EXPIRES_AT_KEY = 'token_expires_at';
|
|
24
|
-
private static readonly TOKEN_TYPE_KEY = 'token_type';
|
|
25
12
|
|
|
26
13
|
private runTimeEnvironment: string
|
|
27
14
|
private browserRunTime: boolean
|
|
@@ -31,179 +18,61 @@ export class TokenClient {
|
|
|
31
18
|
this.runTimeEnvironment = getRuntimeEnvironment()
|
|
32
19
|
this.browserRunTime = this.runTimeEnvironment == "Browser"
|
|
33
20
|
|
|
34
|
-
// For server-side usage, store the token
|
|
35
21
|
if (!this.browserRunTime && token) {
|
|
36
22
|
this.serverToken = token
|
|
37
23
|
}
|
|
38
24
|
}
|
|
39
25
|
|
|
40
26
|
/**
|
|
41
|
-
* Get
|
|
27
|
+
* Get session token
|
|
42
28
|
*/
|
|
43
|
-
|
|
29
|
+
getSessionToken(): string | null {
|
|
44
30
|
if (this.browserRunTime) {
|
|
45
31
|
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
46
32
|
return null
|
|
47
33
|
}
|
|
48
|
-
return localStorage.getItem(TokenClient.
|
|
34
|
+
return localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
|
|
49
35
|
}
|
|
50
36
|
return this.serverToken
|
|
51
37
|
}
|
|
52
38
|
|
|
53
39
|
/**
|
|
54
|
-
*
|
|
40
|
+
* Alias for getSessionToken (used by server-side code)
|
|
55
41
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.warn('Token storage is only available in browser environment')
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
// Store access token
|
|
68
|
-
localStorage.setItem(TokenClient.ACCESS_TOKEN_KEY, tokens.accessToken)
|
|
69
|
-
|
|
70
|
-
// Store refresh token
|
|
71
|
-
localStorage.setItem(TokenClient.REFRESH_TOKEN_KEY, tokens.refreshToken)
|
|
72
|
-
|
|
73
|
-
// Store session token if provided
|
|
74
|
-
if (tokens.sessionToken) {
|
|
75
|
-
localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, tokens.sessionToken)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Calculate and store expiration time
|
|
79
|
-
if (tokens.expiresIn) {
|
|
80
|
-
const expiresAt = Date.now() + (tokens.expiresIn * 1000)
|
|
81
|
-
localStorage.setItem(TokenClient.EXPIRES_AT_KEY, expiresAt.toString())
|
|
82
|
-
} else if (tokens.expiresAt) {
|
|
83
|
-
localStorage.setItem(TokenClient.EXPIRES_AT_KEY, tokens.expiresAt.toString())
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Store token type
|
|
87
|
-
if (tokens.tokenType) {
|
|
88
|
-
localStorage.setItem(TokenClient.TOKEN_TYPE_KEY, tokens.tokenType)
|
|
89
|
-
}
|
|
90
|
-
} catch (err) {
|
|
91
|
-
console.error('Failed to store authentication tokens:', err)
|
|
92
|
-
}
|
|
42
|
+
getToken(): string | null {
|
|
43
|
+
return this.getSessionToken()
|
|
93
44
|
}
|
|
94
45
|
|
|
95
46
|
/**
|
|
96
|
-
*
|
|
47
|
+
* Store session token
|
|
97
48
|
*/
|
|
98
|
-
|
|
49
|
+
setTokens(tokens: AuthTokens): void {
|
|
99
50
|
if (!this.browserRunTime) {
|
|
100
|
-
|
|
51
|
+
console.warn('Token storage is only available in browser environment')
|
|
52
|
+
return
|
|
101
53
|
}
|
|
102
54
|
|
|
103
55
|
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
104
|
-
return
|
|
56
|
+
return
|
|
105
57
|
}
|
|
106
58
|
|
|
107
59
|
try {
|
|
108
|
-
|
|
109
|
-
const refreshToken = localStorage.getItem(TokenClient.REFRESH_TOKEN_KEY)
|
|
110
|
-
const sessionToken = localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
|
|
111
|
-
const expiresAt = localStorage.getItem(TokenClient.EXPIRES_AT_KEY)
|
|
112
|
-
const tokenType = localStorage.getItem(TokenClient.TOKEN_TYPE_KEY)
|
|
113
|
-
|
|
114
|
-
if (!accessToken || !refreshToken) {
|
|
115
|
-
return null
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const tokens: AuthTokens = {
|
|
119
|
-
accessToken,
|
|
120
|
-
refreshToken,
|
|
121
|
-
tokenType: tokenType || 'Bearer',
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (sessionToken) {
|
|
125
|
-
tokens.sessionToken = sessionToken
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (expiresAt) {
|
|
129
|
-
tokens.expiresIn = Math.floor((parseInt(expiresAt) - Date.now()) / 1000)
|
|
130
|
-
tokens.expiresAt = parseInt(expiresAt)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return tokens
|
|
60
|
+
localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, tokens.sessionToken)
|
|
134
61
|
} catch (err) {
|
|
135
|
-
console.error('Failed to
|
|
136
|
-
return null
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Get refresh token
|
|
142
|
-
*/
|
|
143
|
-
getRefreshToken(): string | null {
|
|
144
|
-
if (!this.browserRunTime) {
|
|
145
|
-
return null
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
149
|
-
return null
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return localStorage.getItem(TokenClient.REFRESH_TOKEN_KEY)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Get session token
|
|
157
|
-
*/
|
|
158
|
-
getSessionToken(): string | null {
|
|
159
|
-
if (!this.browserRunTime) {
|
|
160
|
-
return null
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
164
|
-
return null
|
|
62
|
+
console.error('Failed to store session token:', err)
|
|
165
63
|
}
|
|
166
|
-
|
|
167
|
-
return localStorage.getItem(TokenClient.SESSION_TOKEN_KEY)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Check if user is authenticated (has valid access token)
|
|
172
|
-
*/
|
|
173
|
-
isAuthenticated(): boolean {
|
|
174
|
-
return !!this.getToken()
|
|
175
64
|
}
|
|
176
65
|
|
|
177
66
|
/**
|
|
178
|
-
*
|
|
179
|
-
*/
|
|
180
|
-
isTokenExpired(): boolean {
|
|
181
|
-
if (!this.browserRunTime) {
|
|
182
|
-
return true
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
186
|
-
return true
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const expiresAt = localStorage.getItem(TokenClient.EXPIRES_AT_KEY)
|
|
190
|
-
if (!expiresAt) {
|
|
191
|
-
return true
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return parseInt(expiresAt) < Date.now()
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Set access token directly (e.g., from external storage)
|
|
67
|
+
* Set session token directly
|
|
199
68
|
*/
|
|
200
69
|
setAccessToken(token: string): void {
|
|
201
70
|
if (this.browserRunTime) {
|
|
202
71
|
if (typeof window === 'undefined' || typeof localStorage === 'undefined') return
|
|
203
72
|
try {
|
|
204
|
-
localStorage.setItem(TokenClient.
|
|
73
|
+
localStorage.setItem(TokenClient.SESSION_TOKEN_KEY, token)
|
|
205
74
|
} catch (err) {
|
|
206
|
-
console.error('Failed to set
|
|
75
|
+
console.error('Failed to set session token:', err)
|
|
207
76
|
}
|
|
208
77
|
} else {
|
|
209
78
|
this.serverToken = token
|
|
@@ -211,53 +80,14 @@ export class TokenClient {
|
|
|
211
80
|
}
|
|
212
81
|
|
|
213
82
|
/**
|
|
214
|
-
*
|
|
215
|
-
* ⚠️ IMPORTANT: Taruvi uses refresh token rotation
|
|
216
|
-
* When you refresh, you get BOTH a new access token AND a new refresh token
|
|
83
|
+
* Check if user is authenticated (has a session token)
|
|
217
84
|
*/
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
console.warn('Token update is only available in browser environment')
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
225
|
-
return
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
localStorage.setItem(TokenClient.ACCESS_TOKEN_KEY, accessToken)
|
|
230
|
-
const expiresAt = Date.now() + (expiresIn * 1000)
|
|
231
|
-
localStorage.setItem(TokenClient.EXPIRES_AT_KEY, expiresAt.toString())
|
|
232
|
-
} catch (err) {
|
|
233
|
-
console.error('Failed to update access token:', err)
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Update refresh token after rotation
|
|
239
|
-
* ⚠️ IMPORTANT: Taruvi rotates refresh tokens
|
|
240
|
-
* Always update the refresh token when you receive a new one
|
|
241
|
-
*/
|
|
242
|
-
updateRefreshToken(refreshToken: string): void {
|
|
243
|
-
if (!this.browserRunTime) {
|
|
244
|
-
console.warn('Token update is only available in browser environment')
|
|
245
|
-
return
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
localStorage.setItem(TokenClient.REFRESH_TOKEN_KEY, refreshToken)
|
|
254
|
-
} catch (err) {
|
|
255
|
-
console.error('Failed to update refresh token:', err)
|
|
256
|
-
}
|
|
85
|
+
isAuthenticated(): boolean {
|
|
86
|
+
return !!this.getSessionToken()
|
|
257
87
|
}
|
|
258
88
|
|
|
259
89
|
/**
|
|
260
|
-
* Clear
|
|
90
|
+
* Clear session token
|
|
261
91
|
*/
|
|
262
92
|
clearTokens(): void {
|
|
263
93
|
if (!this.browserRunTime) {
|
|
@@ -270,13 +100,9 @@ export class TokenClient {
|
|
|
270
100
|
}
|
|
271
101
|
|
|
272
102
|
try {
|
|
273
|
-
localStorage.removeItem(TokenClient.ACCESS_TOKEN_KEY)
|
|
274
|
-
localStorage.removeItem(TokenClient.REFRESH_TOKEN_KEY)
|
|
275
103
|
localStorage.removeItem(TokenClient.SESSION_TOKEN_KEY)
|
|
276
|
-
localStorage.removeItem(TokenClient.EXPIRES_AT_KEY)
|
|
277
|
-
localStorage.removeItem(TokenClient.TOKEN_TYPE_KEY)
|
|
278
104
|
} catch (err) {
|
|
279
|
-
console.error('Failed to clear
|
|
105
|
+
console.error('Failed to clear session token:', err)
|
|
280
106
|
}
|
|
281
107
|
}
|
|
282
108
|
}
|
|
@@ -5,11 +5,8 @@ import { Client } from '../../../src/client.js'
|
|
|
5
5
|
const mockTokenClient = {
|
|
6
6
|
isAuthenticated: vi.fn(),
|
|
7
7
|
getToken: vi.fn(),
|
|
8
|
-
|
|
9
|
-
isTokenExpired: vi.fn(),
|
|
8
|
+
getSessionToken: vi.fn(),
|
|
10
9
|
clearTokens: vi.fn(),
|
|
11
|
-
updateAccessToken: vi.fn(),
|
|
12
|
-
updateRefreshToken: vi.fn()
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
const mockHttpClient = {
|
|
@@ -47,45 +44,17 @@ describe('Auth', () => {
|
|
|
47
44
|
})
|
|
48
45
|
})
|
|
49
46
|
|
|
50
|
-
describe('
|
|
51
|
-
it('returns
|
|
52
|
-
mockTokenClient.
|
|
47
|
+
describe('getSessionToken()', () => {
|
|
48
|
+
it('returns session token from tokenClient', () => {
|
|
49
|
+
mockTokenClient.getSessionToken.mockReturnValue('session-token-123')
|
|
53
50
|
const auth = new Auth(mockClient)
|
|
54
|
-
expect(auth.
|
|
51
|
+
expect(auth.getSessionToken()).toBe('session-token-123')
|
|
55
52
|
})
|
|
56
53
|
|
|
57
|
-
it('returns null when no token exists', () => {
|
|
58
|
-
mockTokenClient.
|
|
54
|
+
it('returns null when no session token exists', () => {
|
|
55
|
+
mockTokenClient.getSessionToken.mockReturnValue(null)
|
|
59
56
|
const auth = new Auth(mockClient)
|
|
60
|
-
expect(auth.
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
describe('getRefreshToken()', () => {
|
|
65
|
-
it('returns refresh token from tokenClient', () => {
|
|
66
|
-
mockTokenClient.getRefreshToken.mockReturnValue('refresh-token-456')
|
|
67
|
-
const auth = new Auth(mockClient)
|
|
68
|
-
expect(auth.getRefreshToken()).toBe('refresh-token-456')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('returns null when no refresh token exists', () => {
|
|
72
|
-
mockTokenClient.getRefreshToken.mockReturnValue(null)
|
|
73
|
-
const auth = new Auth(mockClient)
|
|
74
|
-
expect(auth.getRefreshToken()).toBeNull()
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
describe('isTokenExpired()', () => {
|
|
79
|
-
it('returns true when token is expired', () => {
|
|
80
|
-
mockTokenClient.isTokenExpired.mockReturnValue(true)
|
|
81
|
-
const auth = new Auth(mockClient)
|
|
82
|
-
expect(auth.isTokenExpired()).toBe(true)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('returns false when token is valid', () => {
|
|
86
|
-
mockTokenClient.isTokenExpired.mockReturnValue(false)
|
|
87
|
-
const auth = new Auth(mockClient)
|
|
88
|
-
expect(auth.isTokenExpired()).toBe(false)
|
|
57
|
+
expect(auth.getSessionToken()).toBeNull()
|
|
89
58
|
})
|
|
90
59
|
})
|
|
91
60
|
|
|
@@ -119,13 +88,4 @@ describe('Auth', () => {
|
|
|
119
88
|
expect(result).toBeNull()
|
|
120
89
|
})
|
|
121
90
|
})
|
|
122
|
-
|
|
123
|
-
describe('refreshAccessToken()', () => {
|
|
124
|
-
it('returns null when no refresh token available', async () => {
|
|
125
|
-
mockTokenClient.getRefreshToken.mockReturnValue(null)
|
|
126
|
-
const auth = new Auth(mockClient)
|
|
127
|
-
const result = await auth.refreshAccessToken()
|
|
128
|
-
expect(result).toBeNull()
|
|
129
|
-
})
|
|
130
|
-
})
|
|
131
91
|
})
|
|
@@ -423,6 +423,84 @@ describe('Database', () => {
|
|
|
423
423
|
})
|
|
424
424
|
})
|
|
425
425
|
|
|
426
|
+
describe('upsert()', () => {
|
|
427
|
+
it('calls httpClient.post with body to upsert URL', async () => {
|
|
428
|
+
const body = { name: 'Test', status: 'active' }
|
|
429
|
+
mockHttpClient.post.mockResolvedValue({ id: '1', ...body })
|
|
430
|
+
await new Database(mockClient).from('accounts').upsert(body).execute()
|
|
431
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
432
|
+
'api/apps/test-app/datatables/accounts/data/upsert/',
|
|
433
|
+
body
|
|
434
|
+
)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('builds correct upsert URL with array body', async () => {
|
|
438
|
+
const body = [{ name: 'A' }, { name: 'B' }]
|
|
439
|
+
mockHttpClient.post.mockResolvedValue({ data: body })
|
|
440
|
+
await new Database(mockClient).from('accounts').upsert(body).execute()
|
|
441
|
+
expect(mockHttpClient.post).toHaveBeenCalledWith(
|
|
442
|
+
'api/apps/test-app/datatables/accounts/data/upsert/',
|
|
443
|
+
body
|
|
444
|
+
)
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
describe('bulkUpdate()', () => {
|
|
449
|
+
it('calls httpClient.patch with array body without recordId', async () => {
|
|
450
|
+
const body = [{ id: '1', status: 'inactive' }, { id: '2', status: 'active' }]
|
|
451
|
+
mockHttpClient.patch.mockResolvedValue({ data: body })
|
|
452
|
+
await new Database(mockClient).from('accounts').bulkUpdate(body).execute()
|
|
453
|
+
expect(mockHttpClient.patch).toHaveBeenCalledWith(
|
|
454
|
+
'api/apps/test-app/datatables/accounts/data/',
|
|
455
|
+
body
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('does not throw without recordId when body is array', async () => {
|
|
460
|
+
const body = [{ id: '1', name: 'Updated' }]
|
|
461
|
+
mockHttpClient.patch.mockResolvedValue({ data: body })
|
|
462
|
+
await expect(
|
|
463
|
+
new Database(mockClient).from('accounts').bulkUpdate(body).execute()
|
|
464
|
+
).resolves.toBeDefined()
|
|
465
|
+
})
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
describe('bulkDelete()', () => {
|
|
469
|
+
it('calls httpClient.delete with ids in query string', async () => {
|
|
470
|
+
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
471
|
+
await new Database(mockClient).from('accounts').bulkDelete(['1']).execute()
|
|
472
|
+
expect(mockHttpClient.delete).toHaveBeenCalledWith(
|
|
473
|
+
expect.stringContaining('ids=1')
|
|
474
|
+
)
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
it('joins multiple ids with comma', async () => {
|
|
478
|
+
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
479
|
+
await new Database(mockClient).from('accounts').bulkDelete(['1', '2', '3']).execute()
|
|
480
|
+
expect(mockHttpClient.delete).toHaveBeenCalledWith(
|
|
481
|
+
expect.stringContaining('ids=1%2C2%2C3')
|
|
482
|
+
)
|
|
483
|
+
})
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
describe('deleteFiltered()', () => {
|
|
487
|
+
it('calls httpClient.delete with filter params in query string', async () => {
|
|
488
|
+
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
489
|
+
await new Database(mockClient).from('accounts').filter('status', 'eq', 'inactive').deleteFiltered().execute()
|
|
490
|
+
const url = mockHttpClient.delete.mock.calls[0][0]
|
|
491
|
+
expect(url).toContain('status=inactive')
|
|
492
|
+
expect(url).not.toContain('/undefined/')
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('hits collection endpoint without recordId', async () => {
|
|
496
|
+
mockHttpClient.delete.mockResolvedValue({ status: 'success' })
|
|
497
|
+
await new Database(mockClient).from('accounts').filter('age', 'lt', 18).deleteFiltered().execute()
|
|
498
|
+
expect(mockHttpClient.delete).toHaveBeenCalledWith(
|
|
499
|
+
expect.stringContaining('api/apps/test-app/datatables/accounts/data/?')
|
|
500
|
+
)
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
|
|
426
504
|
describe('edges()', () => {
|
|
427
505
|
it('targets _edges table for list', async () => {
|
|
428
506
|
mockHttpClient.get.mockResolvedValue({ edges: [], total: 0 })
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ConflictError,
|
|
9
9
|
TimeoutError,
|
|
10
10
|
NetworkError,
|
|
11
|
+
RateLimitError,
|
|
11
12
|
createErrorFromResponse,
|
|
12
13
|
ErrorCode
|
|
13
14
|
} from '../../../src/lib-internal/errors/index.js'
|
|
@@ -81,6 +82,21 @@ describe('Error classes', () => {
|
|
|
81
82
|
expect(err.code).toBe('NETWORK_ERROR')
|
|
82
83
|
})
|
|
83
84
|
|
|
85
|
+
it('RateLimitError defaults', () => {
|
|
86
|
+
const err = new RateLimitError()
|
|
87
|
+
expect(err.name).toBe('RateLimitError')
|
|
88
|
+
expect(err.statusCode).toBe(429)
|
|
89
|
+
expect(err.code).toBe('RATE_LIMITED')
|
|
90
|
+
expect(err.retryAfter).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('RateLimitError with retryAfter', () => {
|
|
94
|
+
const err = new RateLimitError('Too many requests', 60)
|
|
95
|
+
expect(err.message).toBe('Too many requests')
|
|
96
|
+
expect(err.retryAfter).toBe(60)
|
|
97
|
+
expect(err).toBeInstanceOf(TaruviError)
|
|
98
|
+
})
|
|
99
|
+
|
|
84
100
|
it('all errors are instanceof TaruviError', () => {
|
|
85
101
|
expect(new ValidationError()).toBeInstanceOf(TaruviError)
|
|
86
102
|
expect(new AuthError()).toBeInstanceOf(TaruviError)
|
|
@@ -89,6 +105,7 @@ describe('Error classes', () => {
|
|
|
89
105
|
expect(new ConflictError()).toBeInstanceOf(TaruviError)
|
|
90
106
|
expect(new TimeoutError()).toBeInstanceOf(TaruviError)
|
|
91
107
|
expect(new NetworkError()).toBeInstanceOf(TaruviError)
|
|
108
|
+
expect(new RateLimitError()).toBeInstanceOf(TaruviError)
|
|
92
109
|
})
|
|
93
110
|
|
|
94
111
|
it('all errors are instanceof Error', () => {
|
|
@@ -171,6 +188,16 @@ describe('createErrorFromResponse', () => {
|
|
|
171
188
|
expect(err).toBeInstanceOf(TimeoutError)
|
|
172
189
|
})
|
|
173
190
|
|
|
191
|
+
it('429 returns RateLimitError', () => {
|
|
192
|
+
const err = createErrorFromResponse(429, {
|
|
193
|
+
status: 'error',
|
|
194
|
+
code: 'RATE_LIMITED',
|
|
195
|
+
message: 'Too many requests'
|
|
196
|
+
})
|
|
197
|
+
expect(err).toBeInstanceOf(RateLimitError)
|
|
198
|
+
expect(err.message).toBe('Too many requests')
|
|
199
|
+
})
|
|
200
|
+
|
|
174
201
|
it('500 returns TaruviError', () => {
|
|
175
202
|
const err = createErrorFromResponse(500, {
|
|
176
203
|
status: 'error',
|