@blueprint-ts/core 4.0.0-beta.8 → 4.0.0
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 +14 -0
- package/docs/services/pagination/index.md +1 -1
- package/docs/services/pagination/updating-rows.md +16 -0
- package/package.json +1 -1
- package/release-tool.json +1 -1
- package/src/pagination/BasePaginator.ts +19 -0
- package/src/requests/BaseRequest.ts +2 -1
- package/src/vue/forms/validation/rules/EmailRule.ts +1 -1
- package/tests/service/bulkRequests/BulkRequestSender.test.ts +76 -0
- package/tests/service/bulkRequests/BulkRequestWrapper.test.ts +51 -0
- package/tests/service/pagination/BasePaginator.test.ts +100 -0
- package/tests/service/pagination/InfiniteScroller.test.ts +101 -0
- package/tests/service/pagination/PageAwarePaginator.test.ts +133 -0
- package/tests/service/pagination/StatePaginator.test.ts +76 -0
- package/tests/service/pagination/VueViewDrivers.test.ts +46 -0
- package/tests/service/pagination/dtos/StatePaginationDataDto.test.ts +14 -0
- package/tests/service/persistenceDrivers/PersistenceDrivers.test.ts +56 -0
- package/tests/service/requests/BaseRequest.test.ts +199 -0
- package/tests/service/requests/BodiesAndFactories.test.ts +28 -0
- package/tests/service/requests/Enums.test.ts +19 -0
- package/tests/service/requests/ErrorHandler.test.ts +45 -1
- package/tests/service/requests/RequestErrorRouter.test.ts +44 -0
- package/tests/service/requests/Responses.test.ts +83 -0
- package/tests/service/requests/exceptions/Exceptions.test.ts +43 -0
- package/tests/service/requests/fetch/FetchDriver.test.ts +76 -0
- package/tests/service/requests/fetch/FetchResponse.test.ts +21 -0
- package/tests/service/support/DeferredPromise.test.ts +40 -0
- package/tests/service/support/helpers.test.ts +37 -0
- package/tests/vue/composables/useConfirmDialog.test.ts +77 -0
- package/tests/vue/composables/useGlobalCheckbox.test.ts +126 -0
- package/tests/vue/composables/useIsEmpty.test.ts +18 -0
- package/tests/vue/composables/useIsOpen.test.ts +25 -0
- package/tests/vue/composables/useIsOpenFromVar.test.ts +22 -0
- package/tests/vue/composables/useModelWrapper.test.ts +30 -0
- package/tests/vue/composables/useOnOpen.test.ts +26 -0
- package/tests/vue/forms/PropertyAwareArray.test.ts +30 -0
- package/tests/vue/forms/validation/ValidationRules.test.ts +79 -0
- package/tests/vue/requests/VueRequestLoaders.test.ts +48 -0
- package/tests/vue/router/routeResourceBinding/RouteResourceUtils.test.ts +70 -0
- package/tests/vue/state/State.test.ts +151 -0
- package/vitest.config.ts +10 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { VueBaseViewDriver } from '../../../src/pagination/frontendDrivers/VueBaseViewDriver'
|
|
3
|
+
import { VuePaginationDriver } from '../../../src/pagination/frontendDrivers/VuePaginationDriver'
|
|
4
|
+
import { VueBaseViewDriverFactory } from '../../../src/pagination/factories/VueBaseViewDriverFactory'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
describe('Vue view drivers', () => {
|
|
9
|
+
it('VueBaseViewDriver stores data and total', () => {
|
|
10
|
+
const driver = new VueBaseViewDriver<number>()
|
|
11
|
+
|
|
12
|
+
driver.setData([1, 2])
|
|
13
|
+
driver.setTotal(5)
|
|
14
|
+
|
|
15
|
+
expect(driver.getData()).toEqual([1, 2])
|
|
16
|
+
expect(driver.getTotal()).toBe(5)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('VuePaginationDriver tracks pages', () => {
|
|
20
|
+
const driver = new VuePaginationDriver<number>(2, 5)
|
|
21
|
+
|
|
22
|
+
driver.setTotal(12)
|
|
23
|
+
|
|
24
|
+
expect(driver.getCurrentPage()).toBe(2)
|
|
25
|
+
expect(driver.getPageSize()).toBe(5)
|
|
26
|
+
expect(driver.getLastPage()).toBe(3)
|
|
27
|
+
expect(driver.getPages()).toEqual([1, 2, 3])
|
|
28
|
+
|
|
29
|
+
driver.setPage(1)
|
|
30
|
+
driver.setPageSize(4)
|
|
31
|
+
driver.setTotal(9)
|
|
32
|
+
|
|
33
|
+
expect(driver.getCurrentPage()).toBe(1)
|
|
34
|
+
expect(driver.getPageSize()).toBe(4)
|
|
35
|
+
expect(driver.getLastPage()).toBe(3)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('VueBaseViewDriverFactory creates a driver', () => {
|
|
39
|
+
const factory = new VueBaseViewDriverFactory()
|
|
40
|
+
|
|
41
|
+
const driver = factory.make<number>()
|
|
42
|
+
|
|
43
|
+
expect(driver.getData()).toEqual([])
|
|
44
|
+
expect(driver.getTotal()).toBe(0)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { StatePaginationDataDto } from '../../../../src/pagination/dtos/StatePaginationDataDto'
|
|
3
|
+
|
|
4
|
+
describe('StatePaginationDataDto', () => {
|
|
5
|
+
it('returns state and next page status', () => {
|
|
6
|
+
const dto = new StatePaginationDataDto([1], 1, 'next')
|
|
7
|
+
|
|
8
|
+
expect(dto.getState()).toBe('next')
|
|
9
|
+
expect(dto.hasNextPage()).toBe(true)
|
|
10
|
+
|
|
11
|
+
const end = new StatePaginationDataDto([1], 1, null)
|
|
12
|
+
expect(end.hasNextPage()).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { LocalStorageDriver } from '../../../src/persistenceDrivers/LocalStorageDriver'
|
|
3
|
+
import { NonPersistentDriver } from '../../../src/persistenceDrivers/NonPersistentDriver'
|
|
4
|
+
import { SessionStorageDriver } from '../../../src/persistenceDrivers/SessionStorageDriver'
|
|
5
|
+
|
|
6
|
+
const clearStorage = () => {
|
|
7
|
+
localStorage.clear()
|
|
8
|
+
sessionStorage.clear()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('Persistence drivers', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
clearStorage()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('NonPersistentDriver does not persist', () => {
|
|
17
|
+
const driver = new NonPersistentDriver()
|
|
18
|
+
|
|
19
|
+
expect(driver.get('any')).toBeNull()
|
|
20
|
+
expect(() => driver.set('key', { value: 1 })).not.toThrow()
|
|
21
|
+
expect(() => driver.remove('key')).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('LocalStorageDriver stores and retrieves values with suffix', () => {
|
|
25
|
+
const driver = new LocalStorageDriver('abc')
|
|
26
|
+
|
|
27
|
+
driver.set('user', { id: 1 })
|
|
28
|
+
|
|
29
|
+
expect(localStorage.getItem('state_user_abc')).toBe('{"id":1}')
|
|
30
|
+
expect(driver.get('user')).toEqual({ id: 1 })
|
|
31
|
+
|
|
32
|
+
driver.remove('user')
|
|
33
|
+
expect(localStorage.getItem('state_user_abc')).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('LocalStorageDriver stores and retrieves values without suffix', () => {
|
|
37
|
+
const driver = new LocalStorageDriver()
|
|
38
|
+
|
|
39
|
+
driver.set('prefs', { dark: true })
|
|
40
|
+
|
|
41
|
+
expect(localStorage.getItem('state_prefs')).toBe('{"dark":true}')
|
|
42
|
+
expect(driver.get('prefs')).toEqual({ dark: true })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('SessionStorageDriver stores and retrieves values with suffix', () => {
|
|
46
|
+
const driver = new SessionStorageDriver('v1')
|
|
47
|
+
|
|
48
|
+
driver.set('token', { value: 'abc' })
|
|
49
|
+
|
|
50
|
+
expect(sessionStorage.getItem('state_token_v1')).toBe('{"value":"abc"}')
|
|
51
|
+
expect(driver.get('token')).toEqual({ value: 'abc' })
|
|
52
|
+
|
|
53
|
+
driver.remove('token')
|
|
54
|
+
expect(sessionStorage.getItem('state_token_v1')).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { BaseRequest } from '../../../src/requests/BaseRequest'
|
|
3
|
+
import { BaseResponse } from '../../../src/requests/responses/BaseResponse'
|
|
4
|
+
import { JsonBodyFactory } from '../../../src/requests/factories/JsonBodyFactory'
|
|
5
|
+
import { RequestMethodEnum } from '../../../src/requests/RequestMethod.enum'
|
|
6
|
+
import { RequestEvents } from '../../../src/requests/RequestEvents.enum'
|
|
7
|
+
import { RequestConcurrencyMode } from '../../../src/requests/RequestConcurrencyMode.enum'
|
|
8
|
+
import { StaleResponseException } from '../../../src/requests/exceptions/StaleResponseException'
|
|
9
|
+
import { ResponseException } from '../../../src/requests/exceptions/ResponseException'
|
|
10
|
+
import { ErrorHandler } from '../../../src/requests/ErrorHandler'
|
|
11
|
+
import type { RequestDriverContract } from '../../../src/requests/contracts/RequestDriverContract'
|
|
12
|
+
import type { RequestLoaderContract } from '../../../src/requests/contracts/RequestLoaderContract'
|
|
13
|
+
import type { RequestLoaderFactoryContract } from '../../../src/requests/contracts/RequestLoaderFactoryContract'
|
|
14
|
+
import type { ResponseHandlerContract } from '../../../src/requests/drivers/contracts/ResponseHandlerContract'
|
|
15
|
+
|
|
16
|
+
class TestResponse extends BaseResponse<string> {
|
|
17
|
+
public getAcceptHeader(): string {
|
|
18
|
+
return 'text/plain'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
protected resolveBody(): Promise<string> {
|
|
22
|
+
return Promise.resolve('ok')
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class TestLoader implements RequestLoaderContract<boolean> {
|
|
27
|
+
private loading = false
|
|
28
|
+
|
|
29
|
+
isLoading(): boolean {
|
|
30
|
+
return this.loading
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setLoading(value: boolean): void {
|
|
34
|
+
this.loading = value
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class TestRequest extends BaseRequest<boolean, { message: string }, string, TestResponse, { name: string }, { filter?: { active?: boolean } }> {
|
|
39
|
+
public method(): RequestMethodEnum {
|
|
40
|
+
return RequestMethodEnum.POST
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public url(): string {
|
|
44
|
+
return '/test'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public getResponse(): TestResponse {
|
|
48
|
+
return new TestResponse()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public requestHeaders() {
|
|
52
|
+
return { 'X-Req': 'value' }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public getRequestBodyFactory() {
|
|
56
|
+
return new JsonBodyFactory<{ name: string }>()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const createResponseHandler = (): ResponseHandlerContract => ({
|
|
61
|
+
getStatusCode: () => 200,
|
|
62
|
+
getHeaders: () => ({}),
|
|
63
|
+
getRawResponse: () => new Response('ok'),
|
|
64
|
+
json: async () => ({ ok: true }),
|
|
65
|
+
text: async () => 'ok',
|
|
66
|
+
blob: async () => new Blob(),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('BaseRequest', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
BaseRequest.setDefaultBaseUrl('https://example.com')
|
|
72
|
+
BaseRequest.setRequestLoaderFactory(undefined as unknown as RequestLoaderFactoryContract<boolean>)
|
|
73
|
+
BaseRequest.setRequestDriver(undefined as unknown as RequestDriverContract)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('builds URLs with params and merges params deeply', () => {
|
|
77
|
+
const request = new TestRequest()
|
|
78
|
+
|
|
79
|
+
request.setParams({ filter: { active: true } })
|
|
80
|
+
request.withParams({ filter: { active: false } })
|
|
81
|
+
|
|
82
|
+
expect(request.getParams()).toEqual({ filter: { active: false } })
|
|
83
|
+
|
|
84
|
+
const url = request.buildUrl()
|
|
85
|
+
expect(url.toString()).toBe('https://example.com/test?filter%5Bactive%5D=false')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('dispatches loading events and toggles loader', async () => {
|
|
89
|
+
const loaderFactory: RequestLoaderFactoryContract<boolean> = {
|
|
90
|
+
make: () => new TestLoader(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
BaseRequest.setRequestLoaderFactory(loaderFactory)
|
|
94
|
+
|
|
95
|
+
const driver: RequestDriverContract = {
|
|
96
|
+
send: vi.fn().mockResolvedValue(createResponseHandler()),
|
|
97
|
+
}
|
|
98
|
+
BaseRequest.setRequestDriver(driver)
|
|
99
|
+
|
|
100
|
+
const request = new TestRequest()
|
|
101
|
+
const loadingEvents: boolean[] = []
|
|
102
|
+
|
|
103
|
+
request.on(RequestEvents.LOADING, (value: boolean) => loadingEvents.push(value))
|
|
104
|
+
request.setBody({ name: 'Ada' })
|
|
105
|
+
|
|
106
|
+
const response = await request.send()
|
|
107
|
+
|
|
108
|
+
expect(response.getBody()).toBe('ok')
|
|
109
|
+
expect(loadingEvents).toEqual([true, false])
|
|
110
|
+
expect(request.isLoading()).toBe(false)
|
|
111
|
+
|
|
112
|
+
expect(driver.send).toHaveBeenCalledTimes(1)
|
|
113
|
+
|
|
114
|
+
const [url, method, headers, body] = (driver.send as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
115
|
+
expect(url.toString()).toBe('https://example.com/test')
|
|
116
|
+
expect(method).toBe(RequestMethodEnum.POST)
|
|
117
|
+
expect(headers).toEqual({ Accept: 'text/plain', 'X-Req': 'value' })
|
|
118
|
+
expect(body?.getContent()).toBe('{"name":"Ada"}')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('throws when loading state is requested without a loader', () => {
|
|
122
|
+
const request = new TestRequest()
|
|
123
|
+
|
|
124
|
+
expect(() => request.isLoading()).toThrow('Request loader is not set.')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('marks stale responses when using LATEST concurrency mode', async () => {
|
|
128
|
+
const driver: RequestDriverContract = {
|
|
129
|
+
send: vi.fn(),
|
|
130
|
+
}
|
|
131
|
+
BaseRequest.setRequestDriver(driver)
|
|
132
|
+
|
|
133
|
+
const resolvers: Array<(value: ResponseHandlerContract) => void> = []
|
|
134
|
+
|
|
135
|
+
;(driver.send as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
|
136
|
+
new Promise<ResponseHandlerContract>((resolve) => {
|
|
137
|
+
resolvers.push(resolve)
|
|
138
|
+
})
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const request = new TestRequest()
|
|
142
|
+
request.setConcurrency({ mode: RequestConcurrencyMode.LATEST, key: 'latest-test' })
|
|
143
|
+
|
|
144
|
+
const first = request.send()
|
|
145
|
+
const second = request.send()
|
|
146
|
+
|
|
147
|
+
resolvers[1](createResponseHandler())
|
|
148
|
+
await expect(second).resolves.toBeInstanceOf(TestResponse)
|
|
149
|
+
|
|
150
|
+
resolvers[0](createResponseHandler())
|
|
151
|
+
await expect(first).rejects.toBeInstanceOf(StaleResponseException)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('aborts previous request when using REPLACE mode', async () => {
|
|
155
|
+
const driver: RequestDriverContract = {
|
|
156
|
+
send: vi.fn(),
|
|
157
|
+
}
|
|
158
|
+
BaseRequest.setRequestDriver(driver)
|
|
159
|
+
|
|
160
|
+
const request = new TestRequest()
|
|
161
|
+
request.setConcurrency({ mode: RequestConcurrencyMode.REPLACE, key: 'replace-test' })
|
|
162
|
+
|
|
163
|
+
const resolvers: Array<(value: ResponseHandlerContract) => void> = []
|
|
164
|
+
;(driver.send as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
|
165
|
+
new Promise<ResponseHandlerContract>((resolve) => {
|
|
166
|
+
resolvers.push(resolve)
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const firstPromise = request.send()
|
|
171
|
+
const firstConfig = (driver.send as ReturnType<typeof vi.fn>).mock.calls[0][4]
|
|
172
|
+
|
|
173
|
+
const secondPromise = request.send()
|
|
174
|
+
|
|
175
|
+
expect(firstConfig?.abortSignal?.aborted).toBe(true)
|
|
176
|
+
|
|
177
|
+
resolvers[0](createResponseHandler())
|
|
178
|
+
resolvers[1](createResponseHandler())
|
|
179
|
+
|
|
180
|
+
await Promise.allSettled([firstPromise, secondPromise])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('invokes ErrorHandler when a ResponseException is thrown', async () => {
|
|
184
|
+
const responseHandler = createResponseHandler()
|
|
185
|
+
const responseException = new ResponseException(responseHandler)
|
|
186
|
+
|
|
187
|
+
const driver: RequestDriverContract = {
|
|
188
|
+
send: vi.fn().mockRejectedValue(responseException),
|
|
189
|
+
}
|
|
190
|
+
BaseRequest.setRequestDriver(driver)
|
|
191
|
+
|
|
192
|
+
const handleSpy = vi.spyOn(ErrorHandler.prototype, 'handle').mockResolvedValue(undefined as never)
|
|
193
|
+
|
|
194
|
+
const request = new TestRequest()
|
|
195
|
+
|
|
196
|
+
await expect(request.send()).rejects.toBe(responseException)
|
|
197
|
+
expect(handleSpy).toHaveBeenCalledTimes(1)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { JsonBody } from '../../../src/requests/bodies/JsonBody'
|
|
3
|
+
import { JsonBodyFactory } from '../../../src/requests/factories/JsonBodyFactory'
|
|
4
|
+
import { FormDataFactory } from '../../../src/requests/factories/FormDataFactory'
|
|
5
|
+
import { FormDataBody } from '../../../src/requests/bodies/FormDataBody'
|
|
6
|
+
|
|
7
|
+
describe('Request bodies and factories', () => {
|
|
8
|
+
it('JsonBody returns headers and JSON content', () => {
|
|
9
|
+
const body = new JsonBody({ hello: 'world' })
|
|
10
|
+
|
|
11
|
+
expect(body.getHeaders()).toEqual({ 'Content-Type': 'application/json' })
|
|
12
|
+
expect(body.getContent()).toBe('{"hello":"world"}')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('JsonBodyFactory returns JsonBody', () => {
|
|
16
|
+
const factory = new JsonBodyFactory<{ foo: string }>()
|
|
17
|
+
const body = factory.make({ foo: 'bar' })
|
|
18
|
+
|
|
19
|
+
expect(body).toBeInstanceOf(JsonBody)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('FormDataFactory returns FormDataBody', () => {
|
|
23
|
+
const factory = new FormDataFactory<{ name: string }>()
|
|
24
|
+
const body = factory.make({ name: 'alice' })
|
|
25
|
+
|
|
26
|
+
expect(body).toBeInstanceOf(FormDataBody)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { RequestMethodEnum } from '../../../src/requests/RequestMethod.enum'
|
|
3
|
+
import { RequestEvents } from '../../../src/requests/RequestEvents.enum'
|
|
4
|
+
import { RequestConcurrencyMode } from '../../../src/requests/RequestConcurrencyMode.enum'
|
|
5
|
+
import { BulkRequestExecutionMode } from '../../../src/bulkRequests/BulkRequestSender'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
describe('Enums', () => {
|
|
9
|
+
it('exposes expected request enums', () => {
|
|
10
|
+
expect(RequestMethodEnum.GET).toBe('GET')
|
|
11
|
+
expect(RequestEvents.LOADING).toBe('loading')
|
|
12
|
+
expect(RequestConcurrencyMode.REPLACE_LATEST).toBe('replace-latest')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('exposes bulk request execution modes', () => {
|
|
16
|
+
expect(BulkRequestExecutionMode.PARALLEL).toBe('parallel')
|
|
17
|
+
expect(BulkRequestExecutionMode.SEQUENTIAL).toBe('sequential')
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -9,7 +9,22 @@ import {
|
|
|
9
9
|
ResponseException,
|
|
10
10
|
NoResponseReceivedException,
|
|
11
11
|
ServerErrorException,
|
|
12
|
-
InvalidJsonException
|
|
12
|
+
InvalidJsonException,
|
|
13
|
+
BadRequestException,
|
|
14
|
+
ForbiddenException,
|
|
15
|
+
MethodNotAllowedException,
|
|
16
|
+
RequestTimeoutException,
|
|
17
|
+
ConflictException,
|
|
18
|
+
GoneException,
|
|
19
|
+
PreconditionFailedException,
|
|
20
|
+
PayloadTooLargeException,
|
|
21
|
+
UnsupportedMediaTypeException,
|
|
22
|
+
LockedException,
|
|
23
|
+
TooManyRequestsException,
|
|
24
|
+
NotImplementedException,
|
|
25
|
+
BadGatewayException,
|
|
26
|
+
ServiceUnavailableException,
|
|
27
|
+
GatewayTimeoutException
|
|
13
28
|
} from '../../../src/requests/exceptions';
|
|
14
29
|
|
|
15
30
|
describe('ErrorHandler', () => {
|
|
@@ -141,4 +156,33 @@ describe('ErrorHandler', () => {
|
|
|
141
156
|
await expect(handler.handle()).rejects.toThrow(InvalidJsonException);
|
|
142
157
|
expect(mockResponse.json).toHaveBeenCalled();
|
|
143
158
|
});
|
|
159
|
+
|
|
160
|
+
it.each([
|
|
161
|
+
[400, BadRequestException],
|
|
162
|
+
[403, ForbiddenException],
|
|
163
|
+
[405, MethodNotAllowedException],
|
|
164
|
+
[408, RequestTimeoutException],
|
|
165
|
+
[409, ConflictException],
|
|
166
|
+
[410, GoneException],
|
|
167
|
+
[412, PreconditionFailedException],
|
|
168
|
+
[413, PayloadTooLargeException],
|
|
169
|
+
[415, UnsupportedMediaTypeException],
|
|
170
|
+
[423, LockedException],
|
|
171
|
+
[429, TooManyRequestsException],
|
|
172
|
+
[501, NotImplementedException],
|
|
173
|
+
[502, BadGatewayException],
|
|
174
|
+
[503, ServiceUnavailableException],
|
|
175
|
+
[504, GatewayTimeoutException],
|
|
176
|
+
])('should throw correct exception for status code %i', async (status, ExceptionType) => {
|
|
177
|
+
const mockResponse = {
|
|
178
|
+
getStatusCode: vi.fn().mockReturnValue(status),
|
|
179
|
+
json: vi.fn().mockResolvedValue({}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const handler = new ErrorHandler(mockResponse as any);
|
|
183
|
+
|
|
184
|
+
await expect(handler.handle()).rejects.toThrow(ExceptionType);
|
|
185
|
+
expect(mockResponse.getStatusCode).toHaveBeenCalled();
|
|
186
|
+
expect(mockResponse.json).toHaveBeenCalled();
|
|
187
|
+
});
|
|
144
188
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { RequestErrorRouter } from '../../../src/requests/RequestErrorRouter'
|
|
3
|
+
import { BadRequestException } from '../../../src/requests/exceptions/BadRequestException'
|
|
4
|
+
|
|
5
|
+
const mockResponse = {
|
|
6
|
+
getStatusCode: () => 400,
|
|
7
|
+
getHeaders: () => ({}),
|
|
8
|
+
getRawResponse: () => new Response(),
|
|
9
|
+
json: async () => ({}),
|
|
10
|
+
text: async () => '',
|
|
11
|
+
blob: async () => new Blob(),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('RequestErrorRouter', () => {
|
|
15
|
+
it('routes known errors to handlers', async () => {
|
|
16
|
+
const router = new RequestErrorRouter()
|
|
17
|
+
const handler = vi.fn()
|
|
18
|
+
|
|
19
|
+
router.on(BadRequestException, handler)
|
|
20
|
+
|
|
21
|
+
const handled = await router.handle(new BadRequestException(mockResponse as any, { message: 'bad' }))
|
|
22
|
+
|
|
23
|
+
expect(handled).toBe(true)
|
|
24
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('uses default handler when no match found', async () => {
|
|
28
|
+
const router = new RequestErrorRouter()
|
|
29
|
+
const fallback = vi.fn()
|
|
30
|
+
|
|
31
|
+
router.otherwise(fallback)
|
|
32
|
+
|
|
33
|
+
const handled = await router.handle(new Error('other'))
|
|
34
|
+
|
|
35
|
+
expect(handled).toBe(true)
|
|
36
|
+
expect(fallback).toHaveBeenCalledTimes(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns false when no handlers are registered', async () => {
|
|
40
|
+
const router = new RequestErrorRouter()
|
|
41
|
+
|
|
42
|
+
await expect(router.handle(new Error('nope'))).resolves.toBe(false)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { BaseResponse } from '../../../src/requests/responses/BaseResponse'
|
|
3
|
+
import { JsonResponse } from '../../../src/requests/responses/JsonResponse'
|
|
4
|
+
import { PlainTextResponse } from '../../../src/requests/responses/PlainTextResponse'
|
|
5
|
+
import { BlobResponse } from '../../../src/requests/responses/BlobResponse'
|
|
6
|
+
import type { ResponseHandlerContract } from '../../../src/requests/drivers/contracts/ResponseHandlerContract'
|
|
7
|
+
|
|
8
|
+
class TestResponse extends BaseResponse<string> {
|
|
9
|
+
public getAcceptHeader(): string {
|
|
10
|
+
return 'text/plain'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
protected resolveBody(): Promise<string> {
|
|
14
|
+
return Promise.resolve('ok')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const mockResponseHandler: ResponseHandlerContract = {
|
|
19
|
+
getStatusCode: () => 200,
|
|
20
|
+
getHeaders: () => ({ 'x-test': 'yes' }),
|
|
21
|
+
getRawResponse: () => new Response('raw'),
|
|
22
|
+
json: async () => ({ ok: true }),
|
|
23
|
+
text: async () => 'plain',
|
|
24
|
+
blob: async () => new Blob(['blob'], { type: 'text/plain' }),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('Response classes', () => {
|
|
28
|
+
it('BaseResponse throws if body not set', () => {
|
|
29
|
+
const response = new TestResponse()
|
|
30
|
+
|
|
31
|
+
expect(() => response.getBody()).toThrow('Response body is not set')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('BaseResponse stores response metadata and body', async () => {
|
|
35
|
+
const response = new TestResponse()
|
|
36
|
+
|
|
37
|
+
await response.setResponse(mockResponseHandler)
|
|
38
|
+
|
|
39
|
+
expect(response.getBody()).toBe('ok')
|
|
40
|
+
expect(response.getStatusCode()).toBe(200)
|
|
41
|
+
expect(response.getHeaders()).toEqual({ 'x-test': 'yes' })
|
|
42
|
+
expect(response.getRawResponse()).toBeInstanceOf(Response)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('JsonResponse resolves JSON body', async () => {
|
|
46
|
+
const response = new JsonResponse<{ ok: boolean }>()
|
|
47
|
+
|
|
48
|
+
await response.setResponse(mockResponseHandler)
|
|
49
|
+
|
|
50
|
+
expect(response.getAcceptHeader()).toBe('application/json')
|
|
51
|
+
expect(response.getBody()).toEqual({ ok: true })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('PlainTextResponse resolves text body', async () => {
|
|
55
|
+
const response = new PlainTextResponse()
|
|
56
|
+
|
|
57
|
+
await response.setResponse(mockResponseHandler)
|
|
58
|
+
|
|
59
|
+
expect(response.getAcceptHeader()).toBe('text/plain')
|
|
60
|
+
expect(response.getBody()).toBe('plain')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('BlobResponse resolves blob body and uses mime type', async () => {
|
|
64
|
+
const response = new BlobResponse('application/pdf')
|
|
65
|
+
|
|
66
|
+
await response.setResponse(mockResponseHandler)
|
|
67
|
+
|
|
68
|
+
expect(response.getAcceptHeader()).toBe('application/pdf')
|
|
69
|
+
expect(response.getBody()).toBeInstanceOf(Blob)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('throws when resolveBody is called without a response', () => {
|
|
73
|
+
const response = new JsonResponse()
|
|
74
|
+
|
|
75
|
+
expect(() => (response as any).resolveBody()).toThrow('Response is not set')
|
|
76
|
+
|
|
77
|
+
const plain = new PlainTextResponse()
|
|
78
|
+
expect(() => (plain as any).resolveBody()).toThrow('Response is not set')
|
|
79
|
+
|
|
80
|
+
const blob = new BlobResponse()
|
|
81
|
+
expect(() => (blob as any).resolveBody()).toThrow('Response is not set')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ResponseException } from '../../../../src/requests/exceptions/ResponseException'
|
|
3
|
+
import { ResponseBodyException } from '../../../../src/requests/exceptions/ResponseBodyException'
|
|
4
|
+
import { BadRequestException } from '../../../../src/requests/exceptions/BadRequestException'
|
|
5
|
+
import { StaleResponseException } from '../../../../src/requests/exceptions/StaleResponseException'
|
|
6
|
+
|
|
7
|
+
const mockResponse = {
|
|
8
|
+
getStatusCode: () => 400,
|
|
9
|
+
getHeaders: () => ({}),
|
|
10
|
+
getRawResponse: () => new Response(),
|
|
11
|
+
json: async () => ({}),
|
|
12
|
+
text: async () => '',
|
|
13
|
+
blob: async () => new Blob(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Request exceptions', () => {
|
|
17
|
+
it('ResponseException stores response', () => {
|
|
18
|
+
const exception = new ResponseException(mockResponse as any)
|
|
19
|
+
|
|
20
|
+
expect(exception.getResponse()).toBe(mockResponse)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('ResponseBodyException stores response and body', () => {
|
|
24
|
+
const exception = new ResponseBodyException(mockResponse as any, { error: 'bad' })
|
|
25
|
+
|
|
26
|
+
expect(exception.getResponse()).toBe(mockResponse)
|
|
27
|
+
expect(exception.getBody()).toEqual({ error: 'bad' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('BadRequestException extends ResponseBodyException', () => {
|
|
31
|
+
const exception = new BadRequestException(mockResponse as any, { error: 'bad' })
|
|
32
|
+
|
|
33
|
+
expect(exception).toBeInstanceOf(ResponseBodyException)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('StaleResponseException exposes cause', () => {
|
|
37
|
+
const cause = new Error('root')
|
|
38
|
+
const exception = new StaleResponseException('stale', cause)
|
|
39
|
+
|
|
40
|
+
expect(exception.message).toBe('stale')
|
|
41
|
+
expect(exception.getCause()).toBe(cause)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { FetchDriver } from '../../../../src/requests/drivers/fetch/FetchDriver'
|
|
3
|
+
import { FetchResponse } from '../../../../src/requests/drivers/fetch/FetchResponse'
|
|
4
|
+
import { RequestMethodEnum } from '../../../../src/requests/RequestMethod.enum'
|
|
5
|
+
import { ResponseException } from '../../../../src/requests/exceptions/ResponseException'
|
|
6
|
+
import type { BodyContract } from '../../../../src/requests/contracts/BodyContract'
|
|
7
|
+
|
|
8
|
+
const createBody = (content: string): BodyContract => ({
|
|
9
|
+
getHeaders: () => ({ 'Content-Type': 'application/json' }),
|
|
10
|
+
getContent: () => content,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('FetchDriver', () => {
|
|
14
|
+
const originalFetch = global.fetch
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
global.fetch = vi.fn()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
global.fetch = originalFetch
|
|
22
|
+
vi.restoreAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('sends requests with merged headers and body', async () => {
|
|
26
|
+
const response = new Response('{"ok":true}', {
|
|
27
|
+
status: 200,
|
|
28
|
+
headers: { 'X-Response': 'yes' },
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
;(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response)
|
|
32
|
+
|
|
33
|
+
const driver = new FetchDriver({ headers: { 'X-Global': 'a' }, corsWithCredentials: true })
|
|
34
|
+
|
|
35
|
+
const result = await driver.send(
|
|
36
|
+
'https://example.com',
|
|
37
|
+
RequestMethodEnum.POST,
|
|
38
|
+
{ 'X-Req': 'b', 'X-Fn': () => 'c', 'X-Ignore': undefined },
|
|
39
|
+
createBody('{"name":"test"}')
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(result).toBeInstanceOf(FetchResponse)
|
|
43
|
+
|
|
44
|
+
const [, config] = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
45
|
+
expect(config.method).toBe('POST')
|
|
46
|
+
expect(config.credentials).toBe('include')
|
|
47
|
+
expect(config.headers).toEqual({
|
|
48
|
+
'X-Global': 'a',
|
|
49
|
+
'X-Req': 'b',
|
|
50
|
+
'X-Fn': 'c',
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
})
|
|
53
|
+
expect(config.body).toBe('{"name":"test"}')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('omits body for GET/HEAD requests', async () => {
|
|
57
|
+
const response = new Response('ok', { status: 200 })
|
|
58
|
+
;(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response)
|
|
59
|
+
|
|
60
|
+
const driver = new FetchDriver()
|
|
61
|
+
|
|
62
|
+
await driver.send('https://example.com', RequestMethodEnum.GET, {}, createBody('data'))
|
|
63
|
+
|
|
64
|
+
const [, config] = (global.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
65
|
+
expect(config.body).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('throws ResponseException when response is not ok', async () => {
|
|
69
|
+
const response = new Response('fail', { status: 500 })
|
|
70
|
+
;(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(response)
|
|
71
|
+
|
|
72
|
+
const driver = new FetchDriver()
|
|
73
|
+
|
|
74
|
+
await expect(driver.send('https://example.com', RequestMethodEnum.GET, {})).rejects.toBeInstanceOf(ResponseException)
|
|
75
|
+
})
|
|
76
|
+
})
|