apimo.js 1.0.1
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/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +69 -0
- package/.idea/apimo.js.iml +13 -0
- package/.idea/copilotDiffState.xml +43 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/jsLinters/eslint.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +6 -0
- package/.idea/vcs.xml +6 -0
- package/README.md +91 -0
- package/dist/src/consts/catalogs.d.ts +2 -0
- package/dist/src/consts/catalogs.js +53 -0
- package/dist/src/consts/languages.d.ts +2 -0
- package/dist/src/consts/languages.js +20 -0
- package/dist/src/core/api.d.ts +389 -0
- package/dist/src/core/api.js +157 -0
- package/dist/src/core/api.test.d.ts +1 -0
- package/dist/src/core/api.test.js +246 -0
- package/dist/src/core/converters.d.ts +4 -0
- package/dist/src/core/converters.js +4 -0
- package/dist/src/schemas/agency.d.ts +416 -0
- package/dist/src/schemas/agency.js +61 -0
- package/dist/src/schemas/common.d.ts +153 -0
- package/dist/src/schemas/common.js +47 -0
- package/dist/src/schemas/internal.d.ts +3 -0
- package/dist/src/schemas/internal.js +11 -0
- package/dist/src/schemas/property.d.ts +1500 -0
- package/dist/src/schemas/property.js +238 -0
- package/dist/src/services/storage/dummy.cache.d.ts +10 -0
- package/dist/src/services/storage/dummy.cache.js +28 -0
- package/dist/src/services/storage/dummy.cache.test.d.ts +1 -0
- package/dist/src/services/storage/dummy.cache.test.js +96 -0
- package/dist/src/services/storage/filesystem.cache.d.ts +18 -0
- package/dist/src/services/storage/filesystem.cache.js +85 -0
- package/dist/src/services/storage/filesystem.cache.test.d.ts +1 -0
- package/dist/src/services/storage/filesystem.cache.test.js +197 -0
- package/dist/src/services/storage/memory.cache.d.ts +20 -0
- package/dist/src/services/storage/memory.cache.js +62 -0
- package/dist/src/services/storage/memory.cache.test.d.ts +1 -0
- package/dist/src/services/storage/memory.cache.test.js +80 -0
- package/dist/src/services/storage/types.d.ts +16 -0
- package/dist/src/services/storage/types.js +4 -0
- package/dist/src/types/index.d.ts +4 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/url.d.ts +14 -0
- package/dist/src/utils/url.js +11 -0
- package/dist/src/utils/url.test.d.ts +1 -0
- package/dist/src/utils/url.test.js +18 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +6 -0
- package/eslint.config.mjs +3 -0
- package/package.json +45 -0
- package/src/consts/catalogs.ts +55 -0
- package/src/consts/languages.ts +22 -0
- package/src/core/api.test.ts +308 -0
- package/src/core/api.ts +230 -0
- package/src/core/converters.ts +7 -0
- package/src/schemas/agency.ts +66 -0
- package/src/schemas/common.ts +67 -0
- package/src/schemas/internal.ts +13 -0
- package/src/schemas/property.ts +257 -0
- package/src/services/storage/dummy.cache.test.ts +110 -0
- package/src/services/storage/dummy.cache.ts +21 -0
- package/src/services/storage/filesystem.cache.test.ts +243 -0
- package/src/services/storage/filesystem.cache.ts +94 -0
- package/src/services/storage/memory.cache.test.ts +94 -0
- package/src/services/storage/memory.cache.ts +69 -0
- package/src/services/storage/types.ts +20 -0
- package/src/types/index.ts +5 -0
- package/src/utils/url.test.ts +21 -0
- package/src/utils/url.ts +27 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { LocalizedCatalogTransformer } from './common'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { Converters } from '../core/converters'
|
|
4
|
+
import { CitySchema, getUserSchema, NameIdPairSchema } from './common'
|
|
5
|
+
import { TYPE_UNDOCUMENTED, TYPE_UNDOCUMENTED_NULLABLE } from './internal'
|
|
6
|
+
|
|
7
|
+
export function getAgreementSchema(transformer: LocalizedCatalogTransformer) {
|
|
8
|
+
return z.object({
|
|
9
|
+
type: z.coerce.number().transform(v => transformer('property_agreement', v)),
|
|
10
|
+
reference: z.string(),
|
|
11
|
+
start_at: z.coerce.string().transform(Converters.toDate),
|
|
12
|
+
end_at: z.coerce.string().transform(Converters.toDate),
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getSurfaceSchema(transformer: LocalizedCatalogTransformer) {
|
|
17
|
+
return z.object({
|
|
18
|
+
unit: z.coerce.number().transform(v => transformer('unit_area', v)),
|
|
19
|
+
value: z.coerce.number(),
|
|
20
|
+
total: z.coerce.number(),
|
|
21
|
+
weighted: z.coerce.number(),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getPlotSchema(transformer: LocalizedCatalogTransformer) {
|
|
26
|
+
return z.object({
|
|
27
|
+
net_floor: z.coerce.number(),
|
|
28
|
+
land_type: z.coerce.number().transform(v => transformer('property_land', v)),
|
|
29
|
+
width: z.coerce.number(),
|
|
30
|
+
height: z.coerce.number().optional(),
|
|
31
|
+
serviced_plot: z.boolean(),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getPriceSchema(transformer: LocalizedCatalogTransformer) {
|
|
36
|
+
return z.object({
|
|
37
|
+
value: z.coerce.number(),
|
|
38
|
+
max: z.coerce.number(),
|
|
39
|
+
fees: z.coerce.number(),
|
|
40
|
+
unit: TYPE_UNDOCUMENTED_NULLABLE,
|
|
41
|
+
period: z.coerce.number().transform(v => transformer('property_period', v)),
|
|
42
|
+
hide: z.coerce.boolean(),
|
|
43
|
+
inventory: z.number().nullable(),
|
|
44
|
+
deposit: z.number().nullable(),
|
|
45
|
+
currency: z.string().toLowerCase(),
|
|
46
|
+
commission: z.number().nullable(),
|
|
47
|
+
transfer_tax: TYPE_UNDOCUMENTED_NULLABLE,
|
|
48
|
+
contribution: TYPE_UNDOCUMENTED_NULLABLE,
|
|
49
|
+
pension: TYPE_UNDOCUMENTED_NULLABLE,
|
|
50
|
+
tenant: z.number().nullable(),
|
|
51
|
+
vat: z.boolean().nullable(),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getResidenceSchema(transformer: LocalizedCatalogTransformer) {
|
|
56
|
+
return z.object({
|
|
57
|
+
id: z.coerce.number(),
|
|
58
|
+
type: z.coerce.number().transform(v => transformer('property_building', v)),
|
|
59
|
+
fees: z.coerce.number(),
|
|
60
|
+
period: z.coerce.number().transform(v => transformer('property_period', v)),
|
|
61
|
+
lots: z.coerce.number(),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getViewSchema(transformer: LocalizedCatalogTransformer) {
|
|
66
|
+
return z.object({
|
|
67
|
+
type: z.coerce.number().transform(v => transformer('property_view_type', v)),
|
|
68
|
+
landscape: z.coerce.number().transform(v => transformer('property_view_landscape', v)).array(),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getConstructionSchema(transformer: LocalizedCatalogTransformer) {
|
|
73
|
+
return z.object({
|
|
74
|
+
type: z.coerce.number().transform(v => transformer('property_construction_method', v)).array().optional(),
|
|
75
|
+
construction_year: z.coerce.number(),
|
|
76
|
+
renovation_year: z.coerce.number(),
|
|
77
|
+
renovation_cost: z.coerce.number(),
|
|
78
|
+
construction_step: z.coerce.number().transform(v => transformer('construction_step', v)),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getFloorSchema(transformer: LocalizedCatalogTransformer) {
|
|
83
|
+
return z.object({
|
|
84
|
+
type: z.coerce.number().transform(v => transformer('property_floor', v)),
|
|
85
|
+
value: z.coerce.number(),
|
|
86
|
+
levels: z.coerce.number(),
|
|
87
|
+
floors: z.coerce.number(),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getHeatingSchema(transformer: LocalizedCatalogTransformer) {
|
|
92
|
+
return z.object({
|
|
93
|
+
|
|
94
|
+
device: z.coerce.number().transform(v => transformer('property_heating_device', v)),
|
|
95
|
+
devices: z.coerce.number().transform(v => transformer('property_heating_device', v)).array().nullable(),
|
|
96
|
+
access: z.coerce.number().transform(v => transformer('property_heating_access', v)),
|
|
97
|
+
type: z.coerce.number().transform(v => transformer('property_heating_type', v)),
|
|
98
|
+
types: z.coerce.number().transform(v => transformer('property_heating_type', v)).array().nullable(),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getWaterSchema(transformer: LocalizedCatalogTransformer) {
|
|
103
|
+
return z.object({
|
|
104
|
+
hot_device: z.coerce.number().transform(v => transformer('property_hot_water_device', v)),
|
|
105
|
+
hot_access: z.coerce.number().transform(v => transformer('property_hot_water_access', v)),
|
|
106
|
+
waste: z.coerce.number().transform(v => transformer('property_waste_water', v)),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const CommentSchema = z.object({
|
|
111
|
+
language: z.string(),
|
|
112
|
+
title: z.string().optional().nullable(),
|
|
113
|
+
subtitle: z.string().optional().nullable(),
|
|
114
|
+
hook: TYPE_UNDOCUMENTED_NULLABLE.optional(),
|
|
115
|
+
comment: z.string(),
|
|
116
|
+
comment_full: z.string().optional().nullable(),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export const PictureSchema = z.object({
|
|
120
|
+
id: z.coerce.number(),
|
|
121
|
+
rank: z.coerce.number(),
|
|
122
|
+
url: z.string(),
|
|
123
|
+
width_max: z.coerce.number(),
|
|
124
|
+
height_max: z.coerce.number(),
|
|
125
|
+
internet: z.coerce.boolean(),
|
|
126
|
+
print: z.coerce.boolean(),
|
|
127
|
+
panorama: z.coerce.boolean(),
|
|
128
|
+
child: z.coerce.number(),
|
|
129
|
+
reference: TYPE_UNDOCUMENTED_NULLABLE,
|
|
130
|
+
comments: CommentSchema.array(),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
export function getAreaSchema(transformer: LocalizedCatalogTransformer) {
|
|
134
|
+
return z.object({
|
|
135
|
+
type: z.coerce.number().transform(v => transformer('property_areas', v)),
|
|
136
|
+
number: z.coerce.number(),
|
|
137
|
+
area: z.coerce.number(),
|
|
138
|
+
flooring: z.coerce.number().transform(v => transformer('property_flooring', v)),
|
|
139
|
+
ceiling_height: z.number().nullable(),
|
|
140
|
+
floor: z.object({
|
|
141
|
+
type: z.coerce.number().transform(v => transformer('property_floor', v)),
|
|
142
|
+
value: z.coerce.number(),
|
|
143
|
+
}),
|
|
144
|
+
orientations: z.coerce.number().transform(v => transformer('property_orientation', v)).array(),
|
|
145
|
+
comments: CommentSchema.array(),
|
|
146
|
+
lot: z.object({
|
|
147
|
+
type: TYPE_UNDOCUMENTED_NULLABLE,
|
|
148
|
+
rank: TYPE_UNDOCUMENTED_NULLABLE,
|
|
149
|
+
name: TYPE_UNDOCUMENTED.array(),
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function getRegulationSchema(transformer: LocalizedCatalogTransformer) {
|
|
155
|
+
return z.object({
|
|
156
|
+
type: z.coerce.number().transform(v => transformer('property_regulation', v)),
|
|
157
|
+
value: z.coerce.string().transform((v) => {
|
|
158
|
+
const values = v.split(',')
|
|
159
|
+
return values.map(value => Number.parseInt(value))
|
|
160
|
+
}),
|
|
161
|
+
date: z.string().transform(Converters.toDate).nullable(),
|
|
162
|
+
graph: z.string().nullable(),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function getPropertySchema(transformer: LocalizedCatalogTransformer) {
|
|
167
|
+
return z.object(
|
|
168
|
+
{
|
|
169
|
+
id: z.coerce.number(),
|
|
170
|
+
reference: z.number(),
|
|
171
|
+
agency: z.coerce.number(),
|
|
172
|
+
brand: TYPE_UNDOCUMENTED_NULLABLE,
|
|
173
|
+
sector: TYPE_UNDOCUMENTED_NULLABLE,
|
|
174
|
+
user: getUserSchema(transformer),
|
|
175
|
+
step: z.number().transform(v => transformer('property_step', v)),
|
|
176
|
+
status: z.number().transform(v => transformer('property_status', v)),
|
|
177
|
+
parent: z.number().nullable(),
|
|
178
|
+
ranking: TYPE_UNDOCUMENTED_NULLABLE,
|
|
179
|
+
category: z.coerce.number().transform(v => transformer('property_category', v)),
|
|
180
|
+
name: z.string().nullable(),
|
|
181
|
+
type: z.coerce.number().transform(v => transformer('property_type', v)),
|
|
182
|
+
subtype: z.coerce.number().transform(v => transformer('property_subtype', v)),
|
|
183
|
+
agreement: getAgreementSchema(transformer).nullable(),
|
|
184
|
+
block_name: z.string().nullable(),
|
|
185
|
+
lot_reference: z.string().nullable(),
|
|
186
|
+
cadastre_reference: z.string().nullable(),
|
|
187
|
+
stairs_reference: z.string().nullable(),
|
|
188
|
+
address: z.string().nullable(),
|
|
189
|
+
address_more: z.string().nullable(),
|
|
190
|
+
publish_address: z.coerce.boolean(),
|
|
191
|
+
country: z.string().toLowerCase(),
|
|
192
|
+
region: NameIdPairSchema,
|
|
193
|
+
city: CitySchema,
|
|
194
|
+
original_city: TYPE_UNDOCUMENTED_NULLABLE,
|
|
195
|
+
district: NameIdPairSchema.nullable(),
|
|
196
|
+
original_district: TYPE_UNDOCUMENTED_NULLABLE,
|
|
197
|
+
location: TYPE_UNDOCUMENTED_NULLABLE,
|
|
198
|
+
longitude: z.coerce.number(),
|
|
199
|
+
latitude: z.coerce.number(),
|
|
200
|
+
radius: z.coerce.number(),
|
|
201
|
+
altitude: z.coerce.number(),
|
|
202
|
+
referral: TYPE_UNDOCUMENTED_NULLABLE,
|
|
203
|
+
subreferral: TYPE_UNDOCUMENTED_NULLABLE,
|
|
204
|
+
area: getSurfaceSchema(transformer),
|
|
205
|
+
plot: getPlotSchema(transformer),
|
|
206
|
+
rooms: z.coerce.number(),
|
|
207
|
+
bedrooms: z.coerce.number(),
|
|
208
|
+
sleeps: z.coerce.number(),
|
|
209
|
+
price: getPriceSchema(transformer),
|
|
210
|
+
rates: z.unknown().array(),
|
|
211
|
+
owner: TYPE_UNDOCUMENTED_NULLABLE,
|
|
212
|
+
visit: TYPE_UNDOCUMENTED_NULLABLE,
|
|
213
|
+
residence: getResidenceSchema(transformer).nullable(),
|
|
214
|
+
view: getViewSchema(transformer).nullable(),
|
|
215
|
+
construction: getConstructionSchema(transformer),
|
|
216
|
+
floor: getFloorSchema(transformer),
|
|
217
|
+
heating: getHeatingSchema(transformer),
|
|
218
|
+
water: getWaterSchema(transformer),
|
|
219
|
+
condition: z.coerce.number().transform(v => transformer('property_condition', v)),
|
|
220
|
+
standing: z.coerce.number().transform(v => transformer('property_standing', v)),
|
|
221
|
+
style: z.object({ name: z.string().nullable() }),
|
|
222
|
+
twinned: z.coerce.number().nullable(),
|
|
223
|
+
facades: z.coerce.number(),
|
|
224
|
+
length: z.coerce.number().nullable(),
|
|
225
|
+
height: z.coerce.number().nullable(),
|
|
226
|
+
url: z.string().nullable(),
|
|
227
|
+
availability: z.coerce.number().transform(v => transformer('property_availability', v)),
|
|
228
|
+
available_at: TYPE_UNDOCUMENTED_NULLABLE,
|
|
229
|
+
delivered_at: z.string().transform(Converters.toDate).nullable(),
|
|
230
|
+
activities: z.coerce.number().transform(v => transformer('property_activity', v)).array(),
|
|
231
|
+
orientations: z.coerce.number().transform(v => transformer('property_orientation', v)).array(),
|
|
232
|
+
services: z.coerce.number().transform(v => transformer('property_service', v)).array(),
|
|
233
|
+
proximities: z.coerce.number().transform(v => transformer('property_proximity', v)).array(),
|
|
234
|
+
tags: z.coerce.number().transform(v => transformer('tags', v)).array(),
|
|
235
|
+
tags_customized: z.unknown().array(),
|
|
236
|
+
pictures: PictureSchema.array(),
|
|
237
|
+
medias: z.unknown().array(),
|
|
238
|
+
documents: z.unknown().array(),
|
|
239
|
+
comments: CommentSchema.array(),
|
|
240
|
+
areas: getAreaSchema(transformer).array(),
|
|
241
|
+
regulations: getRegulationSchema(transformer).array(),
|
|
242
|
+
financial: z.unknown().array(),
|
|
243
|
+
exchanges: z.unknown().array(),
|
|
244
|
+
options: z.unknown().array(),
|
|
245
|
+
filling_rate: TYPE_UNDOCUMENTED_NULLABLE,
|
|
246
|
+
private_comment: TYPE_UNDOCUMENTED_NULLABLE,
|
|
247
|
+
interagency_comment: TYPE_UNDOCUMENTED_NULLABLE,
|
|
248
|
+
status_comment: TYPE_UNDOCUMENTED_NULLABLE,
|
|
249
|
+
logs: z.unknown().array(),
|
|
250
|
+
referrals: z.unknown().array(),
|
|
251
|
+
created_at: z.string().transform(Converters.toDate),
|
|
252
|
+
updated_at: z.string().transform(Converters.toDate),
|
|
253
|
+
created_by: z.coerce.number(),
|
|
254
|
+
updated_by: z.coerce.number(),
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { DummyCache } from './dummy.cache'
|
|
5
|
+
import { CacheExpiredError } from './types'
|
|
6
|
+
|
|
7
|
+
describe('cache - Dummy', () => {
|
|
8
|
+
let cache: DummyCache
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
cache = new DummyCache()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
// No cleanup needed for dummy cache
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('constructor', () => {
|
|
19
|
+
it('should create an instance without any configuration', () => {
|
|
20
|
+
const dummyCache = new DummyCache()
|
|
21
|
+
expect(dummyCache).toBeInstanceOf(DummyCache)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('setEntries', () => {
|
|
26
|
+
const culture: ApiCulture = 'en'
|
|
27
|
+
const entries = [
|
|
28
|
+
{ id: 1, name: 'Item 1', name_plurial: 'Items 1' },
|
|
29
|
+
{ id: 2, name: 'Item 2', name_plurial: 'Items 2' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
it('should not throw when setting entries', async () => {
|
|
33
|
+
const catalogName: CatalogName = 'book_step'
|
|
34
|
+
await expect(cache.setEntries(catalogName, culture, entries)).resolves.toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should handle empty entries array', async () => {
|
|
38
|
+
const catalogName: CatalogName = 'book_step'
|
|
39
|
+
await expect(cache.setEntries(catalogName, culture, [])).resolves.toBeUndefined()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should handle different catalog and culture combinations', async () => {
|
|
43
|
+
await expect(cache.setEntries('book_step', 'en', entries)).resolves.toBeUndefined()
|
|
44
|
+
await expect(cache.setEntries('property_land', 'fr', entries)).resolves.toBeUndefined()
|
|
45
|
+
await expect(cache.setEntries('property_type', 'de', entries)).resolves.toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('getEntry', () => {
|
|
50
|
+
const culture: ApiCulture = 'en'
|
|
51
|
+
|
|
52
|
+
it('should always throw CacheExpiredError regardless of parameters', async () => {
|
|
53
|
+
const catalogName: CatalogName = 'book_step'
|
|
54
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should throw CacheExpiredError for any ID', async () => {
|
|
58
|
+
const catalogName: CatalogName = 'book_step'
|
|
59
|
+
await expect(cache.getEntry(catalogName, culture, 999)).rejects.toThrow(CacheExpiredError)
|
|
60
|
+
await expect(cache.getEntry(catalogName, culture, 0)).rejects.toThrow(CacheExpiredError)
|
|
61
|
+
await expect(cache.getEntry(catalogName, culture, -1)).rejects.toThrow(CacheExpiredError)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should throw CacheExpiredError for different catalogs and cultures', async () => {
|
|
65
|
+
await expect(cache.getEntry('book_step', 'en', 1)).rejects.toThrow(CacheExpiredError)
|
|
66
|
+
await expect(cache.getEntry('property_land', 'fr', 1)).rejects.toThrow(CacheExpiredError)
|
|
67
|
+
await expect(cache.getEntry('property_type', 'de', 1)).rejects.toThrow(CacheExpiredError)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should throw CacheExpiredError even after setting entries', async () => {
|
|
71
|
+
const catalogName: CatalogName = 'book_step'
|
|
72
|
+
const entries = [{ id: 1, name: 'Item 1', name_plurial: 'Items 1' }]
|
|
73
|
+
|
|
74
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
75
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('behavior consistency', () => {
|
|
80
|
+
it('should behave consistently across multiple calls', async () => {
|
|
81
|
+
const catalogName: CatalogName = 'book_step'
|
|
82
|
+
const culture: ApiCulture = 'en'
|
|
83
|
+
const entries = [{ id: 1, name: 'Item 1', name_plurial: 'Items 1' }]
|
|
84
|
+
|
|
85
|
+
// Multiple setEntries calls should not throw
|
|
86
|
+
await expect(cache.setEntries(catalogName, culture, entries)).resolves.toBeUndefined()
|
|
87
|
+
await expect(cache.setEntries(catalogName, culture, entries)).resolves.toBeUndefined()
|
|
88
|
+
|
|
89
|
+
// Multiple getEntry calls should always throw
|
|
90
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
91
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should maintain dummy behavior regardless of cache state', async () => {
|
|
95
|
+
const catalogName: CatalogName = 'book_step'
|
|
96
|
+
const culture: ApiCulture = 'en'
|
|
97
|
+
|
|
98
|
+
// Should throw before any operations
|
|
99
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
100
|
+
|
|
101
|
+
// Should still throw after setting entries
|
|
102
|
+
await cache.setEntries(catalogName, culture, [{ id: 1, name: 'Test', name_plurial: 'Tests' }])
|
|
103
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
104
|
+
|
|
105
|
+
// Should still throw after multiple operations
|
|
106
|
+
await cache.setEntries(catalogName, culture, [])
|
|
107
|
+
await expect(cache.getEntry(catalogName, culture, 999)).rejects.toThrow(CacheExpiredError)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import type { CatalogEntry } from '../../schemas/common'
|
|
4
|
+
import type { ApiCacheAdapter, CatalogEntryName } from './types'
|
|
5
|
+
import { CacheExpiredError } from './types'
|
|
6
|
+
|
|
7
|
+
export class DummyCache implements ApiCacheAdapter {
|
|
8
|
+
constructor() {
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async getEntries(_catalogName: CatalogName, _culture: ApiCulture): Promise<CatalogEntry[]> {
|
|
12
|
+
throw new CacheExpiredError()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async setEntries(_catalogName: CatalogName, _culture: ApiCulture, _entries: CatalogEntry[]): Promise<void> {
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getEntry(_catalogName: CatalogName, _culture: ApiCulture, _id: number): Promise<CatalogEntryName | null> {
|
|
19
|
+
throw new CacheExpiredError()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { CatalogName } from '../../consts/catalogs'
|
|
2
|
+
import type { ApiCulture } from '../../consts/languages'
|
|
3
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
4
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
|
+
import * as os from 'node:os'
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
8
|
+
import { FilesystemCache } from './filesystem.cache'
|
|
9
|
+
import { CacheExpiredError } from './types'
|
|
10
|
+
|
|
11
|
+
describe('cache - Filesystem', () => {
|
|
12
|
+
let tempDir: string
|
|
13
|
+
let cache: FilesystemCache
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Create a unique temporary directory for each test
|
|
17
|
+
tempDir = path.join(os.tmpdir(), `filesystem-cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
18
|
+
mkdirSync(tempDir, { recursive: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
// Clean up the temporary directory after each test
|
|
23
|
+
if (existsSync(tempDir)) {
|
|
24
|
+
rmSync(tempDir, { recursive: true, force: true })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('constructor', () => {
|
|
29
|
+
it('should use default cache location and expiration when no settings provided', () => {
|
|
30
|
+
// eslint-disable-next-line no-new
|
|
31
|
+
new FilesystemCache()
|
|
32
|
+
expect(existsSync('./cache/catalogs')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should create custom cache directory when provided', () => {
|
|
36
|
+
const customPath = path.join(tempDir, 'custom-cache')
|
|
37
|
+
cache = new FilesystemCache({ path: customPath })
|
|
38
|
+
expect(existsSync(customPath)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should use custom expiration time when provided', () => {
|
|
42
|
+
const customExpiration = 60000
|
|
43
|
+
cache = new FilesystemCache({
|
|
44
|
+
path: tempDir,
|
|
45
|
+
cacheExpirationMs: customExpiration,
|
|
46
|
+
})
|
|
47
|
+
// The expiration time is private, but we can test its effect
|
|
48
|
+
expect(cache).toBeInstanceOf(FilesystemCache)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should create nested directories recursively', () => {
|
|
52
|
+
const nestedPath = path.join(tempDir, 'level1', 'level2', 'cache')
|
|
53
|
+
cache = new FilesystemCache({ path: nestedPath })
|
|
54
|
+
expect(existsSync(nestedPath)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('setEntries and getEntry', () => {
|
|
59
|
+
const culture: ApiCulture = 'en'
|
|
60
|
+
const entries = [
|
|
61
|
+
{ id: 1, name: 'Item 1', name_plurial: 'Items 1' },
|
|
62
|
+
{ id: 2, name: 'Item 2', name_plurial: 'Items 2' },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
cache = new FilesystemCache({ path: tempDir })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should store and retrieve entries correctly', async () => {
|
|
70
|
+
const catalogName: CatalogName = 'book_step'
|
|
71
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
72
|
+
|
|
73
|
+
const entry1 = await cache.getEntry(catalogName, culture, 1)
|
|
74
|
+
const entry2 = await cache.getEntry(catalogName, culture, 2)
|
|
75
|
+
|
|
76
|
+
expect(entry1).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
77
|
+
expect(entry2).toEqual({ name: 'Item 2', namePlural: 'Items 2' })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should create cache file with correct format', async () => {
|
|
81
|
+
const catalogName: CatalogName = 'book_step'
|
|
82
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
83
|
+
|
|
84
|
+
const filePath = path.join(tempDir, `${catalogName}-${culture}.json`)
|
|
85
|
+
expect(existsSync(filePath)).toBe(true)
|
|
86
|
+
|
|
87
|
+
const fileContent = await readFile(filePath, 'utf-8')
|
|
88
|
+
const parsed = JSON.parse(fileContent)
|
|
89
|
+
|
|
90
|
+
expect(parsed).toHaveProperty('timestamp')
|
|
91
|
+
expect(parsed).toHaveProperty('cache')
|
|
92
|
+
expect(parsed.cache).toEqual({
|
|
93
|
+
1: { name: 'Item 1', namePlural: 'Items 1' },
|
|
94
|
+
2: { name: 'Item 2', namePlural: 'Items 2' },
|
|
95
|
+
})
|
|
96
|
+
expect(typeof parsed.timestamp).toBe('number')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should return null for non-existent entry', async () => {
|
|
100
|
+
const catalogName: CatalogName = 'book_step'
|
|
101
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
102
|
+
|
|
103
|
+
const entry = await cache.getEntry(catalogName, culture, 999)
|
|
104
|
+
expect(entry).toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should throw CacheExpiredError when cache file does not exist', async () => {
|
|
108
|
+
const catalogName: CatalogName = 'book_step'
|
|
109
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should throw CacheExpiredError when cache has expired', async () => {
|
|
113
|
+
const catalogName: CatalogName = 'book_step'
|
|
114
|
+
const expiredCache = new FilesystemCache({
|
|
115
|
+
path: tempDir,
|
|
116
|
+
cacheExpirationMs: 1,
|
|
117
|
+
})
|
|
118
|
+
await expiredCache.setEntries(catalogName, culture, entries)
|
|
119
|
+
|
|
120
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
121
|
+
|
|
122
|
+
await expect(expiredCache.getEntry(catalogName, culture, 1)).rejects.toThrow(CacheExpiredError)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should handle different catalog and culture combinations', async () => {
|
|
126
|
+
await cache.setEntries('book_step', 'en', entries)
|
|
127
|
+
await cache.setEntries('property_land', 'fr', entries)
|
|
128
|
+
|
|
129
|
+
const entry1 = await cache.getEntry('book_step', 'en', 1)
|
|
130
|
+
const entry2 = await cache.getEntry('property_land', 'fr', 1)
|
|
131
|
+
|
|
132
|
+
expect(entry1).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
133
|
+
expect(entry2).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
134
|
+
|
|
135
|
+
await expect(cache.getEntry('book_step', 'fr', 1)).rejects.toThrow(CacheExpiredError)
|
|
136
|
+
|
|
137
|
+
// Verify separate files were created
|
|
138
|
+
expect(existsSync(path.join(tempDir, 'book_step-en.json'))).toBe(true)
|
|
139
|
+
expect(existsSync(path.join(tempDir, 'property_land-fr.json'))).toBe(true)
|
|
140
|
+
expect(existsSync(path.join(tempDir, 'book_step-fr.json'))).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should overwrite existing cache when setting new entries', async () => {
|
|
144
|
+
const catalogName: CatalogName = 'property_land'
|
|
145
|
+
|
|
146
|
+
await cache.setEntries(catalogName, culture, entries)
|
|
147
|
+
|
|
148
|
+
const newEntries = [{ id: 3, name: 'New Item', name_plurial: 'New Items' }]
|
|
149
|
+
await cache.setEntries(catalogName, culture, newEntries)
|
|
150
|
+
|
|
151
|
+
const newEntry = await cache.getEntry(catalogName, culture, 3)
|
|
152
|
+
expect(newEntry).toEqual({ name: 'New Item', namePlural: 'New Items' })
|
|
153
|
+
|
|
154
|
+
const oldEntry = await cache.getEntry(catalogName, culture, 1)
|
|
155
|
+
expect(oldEntry).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should handle entries with undefined name_plurial', async () => {
|
|
159
|
+
const catalogName: CatalogName = 'book_step'
|
|
160
|
+
const entriesWithUndefined = [
|
|
161
|
+
{ id: 1, name: 'Item 1', name_plurial: undefined },
|
|
162
|
+
{ id: 2, name: 'Item 2', name_plurial: 'Items 2' },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
await cache.setEntries(catalogName, culture, entriesWithUndefined)
|
|
166
|
+
|
|
167
|
+
const entry1 = await cache.getEntry(catalogName, culture, 1)
|
|
168
|
+
const entry2 = await cache.getEntry(catalogName, culture, 2)
|
|
169
|
+
|
|
170
|
+
expect(entry1).toEqual({ name: 'Item 1', namePlural: undefined })
|
|
171
|
+
expect(entry2).toEqual({ name: 'Item 2', namePlural: 'Items 2' })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should handle large entry IDs correctly', async () => {
|
|
175
|
+
const catalogName: CatalogName = 'book_step'
|
|
176
|
+
const largeIdEntries = [
|
|
177
|
+
{ id: 999999999, name: 'Large ID Item', name_plurial: 'Large ID Items' },
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
await cache.setEntries(catalogName, culture, largeIdEntries)
|
|
181
|
+
|
|
182
|
+
const entry = await cache.getEntry(catalogName, culture, 999999999)
|
|
183
|
+
expect(entry).toEqual({ name: 'Large ID Item', namePlural: 'Large ID Items' })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('should persist cache across multiple FilesystemCache instances', async () => {
|
|
187
|
+
const catalogName: CatalogName = 'book_step'
|
|
188
|
+
|
|
189
|
+
// Create cache with first instance
|
|
190
|
+
const cache1 = new FilesystemCache({ path: tempDir })
|
|
191
|
+
await cache1.setEntries(catalogName, culture, entries)
|
|
192
|
+
|
|
193
|
+
// Access cache with second instance
|
|
194
|
+
const cache2 = new FilesystemCache({ path: tempDir })
|
|
195
|
+
const entry = await cache2.getEntry(catalogName, culture, 1)
|
|
196
|
+
|
|
197
|
+
expect(entry).toEqual({ name: 'Item 1', namePlural: 'Items 1' })
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('error handling', () => {
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
cache = new FilesystemCache({ path: tempDir })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should handle malformed JSON files gracefully', async () => {
|
|
207
|
+
const catalogName: CatalogName = 'book_step'
|
|
208
|
+
const culture: ApiCulture = 'en'
|
|
209
|
+
const filePath = path.join(tempDir, `${catalogName}-${culture}.json`)
|
|
210
|
+
|
|
211
|
+
// Write malformed JSON
|
|
212
|
+
await writeFile(filePath, 'invalid json content')
|
|
213
|
+
|
|
214
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should handle files with missing timestamp', async () => {
|
|
218
|
+
const catalogName: CatalogName = 'book_step'
|
|
219
|
+
const culture: ApiCulture = 'en'
|
|
220
|
+
const filePath = path.join(tempDir, `${catalogName}-${culture}.json`)
|
|
221
|
+
|
|
222
|
+
// Write JSON without timestamp
|
|
223
|
+
await writeFile(filePath, JSON.stringify({
|
|
224
|
+
cache: { 1: { name: 'Test', namePlural: 'Tests' } },
|
|
225
|
+
}))
|
|
226
|
+
|
|
227
|
+
await expect(cache.getEntry(catalogName, culture, 1)).resolves.toStrictEqual({ name: 'Test', namePlural: 'Tests' })
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should handle files with missing cache property', async () => {
|
|
231
|
+
const catalogName: CatalogName = 'book_step'
|
|
232
|
+
const culture: ApiCulture = 'en'
|
|
233
|
+
const filePath = path.join(tempDir, `${catalogName}-${culture}.json`)
|
|
234
|
+
|
|
235
|
+
// Write JSON without cache property
|
|
236
|
+
await writeFile(filePath, JSON.stringify({
|
|
237
|
+
timestamp: Date.now(),
|
|
238
|
+
}))
|
|
239
|
+
|
|
240
|
+
await expect(cache.getEntry(catalogName, culture, 1)).rejects.toThrow()
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|