@vulog/aima-unavailability 1.2.7

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/.eslintrc.js ADDED
@@ -0,0 +1,112 @@
1
+ module.exports = {
2
+ parser: '@typescript-eslint/parser',
3
+ parserOptions: {
4
+ // Indicates the location of the TypeScript configuration file
5
+ project: 'tsconfig.json',
6
+ // Sets the root directory for the TypeScript configuration
7
+ tsconfigRootDir: __dirname,
8
+ // Specifies the version of ECMAScript syntax to be used
9
+ ecmaVersion: 'latest',
10
+ // Indicates the type of source code (script or module)
11
+ sourceType: 'module',
12
+ },
13
+ plugins: ['@typescript-eslint', 'prettier', 'import'],
14
+ extends: [
15
+ 'airbnb-base',
16
+ 'airbnb-typescript/base',
17
+ 'plugin:@typescript-eslint/recommended',
18
+ 'prettier',
19
+ 'plugin:prettier/recommended',
20
+ ],
21
+ root: true,
22
+ env: {
23
+ es6: true,
24
+ node: true,
25
+ },
26
+ ignorePatterns: [
27
+ '/dist/**/*', // Ignore built files.
28
+ '.eslintrc.js',
29
+ '**/*.test.ts',
30
+ ],
31
+ rules: {
32
+ // Configures the Prettier integration with ESLint
33
+ 'prettier/prettier': ['error', { endOfLine: 'auto' }],
34
+ // Disables the rule requiring an 'I' prefix for interfaces
35
+ '@typescript-eslint/interface-name-prefix': 'off',
36
+ // Configures the naming conventions for various code constructs
37
+ '@typescript-eslint/naming-convention': [
38
+ // ... various naming convention configurations ...
39
+ 'error',
40
+ // Allows any naming format for destructured variables
41
+ {
42
+ selector: 'variable',
43
+ modifiers: ['destructured'],
44
+ format: null,
45
+ },
46
+ // Requires strict camelCase for function names
47
+ {
48
+ selector: 'function',
49
+ format: ['strictCamelCase'],
50
+ },
51
+ // Requires boolean variables to have one of the specified prefixes
52
+ {
53
+ selector: 'variable',
54
+ format: null,
55
+ types: ['boolean'],
56
+ prefix: ['is', 'should', 'has', 'can', 'did', 'will'],
57
+ },
58
+ // Requires enum names to have a strict PascalCase format
59
+ {
60
+ selector: 'enum',
61
+ format: ['StrictPascalCase'],
62
+ },
63
+ ],
64
+ 'import/extensions': 'off',
65
+ // Disables the rule requiring explicit return types for functions
66
+ '@typescript-eslint/explicit-function-return-type': 'off',
67
+ // Disables the rule requiring explicit boundary types for modules
68
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
69
+ // Disables the rule prohibiting the use of 'any' type
70
+ '@typescript-eslint/no-explicit-any': 'off',
71
+ // Disables the rule detecting unused variables
72
+ 'no-unused-vars': 0,
73
+ // Disables the rule disallowing named exports used as a default export
74
+ 'import/no-named-as-default': 0,
75
+ // Configures the order and formatting of import statements
76
+ 'import/order': [
77
+ // ... import order configuration ...
78
+ 'error',
79
+ {
80
+ // Configure the alphabetization settings
81
+ alphabetize: {
82
+ // Enforce ascending alphabetical order
83
+ order: 'asc',
84
+ // Do not ignore the case while sorting
85
+ caseInsensitive: false,
86
+ },
87
+ // Enforce newlines between different groups and inside groups of imports
88
+ 'newlines-between': 'always-and-inside-groups',
89
+ // Warn when there is an import statement that is not part of any group
90
+ warnOnUnassignedImports: true,
91
+ },
92
+ ],
93
+ // Configures the rule detecting extraneous dependencies
94
+ 'import/no-extraneous-dependencies': [
95
+ // ... extraneous dependencies configuration ...
96
+ 'error',
97
+ {
98
+ // Specify the file patterns where devDependencies imports are allowed
99
+ devDependencies: [
100
+ // Allow devDependencies imports in test and spec files
101
+ '**/*.test.{ts,js}',
102
+ '**/*.spec.{ts,js}',
103
+ // Allow devDependencies imports in the 'test' folder
104
+ './test/**.{ts,js}',
105
+ // Allow devDependencies imports in the 'scripts' folder
106
+ './scripts/**/*.{ts,js}',
107
+ ],
108
+ },
109
+ ],
110
+ 'import/prefer-default-export': 'off',
111
+ },
112
+ };
package/API_SUMMARY.md ADDED
@@ -0,0 +1,36 @@
1
+ # Vulog Unavailability API Calls - Summary
2
+
3
+ ## Implemented API Endpoints
4
+
5
+ ### 1. GET `/boapi/proxy/user/fleets/{fleetId}/scheduledRules/unavailability/vehicles/{vehicleId}`
6
+ Lists all unavailability rules for a specific vehicle. Returns array of `Unavailability` objects. Returns empty array on 404.
7
+
8
+ ### 2. GET `/boapi/proxy/user/fleets/{fleetId}/scheduledRules/unavailability/vehicles`
9
+ Lists unavailability rules with date range filtering and pagination. Query parameters: `from` (ISO date), `to` (ISO date), `page` (number). Automatically iterates through pages until no more results.
10
+
11
+ ### 3. POST `/boapi/proxy/user/fleets/{fleetId}/scheduledRules/unavailability`
12
+ Creates a new unavailability rule. Request body: `{ cronExpression, duration, maintenanceTitle, vehicleId }`. Returns created `Unavailability` object.
13
+
14
+ ### 4. PUT `/boapi/proxy/user/fleets/{fleetId}/scheduledRules/unavailability/{id}`
15
+ Updates an existing unavailability rule. Request body same as POST. Returns updated `Unavailability` object.
16
+
17
+ ### 5. DELETE `/boapi/proxy/user/fleets/{fleetId}/scheduledRules/unavailability/{id}`
18
+ Deletes an unavailability rule by ID. Idempotent - returns successfully if already deleted (404).
19
+
20
+ ## Utility Function
21
+
22
+ ### `generateCronExpression(date: Date | string, timezone?: string): string`
23
+ Generates Quartz cron expression from a date. Supports timezone conversion. Format: `second minute hour day month ? year`.
24
+
25
+ ## Type Definitions
26
+
27
+ ```typescript
28
+ type Unavailability = {
29
+ id: string;
30
+ cronExpression: string;
31
+ duration: number;
32
+ maintenanceTitle: string;
33
+ vehicleId: string;
34
+ };
35
+ ```
36
+
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@vulog/aima-unavailability",
3
+ "version": "1.2.7",
4
+ "main": "dist/index.js",
5
+ "module": "dist/index.mjs",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsup",
9
+ "dev": "tsup --watch",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "lint": "eslint src/**/* --ext .ts"
13
+ },
14
+ "keywords": [
15
+ "AIMA",
16
+ "VULOG",
17
+ "UNAVAILABILITY"
18
+ ],
19
+ "author": "Vulog",
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@vulog/aima-client": "1.2.7",
23
+ "@vulog/aima-core": "1.2.7"
24
+ },
25
+ "peerDependencies": {
26
+ "zod": "^3.25.76"
27
+ },
28
+ "description": ""
29
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, test, vi, expect, beforeEach } from 'vitest';
2
+
3
+ import { Client } from '@vulog/aima-client';
4
+ import { createUnavailability } from './createUnavailability';
5
+ import { Unavailability } from './types';
6
+
7
+ describe('createUnavailability', () => {
8
+ const postMock = vi.fn();
9
+ const client = {
10
+ post: postMock,
11
+ clientOptions: {
12
+ fleetId: 'FLEET_ID',
13
+ },
14
+ } as unknown as Client;
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ test('should return invalid args for invalid body', async () => {
21
+ const body = {
22
+ cronExpression: '',
23
+ duration: -1,
24
+ maintenanceTitle: '',
25
+ vehicleId: 'INVALID_UUID',
26
+ };
27
+
28
+ await expect(createUnavailability(client, body)).rejects.toThrow('Invalid args');
29
+ });
30
+
31
+ test('should create unavailability successfully', async () => {
32
+ const body = {
33
+ cronExpression: '0 0 23 * * ? *',
34
+ duration: 60,
35
+ maintenanceTitle: 'GetAround booking 234535',
36
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
37
+ };
38
+
39
+ const mockResponse: Unavailability = {
40
+ id: '550e8400-e29b-41d4-a716-446655440001',
41
+ cronExpression: '0 0 23 * * ? *',
42
+ duration: 60,
43
+ maintenanceTitle: 'GetAround booking 234535',
44
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
45
+ };
46
+
47
+ postMock.mockResolvedValueOnce({
48
+ data: mockResponse,
49
+ });
50
+
51
+ const result = await createUnavailability(client, body);
52
+ expect(result).toEqual(mockResponse);
53
+ expect(postMock).toBeCalledWith(
54
+ `/boapi/proxy/user/fleets/FLEET_ID/scheduledRules/unavailability`,
55
+ {
56
+ cronExpression: body.cronExpression,
57
+ duration: body.duration,
58
+ maintenanceTitle: body.maintenanceTitle,
59
+ vehicleId: body.vehicleId,
60
+ }
61
+ );
62
+ });
63
+ });
64
+
@@ -0,0 +1,31 @@
1
+ import { Client } from '@vulog/aima-client';
2
+ import { z } from 'zod';
3
+
4
+ import { CreateUnavailabilityBody, Unavailability } from './types';
5
+
6
+ const schema = z.object({
7
+ cronExpression: z.string().min(1),
8
+ duration: z.number().int().positive(),
9
+ maintenanceTitle: z.string().min(1),
10
+ vehicleId: z.string().uuid(),
11
+ });
12
+
13
+ export const createUnavailability = async (client: Client, body: CreateUnavailabilityBody): Promise<Unavailability> => {
14
+ const result = schema.safeParse(body);
15
+ if (!result.success) {
16
+ throw new TypeError('Invalid args', {
17
+ cause: result.error.issues,
18
+ });
19
+ }
20
+ return client
21
+ .post<Unavailability>(
22
+ `/boapi/proxy/user/fleets/${client.clientOptions.fleetId}/scheduledRules/unavailability`,
23
+ {
24
+ cronExpression: result.data.cronExpression,
25
+ duration: result.data.duration,
26
+ maintenanceTitle: result.data.maintenanceTitle,
27
+ vehicleId: result.data.vehicleId,
28
+ }
29
+ )
30
+ .then(({ data }) => data);
31
+ };
@@ -0,0 +1,71 @@
1
+ import { describe, test, vi, expect, beforeEach } from 'vitest';
2
+
3
+ import { Client } from '@vulog/aima-client';
4
+ import { deleteUnavailability } from './deleteUnavailability';
5
+
6
+ describe('deleteUnavailability', () => {
7
+ const deleteMock = vi.fn();
8
+ const client = {
9
+ delete: deleteMock,
10
+ clientOptions: {
11
+ fleetId: 'FLEET_ID',
12
+ },
13
+ } as unknown as Client;
14
+
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ test('should return invalid args for invalid id', async () => {
20
+ const id = 'INVALID_UUID';
21
+
22
+ await expect(deleteUnavailability(client, id)).rejects.toThrow('Invalid args');
23
+ });
24
+
25
+ test('should handle 404 gracefully (idempotent)', async () => {
26
+ const id = '550e8400-e29b-41d4-a716-446655440001';
27
+
28
+ deleteMock.mockRejectedValueOnce({
29
+ formattedError: {
30
+ status: 404,
31
+ },
32
+ });
33
+
34
+ const result = await deleteUnavailability(client, id);
35
+ expect(result).toBeUndefined();
36
+ expect(deleteMock).toBeCalledWith(
37
+ `/boapi/proxy/user/fleets/FLEET_ID/scheduledRules/unavailability/${id}`
38
+ );
39
+ });
40
+
41
+ test('should delete unavailability successfully', async () => {
42
+ const id = '550e8400-e29b-41d4-a716-446655440001';
43
+
44
+ deleteMock.mockResolvedValueOnce({
45
+ data: undefined,
46
+ });
47
+
48
+ const result = await deleteUnavailability(client, id);
49
+ expect(result).toBeUndefined();
50
+ expect(deleteMock).toBeCalledWith(
51
+ `/boapi/proxy/user/fleets/FLEET_ID/scheduledRules/unavailability/${id}`
52
+ );
53
+ });
54
+
55
+ test('should throw error on non-404 errors', async () => {
56
+ const id = '550e8400-e29b-41d4-a716-446655440001';
57
+
58
+ deleteMock.mockRejectedValueOnce({
59
+ formattedError: {
60
+ status: 500,
61
+ },
62
+ });
63
+
64
+ await expect(deleteUnavailability(client, id)).rejects.toEqual({
65
+ formattedError: {
66
+ status: 500,
67
+ },
68
+ });
69
+ });
70
+ });
71
+
@@ -0,0 +1,27 @@
1
+ import { Client } from '@vulog/aima-client';
2
+ import { z } from 'zod';
3
+
4
+ const schema = z.object({
5
+ id: z.string().trim().min(1).uuid(),
6
+ });
7
+
8
+ export const deleteUnavailability = async (client: Client, id: string): Promise<void> => {
9
+ const result = schema.safeParse({ id });
10
+ if (!result.success) {
11
+ throw new TypeError('Invalid args', {
12
+ cause: result.error.issues,
13
+ });
14
+ }
15
+ return client
16
+ .delete(
17
+ `/boapi/proxy/user/fleets/${client.clientOptions.fleetId}/scheduledRules/unavailability/${result.data.id}`
18
+ )
19
+ .then(() => undefined)
20
+ .catch((error) => {
21
+ // Handle 404 gracefully (idempotent - if already deleted, that's fine)
22
+ if (error.formattedError?.status === 404) {
23
+ return undefined;
24
+ }
25
+ throw error;
26
+ });
27
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, test, expect } from 'vitest';
2
+
3
+ import { generateCronExpression } from './generateCronExpression';
4
+
5
+ describe('generateCronExpression', () => {
6
+ test('should generate cron expression from Date object', () => {
7
+ const date = new Date('2018-08-14T23:00:00Z');
8
+ const cron = generateCronExpression(date);
9
+
10
+ // Note: This will depend on the local timezone where the test runs
11
+ // The date is in UTC, so we expect it to be converted to local time
12
+ // Just verify the format is correct
13
+ expect(cron).toMatch(/^\d+ \d+ \d+ \d+ 8 \? 2018$/);
14
+ expect(cron.split(' ').length).toBe(7);
15
+ });
16
+
17
+ test('should generate cron expression from ISO string', () => {
18
+ const dateStr = '2018-08-14T23:00:00Z';
19
+ const cron = generateCronExpression(dateStr);
20
+
21
+ // Just verify the format is correct (date will vary by timezone)
22
+ expect(cron).toMatch(/^\d+ \d+ \d+ \d+ 8 \? 2018$/);
23
+ expect(cron.split(' ').length).toBe(7);
24
+ });
25
+
26
+ test('should generate cron expression with specific timezone', () => {
27
+ // Test with Europe/Paris timezone (UTC+2 in summer)
28
+ const date = new Date('2018-08-14T21:00:00Z'); // 21:00 UTC = 23:00 CEST
29
+ const cron = generateCronExpression(date, 'Europe/Paris');
30
+
31
+ // Should be 23:00 in Paris timezone
32
+ expect(cron).toMatch(/^\d+ 0 23 14 8 \? 2018$/);
33
+ });
34
+
35
+ test('should generate cron expression with America/New_York timezone', () => {
36
+ // Test with America/New_York timezone (UTC-4 in summer)
37
+ const date = new Date('2018-08-14T23:00:00Z'); // 23:00 UTC = 19:00 EDT
38
+ const cron = generateCronExpression(date, 'America/New_York');
39
+
40
+ // Should be 19:00 in New York timezone
41
+ expect(cron).toMatch(/^\d+ 0 19 14 8 \? 2018$/);
42
+ });
43
+
44
+ test('should handle date with specific time components', () => {
45
+ const date = new Date('2018-08-14T15:30:45Z');
46
+ const cron = generateCronExpression(date, 'UTC');
47
+
48
+ // Should match: 45 30 15 14 8 ? 2018
49
+ expect(cron).toBe('45 30 15 14 8 ? 2018');
50
+ });
51
+
52
+ test('should handle date at midnight', () => {
53
+ const date = new Date('2018-08-14T00:00:00Z');
54
+ const cron = generateCronExpression(date, 'UTC');
55
+
56
+ expect(cron).toBe('0 0 0 14 8 ? 2018');
57
+ });
58
+
59
+ test('should handle date at end of day', () => {
60
+ const date = new Date('2018-08-14T23:59:59Z');
61
+ const cron = generateCronExpression(date, 'UTC');
62
+
63
+ expect(cron).toBe('59 59 23 14 8 ? 2018');
64
+ });
65
+
66
+ test('should handle different months correctly', () => {
67
+ const date = new Date('2018-12-25T12:00:00Z');
68
+ const cron = generateCronExpression(date, 'UTC');
69
+
70
+ expect(cron).toBe('0 0 12 25 12 ? 2018');
71
+ });
72
+
73
+ test('should handle different years correctly', () => {
74
+ const date = new Date('2024-01-01T00:00:00Z');
75
+ const cron = generateCronExpression(date, 'UTC');
76
+
77
+ expect(cron).toBe('0 0 0 1 1 ? 2024');
78
+ });
79
+
80
+ test('should throw error for invalid date string', () => {
81
+ expect(() => generateCronExpression('invalid-date')).toThrow('Invalid date provided');
82
+ });
83
+
84
+ test('should throw error for invalid Date object', () => {
85
+ const invalidDate = new Date('invalid');
86
+ expect(() => generateCronExpression(invalidDate)).toThrow('Invalid date provided');
87
+ });
88
+
89
+ test('should handle timezone conversion correctly for different dates', () => {
90
+ // Test with a date that might have different DST rules
91
+ const date = new Date('2018-01-14T12:00:00Z'); // Winter in Europe/Paris (UTC+1)
92
+ const cron = generateCronExpression(date, 'Europe/Paris');
93
+
94
+ // Should be 13:00 in Paris timezone (UTC+1 in winter)
95
+ expect(cron).toMatch(/^\d+ 0 13 14 1 \? 2018$/);
96
+ });
97
+
98
+ test('should handle edge case: last day of month', () => {
99
+ const date = new Date('2018-01-31T12:00:00Z');
100
+ const cron = generateCronExpression(date, 'UTC');
101
+
102
+ expect(cron).toBe('0 0 12 31 1 ? 2018');
103
+ });
104
+
105
+ test('should handle edge case: leap year February 29', () => {
106
+ const date = new Date('2020-02-29T12:00:00Z');
107
+ const cron = generateCronExpression(date, 'UTC');
108
+
109
+ expect(cron).toBe('0 0 12 29 2 ? 2020');
110
+ });
111
+ });
112
+
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Generates a Quartz cron expression from a date.
3
+ * Format: second minute hour day month dayOfWeek year
4
+ * Example: "0 0 23 14 8 ? 2018"
5
+ *
6
+ * @param date - The date to convert to cron expression (Date object or ISO string)
7
+ * @param timezone - Optional timezone string (e.g., "Europe/Paris", "America/New_York")
8
+ * If provided, the date will be converted to the specified timezone.
9
+ * If not provided, the date will be used as-is.
10
+ * @returns A cron expression string in Quartz format
11
+ */
12
+ export const generateCronExpression = (date: Date | string, timezone?: string): string => {
13
+ let dateObj: Date;
14
+
15
+ if (typeof date === 'string') {
16
+ dateObj = new Date(date);
17
+ } else {
18
+ dateObj = new Date(date);
19
+ }
20
+
21
+ if (Number.isNaN(dateObj.getTime())) {
22
+ throw new TypeError('Invalid date provided');
23
+ }
24
+
25
+ // If timezone is provided, convert to that timezone
26
+ if (timezone) {
27
+ // Use Intl.DateTimeFormat to get the date components in the specified timezone
28
+ const formatter = new Intl.DateTimeFormat('en-US', {
29
+ timeZone: timezone,
30
+ year: 'numeric',
31
+ month: '2-digit',
32
+ day: '2-digit',
33
+ hour: '2-digit',
34
+ minute: '2-digit',
35
+ second: '2-digit',
36
+ hour12: false,
37
+ });
38
+
39
+ const parts = formatter.formatToParts(dateObj);
40
+ const year = parseInt(parts.find((p) => p.type === 'year')?.value || '0', 10);
41
+ const month = parseInt(parts.find((p) => p.type === 'month')?.value || '0', 10);
42
+ const day = parseInt(parts.find((p) => p.type === 'day')?.value || '0', 10);
43
+ const hour = parseInt(parts.find((p) => p.type === 'hour')?.value || '0', 10);
44
+ const minute = parseInt(parts.find((p) => p.type === 'minute')?.value || '0', 10);
45
+ const second = parseInt(parts.find((p) => p.type === 'second')?.value || '0', 10);
46
+
47
+ // Format: second minute hour day month ? year
48
+ return `${second} ${minute} ${hour} ${day} ${month} ? ${year}`;
49
+ }
50
+
51
+ // If no timezone, use local time components
52
+ const year = dateObj.getFullYear();
53
+ const month = dateObj.getMonth() + 1; // getMonth() returns 0-11, cron expects 1-12
54
+ const day = dateObj.getDate();
55
+ const hour = dateObj.getHours();
56
+ const minute = dateObj.getMinutes();
57
+ const second = dateObj.getSeconds();
58
+
59
+ // Format: second minute hour day month ? year
60
+ return `${second} ${minute} ${hour} ${day} ${month} ? ${year}`;
61
+ };
@@ -0,0 +1,123 @@
1
+ import { describe, test, vi, expect, beforeEach } from 'vitest';
2
+
3
+ import { Client } from '@vulog/aima-client';
4
+ import { getUnavailabilities } from './getUnavailabilities';
5
+ import { Unavailability } from './types';
6
+
7
+ describe('getUnavailabilities', () => {
8
+ const getMock = vi.fn();
9
+ const client = {
10
+ get: getMock,
11
+ clientOptions: {
12
+ fleetId: 'FLEET_ID',
13
+ },
14
+ } as unknown as Client;
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ test('should return invalid args for invalid date format', async () => {
21
+ const options = {
22
+ from: 'invalid-date',
23
+ to: '2018-08-16T07:30:00Z',
24
+ };
25
+
26
+ await expect(getUnavailabilities(client, options)).rejects.toThrow('Invalid args');
27
+ });
28
+
29
+ test('should return empty array on 404', async () => {
30
+ const options = {
31
+ from: '2018-08-14T07:30:00Z',
32
+ to: '2018-08-16T07:30:00Z',
33
+ };
34
+
35
+ getMock.mockRejectedValueOnce({
36
+ formattedError: {
37
+ status: 404,
38
+ },
39
+ });
40
+
41
+ const result = await getUnavailabilities(client, options);
42
+ expect(result).toEqual([]);
43
+ });
44
+
45
+ test('should get unavailabilities successfully with pagination', async () => {
46
+ const options = {
47
+ from: '2018-08-14T07:30:00Z',
48
+ to: '2018-08-16T07:30:00Z',
49
+ page: 0,
50
+ };
51
+
52
+ const mockResponsePage1: Unavailability[] = [
53
+ {
54
+ id: '550e8400-e29b-41d4-a716-446655440001',
55
+ cronExpression: '0 0 23 * * ? *',
56
+ duration: 60,
57
+ maintenanceTitle: 'GetAround booking 234535',
58
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
59
+ },
60
+ ];
61
+
62
+ const mockResponsePage2: Unavailability[] = [];
63
+
64
+ getMock
65
+ .mockResolvedValueOnce({
66
+ data: mockResponsePage1,
67
+ })
68
+ .mockResolvedValueOnce({
69
+ data: mockResponsePage2,
70
+ });
71
+
72
+ const result = await getUnavailabilities(client, options);
73
+ expect(result).toEqual(mockResponsePage1);
74
+ expect(getMock).toBeCalledTimes(2);
75
+ });
76
+
77
+ test('should throw error when max pages reached', async () => {
78
+ const options = {
79
+ from: '2018-08-14T07:30:00Z',
80
+ to: '2018-08-16T07:30:00Z',
81
+ page: 0,
82
+ };
83
+
84
+ const mockResponse: Unavailability[] = [
85
+ {
86
+ id: '550e8400-e29b-41d4-a716-446655440001',
87
+ cronExpression: '0 0 23 * * ? *',
88
+ duration: 60,
89
+ maintenanceTitle: 'GetAround booking 234535',
90
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
91
+ },
92
+ ];
93
+
94
+ // Mock 51 pages to trigger max pages error
95
+ for (let i = 0; i < 51; i++) {
96
+ getMock.mockResolvedValueOnce({
97
+ data: mockResponse,
98
+ });
99
+ }
100
+
101
+ await expect(getUnavailabilities(client, options)).rejects.toThrow('Maximum page limit');
102
+ });
103
+
104
+ test('should throw error on non-404 errors', async () => {
105
+ const options = {
106
+ from: '2018-08-14T07:30:00Z',
107
+ to: '2018-08-16T07:30:00Z',
108
+ };
109
+
110
+ getMock.mockRejectedValueOnce({
111
+ formattedError: {
112
+ status: 500,
113
+ },
114
+ });
115
+
116
+ await expect(getUnavailabilities(client, options)).rejects.toEqual({
117
+ formattedError: {
118
+ status: 500,
119
+ },
120
+ });
121
+ });
122
+ });
123
+
@@ -0,0 +1,67 @@
1
+ import { Client } from '@vulog/aima-client';
2
+ import { z } from 'zod';
3
+
4
+ import { Unavailability, UnavailabilityOptions } from './types';
5
+
6
+ const schema = z.object({
7
+ from: z.string().datetime({ offset: false, precision: 0 }),
8
+ to: z.string().datetime({ offset: false, precision: 0 }),
9
+ page: z.number().int().nonnegative().default(0),
10
+ });
11
+
12
+ export const getUnavailabilities = async (
13
+ client: Client,
14
+ options: UnavailabilityOptions
15
+ ): Promise<Unavailability[]> => {
16
+ const result = schema.safeParse({
17
+ from: options.from,
18
+ to: options.to,
19
+ page: options.page ?? 0,
20
+ });
21
+ if (!result.success) {
22
+ throw new TypeError('Invalid args', {
23
+ cause: result.error.issues,
24
+ });
25
+ }
26
+
27
+ const allUnavailabilities: Unavailability[] = [];
28
+ let currentPage = result.data.page;
29
+ let hasMorePages = true;
30
+ const MAX_PAGES = 50;
31
+
32
+ while (hasMorePages) {
33
+ if (currentPage >= MAX_PAGES) {
34
+ throw new Error(
35
+ `Maximum page limit (${MAX_PAGES}) reached. This might indicate an issue with the pagination or a very large dataset.`
36
+ );
37
+ }
38
+
39
+ const queryParams = new URLSearchParams({
40
+ from: result.data.from,
41
+ to: result.data.to,
42
+ page: currentPage.toString(),
43
+ });
44
+
45
+ // eslint-disable-next-line no-await-in-loop -- Pagination requires sequential API calls
46
+ const unavailabilities = await client
47
+ .get<Unavailability[]>(
48
+ `/boapi/proxy/user/fleets/${client.clientOptions.fleetId}/scheduledRules/unavailability/vehicles?${queryParams.toString()}`
49
+ )
50
+ .then(({ data }) => data)
51
+ .catch((error) => {
52
+ if (error.formattedError?.status === 404) {
53
+ return [];
54
+ }
55
+ throw error;
56
+ });
57
+
58
+ allUnavailabilities.push(...unavailabilities);
59
+
60
+ // Stop if we received fewer unavailabilities than expected (including 0)
61
+ // Assuming page size is consistent, stop when we get an empty array
62
+ hasMorePages = unavailabilities.length > 0;
63
+ currentPage += 1;
64
+ }
65
+
66
+ return allUnavailabilities;
67
+ };
@@ -0,0 +1,79 @@
1
+ import { describe, test, vi, expect, beforeEach } from 'vitest';
2
+
3
+ import { Client } from '@vulog/aima-client';
4
+ import { getUnavailabilitiesByVehicle } from './getUnavailabilitiesByVehicle';
5
+ import { Unavailability } from './types';
6
+
7
+ describe('getUnavailabilitiesByVehicle', () => {
8
+ const getMock = vi.fn();
9
+ const client = {
10
+ get: getMock,
11
+ clientOptions: {
12
+ fleetId: 'FLEET_ID',
13
+ },
14
+ } as unknown as Client;
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ test('should return invalid args', async () => {
21
+ const vehicleId = 'INVALID_VEHICLE_ID';
22
+
23
+ await expect(getUnavailabilitiesByVehicle(client, vehicleId)).rejects.toThrow('Invalid args');
24
+ });
25
+
26
+ test('should return empty array on 404', async () => {
27
+ const vehicleId = '550e8400-e29b-41d4-a716-446655440000';
28
+
29
+ getMock.mockRejectedValueOnce({
30
+ formattedError: {
31
+ status: 404,
32
+ },
33
+ });
34
+
35
+ const result = await getUnavailabilitiesByVehicle(client, vehicleId);
36
+ expect(result).toEqual([]);
37
+ });
38
+
39
+ test('should get unavailabilities by vehicle successfully', async () => {
40
+ const vehicleId = '550e8400-e29b-41d4-a716-446655440000';
41
+
42
+ const mockResponse: Unavailability[] = [
43
+ {
44
+ id: '550e8400-e29b-41d4-a716-446655440001',
45
+ cronExpression: '0 0 23 * * ? *',
46
+ duration: 60,
47
+ maintenanceTitle: 'GetAround booking 234535',
48
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
49
+ },
50
+ ];
51
+
52
+ getMock.mockResolvedValueOnce({
53
+ data: mockResponse,
54
+ });
55
+
56
+ const result = await getUnavailabilitiesByVehicle(client, vehicleId);
57
+ expect(result).toEqual(mockResponse);
58
+ expect(getMock).toBeCalledWith(
59
+ `/boapi/proxy/user/fleets/FLEET_ID/scheduledRules/unavailability/vehicles/${vehicleId}`
60
+ );
61
+ });
62
+
63
+ test('should throw error on non-404 errors', async () => {
64
+ const vehicleId = '550e8400-e29b-41d4-a716-446655440000';
65
+
66
+ getMock.mockRejectedValueOnce({
67
+ formattedError: {
68
+ status: 500,
69
+ },
70
+ });
71
+
72
+ await expect(getUnavailabilitiesByVehicle(client, vehicleId)).rejects.toEqual({
73
+ formattedError: {
74
+ status: 500,
75
+ },
76
+ });
77
+ });
78
+ });
79
+
@@ -0,0 +1,28 @@
1
+ import { Client } from '@vulog/aima-client';
2
+ import { z } from 'zod';
3
+
4
+ import { Unavailability } from './types';
5
+
6
+ const schema = z.object({
7
+ vehicleId: z.string().trim().min(1).uuid(),
8
+ });
9
+
10
+ export const getUnavailabilitiesByVehicle = async (client: Client, vehicleId: string): Promise<Unavailability[]> => {
11
+ const result = schema.safeParse({ vehicleId });
12
+ if (!result.success) {
13
+ throw new TypeError('Invalid args', {
14
+ cause: result.error.issues,
15
+ });
16
+ }
17
+ return client
18
+ .get<Unavailability[]>(
19
+ `/boapi/proxy/user/fleets/${client.clientOptions.fleetId}/scheduledRules/unavailability/vehicles/${result.data.vehicleId}`
20
+ )
21
+ .then(({ data }) => data)
22
+ .catch((error) => {
23
+ if (error.formattedError?.status === 404) {
24
+ return [];
25
+ }
26
+ throw error;
27
+ });
28
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { getUnavailabilitiesByVehicle } from './getUnavailabilitiesByVehicle';
2
+ export { getUnavailabilities } from './getUnavailabilities';
3
+ export { createUnavailability } from './createUnavailability';
4
+ export { updateUnavailability } from './updateUnavailability';
5
+ export { deleteUnavailability } from './deleteUnavailability';
6
+ export { generateCronExpression } from './generateCronExpression';
7
+ export * from './types';
package/src/types.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type Unavailability = {
2
+ id: string;
3
+ cronExpression: string;
4
+ duration: number;
5
+ maintenanceTitle: string;
6
+ vehicleId: string;
7
+ };
8
+
9
+ export type CreateUnavailabilityBody = {
10
+ cronExpression: string;
11
+ duration: number;
12
+ maintenanceTitle: string;
13
+ vehicleId: string;
14
+ };
15
+
16
+ export type UpdateUnavailabilityBody = CreateUnavailabilityBody;
17
+
18
+ export type UnavailabilityOptions = {
19
+ from: string; // ISO date time
20
+ to: string; // ISO date time
21
+ page?: number;
22
+ };
@@ -0,0 +1,78 @@
1
+ import { describe, test, vi, expect, beforeEach } from 'vitest';
2
+
3
+ import { Client } from '@vulog/aima-client';
4
+ import { updateUnavailability } from './updateUnavailability';
5
+ import { Unavailability } from './types';
6
+
7
+ describe('updateUnavailability', () => {
8
+ const putMock = vi.fn();
9
+ const client = {
10
+ put: putMock,
11
+ clientOptions: {
12
+ fleetId: 'FLEET_ID',
13
+ },
14
+ } as unknown as Client;
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ });
19
+
20
+ test('should return invalid args for invalid id', async () => {
21
+ const id = 'INVALID_UUID';
22
+ const body = {
23
+ cronExpression: '0 0 23 * * ? *',
24
+ duration: 60,
25
+ maintenanceTitle: 'GetAround booking 234535',
26
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
27
+ };
28
+
29
+ await expect(updateUnavailability(client, id, body)).rejects.toThrow('Invalid args');
30
+ });
31
+
32
+ test('should return invalid args for invalid body', async () => {
33
+ const id = '550e8400-e29b-41d4-a716-446655440001';
34
+ const body = {
35
+ cronExpression: '',
36
+ duration: -1,
37
+ maintenanceTitle: '',
38
+ vehicleId: 'INVALID_UUID',
39
+ };
40
+
41
+ await expect(updateUnavailability(client, id, body)).rejects.toThrow('Invalid args');
42
+ });
43
+
44
+ test('should update unavailability successfully', async () => {
45
+ const id = '550e8400-e29b-41d4-a716-446655440001';
46
+ const body = {
47
+ cronExpression: '0 0 24 * * ? *',
48
+ duration: 120,
49
+ maintenanceTitle: 'GetAround booking 234535 updated',
50
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
51
+ };
52
+
53
+ const mockResponse: Unavailability = {
54
+ id: id,
55
+ cronExpression: '0 0 24 * * ? *',
56
+ duration: 120,
57
+ maintenanceTitle: 'GetAround booking 234535 updated',
58
+ vehicleId: '550e8400-e29b-41d4-a716-446655440000',
59
+ };
60
+
61
+ putMock.mockResolvedValueOnce({
62
+ data: mockResponse,
63
+ });
64
+
65
+ const result = await updateUnavailability(client, id, body);
66
+ expect(result).toEqual(mockResponse);
67
+ expect(putMock).toBeCalledWith(
68
+ `/boapi/proxy/user/fleets/FLEET_ID/scheduledRules/unavailability/${id}`,
69
+ {
70
+ cronExpression: body.cronExpression,
71
+ duration: body.duration,
72
+ maintenanceTitle: body.maintenanceTitle,
73
+ vehicleId: body.vehicleId,
74
+ }
75
+ );
76
+ });
77
+ });
78
+
@@ -0,0 +1,39 @@
1
+ import { Client } from '@vulog/aima-client';
2
+ import { z } from 'zod';
3
+
4
+ import { UpdateUnavailabilityBody, Unavailability } from './types';
5
+
6
+ const schema = z.object({
7
+ id: z.string().trim().min(1).uuid(),
8
+ cronExpression: z.string().min(1),
9
+ duration: z.number().int().positive(),
10
+ maintenanceTitle: z.string().min(1),
11
+ vehicleId: z.string().uuid(),
12
+ });
13
+
14
+ export const updateUnavailability = async (
15
+ client: Client,
16
+ id: string,
17
+ body: UpdateUnavailabilityBody
18
+ ): Promise<Unavailability> => {
19
+ const result = schema.safeParse({
20
+ id,
21
+ ...body,
22
+ });
23
+ if (!result.success) {
24
+ throw new TypeError('Invalid args', {
25
+ cause: result.error.issues,
26
+ });
27
+ }
28
+ return client
29
+ .put<Unavailability>(
30
+ `/boapi/proxy/user/fleets/${client.clientOptions.fleetId}/scheduledRules/unavailability/${result.data.id}`,
31
+ {
32
+ cronExpression: result.data.cronExpression,
33
+ duration: result.data.duration,
34
+ maintenanceTitle: result.data.maintenanceTitle,
35
+ vehicleId: result.data.vehicleId,
36
+ }
37
+ )
38
+ .then(({ data }) => data);
39
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "include": ["src"],
3
+ "exclude": ["**/*.test.ts"],
4
+ "compilerOptions": {
5
+ "module": "esnext",
6
+ "target": "esnext",
7
+ "lib": ["esnext"],
8
+ "declaration": true,
9
+ "strict": true,
10
+ "moduleResolution": "node",
11
+ "skipLibCheck": true,
12
+ "esModuleInterop": true,
13
+ "outDir": "dist",
14
+ "rootDir": "src"
15
+ }
16
+ }
17
+
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ clean: true,
6
+ format: ['cjs', 'esm'],
7
+ dts: true,
8
+ });
9
+
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ },
8
+ });
9
+